@indigoai-us/hq-cli 5.39.2 → 5.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/commands/auth.js +29 -5
- package/dist/commands/dm.d.ts +6 -0
- package/dist/commands/dm.js +86 -3
- package/dist/commands/members.js +12 -2
- package/dist/commands/whoami.js +21 -4
- package/dist/utils/cognito-session.js +29 -3
- package/package.json +3 -3
- package/src/commands/auth.ts +38 -3
- package/src/commands/dm.test.ts +44 -0
- package/src/commands/dm.ts +128 -1
- package/src/commands/members.test.ts +25 -0
- package/src/commands/members.ts +16 -0
- package/src/commands/whoami.ts +33 -3
- package/src/utils/cognito-session.machine.test.ts +114 -0
- package/src/utils/cognito-session.ts +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.39.3]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`hq members invite` no longer errors out on an existing member (HQ-11).**
|
|
10
|
+
Inviting (or `--resend`-ing) an email that is already an active member returns
|
|
11
|
+
`409 MEMBERSHIP_ALREADY_EXISTS`; the command used to print a red error and exit
|
|
12
|
+
`1`, so a re-run just re-fired the server's (correctly captured) 409 and a
|
|
13
|
+
bulk-invite script aborted on an already-member. It now treats that 409 as a
|
|
14
|
+
terminal "already a member" outcome — a plain note, exit `0`, no retry. The
|
|
15
|
+
409 stays the server's correct signal; only the client's needless re-attempt
|
|
16
|
+
is removed. Sibling to the hq-console fix.
|
|
17
|
+
|
|
5
18
|
## [5.39.0]
|
|
6
19
|
|
|
7
20
|
### Added
|
package/dist/commands/auth.js
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
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]="b9690c95-1d23-5c58-8cb3-0e3bdc8b35e8")}catch(e){}}();
|
|
18
18
|
import chalk from "chalk";
|
|
19
|
-
import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, CognitoAuthError, } from "@indigoai-us/hq-cloud";
|
|
19
|
+
import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
|
|
20
20
|
import { refreshCachedSession, } from "../utils/cognito-session.js";
|
|
21
21
|
import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
|
|
22
22
|
/**
|
|
@@ -32,12 +32,22 @@ function peekIdToken(idToken) {
|
|
|
32
32
|
const pad = payload.length % 4 === 0 ? "" : "=".repeat(4 - (payload.length % 4));
|
|
33
33
|
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/") + pad;
|
|
34
34
|
const decoded = JSON.parse(Buffer.from(normalized, "base64").toString("utf-8"));
|
|
35
|
-
return {
|
|
35
|
+
return {
|
|
36
|
+
email: decoded.email,
|
|
37
|
+
sub: decoded.sub,
|
|
38
|
+
entityType: decoded["custom:entityType"],
|
|
39
|
+
entityUid: decoded["custom:entityUid"],
|
|
40
|
+
};
|
|
36
41
|
}
|
|
37
42
|
catch {
|
|
38
43
|
return {};
|
|
39
44
|
}
|
|
40
45
|
}
|
|
46
|
+
/** Display label for the local machine identity (company agents). */
|
|
47
|
+
function machineIdentityLabel() {
|
|
48
|
+
const creds = loadMachineCreds();
|
|
49
|
+
return creds ? `machine identity ${creds.username}` : "machine identity";
|
|
50
|
+
}
|
|
41
51
|
export function registerAuthCommands(program) {
|
|
42
52
|
const authCmd = program
|
|
43
53
|
.command("auth")
|
|
@@ -47,6 +57,10 @@ export function registerAuthCommands(program) {
|
|
|
47
57
|
.description("Sign in to HQ — opens the Cognito Hosted UI and caches tokens locally")
|
|
48
58
|
.option("--provider <provider>", "OAuth provider to use: google, microsoft, or picker")
|
|
49
59
|
.action(async (options) => {
|
|
60
|
+
if (isMachineIdentity()) {
|
|
61
|
+
console.log(chalk.green(`Running as ${machineIdentityLabel()} — sessions mint automatically; no browser login needed.`));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
50
64
|
const existing = loadCachedTokens();
|
|
51
65
|
if (existing && !isExpiring(existing, 120)) {
|
|
52
66
|
const who = peekIdToken(existing.idToken).email ?? "cached session";
|
|
@@ -98,12 +112,22 @@ export function registerAuthCommands(program) {
|
|
|
98
112
|
.command("status")
|
|
99
113
|
.description("Show whether a valid HQ session is cached")
|
|
100
114
|
.action(() => {
|
|
115
|
+
const machine = isMachineIdentity();
|
|
101
116
|
const cached = loadCachedTokens();
|
|
102
117
|
if (!cached) {
|
|
118
|
+
if (machine) {
|
|
119
|
+
// No cached session yet, but machine creds mint one on demand —
|
|
120
|
+
// report ready, not signed-out.
|
|
121
|
+
console.log(chalk.green(`${machineIdentityLabel()} — no cached session yet (mints automatically on first use)`));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
103
124
|
console.log(chalk.yellow("No cached HQ session — run `hq auth login`"));
|
|
104
125
|
process.exit(1);
|
|
105
126
|
}
|
|
106
|
-
const
|
|
127
|
+
const claims = peekIdToken(cached.idToken);
|
|
128
|
+
const who = machine
|
|
129
|
+
? `${machineIdentityLabel()}${claims.entityUid ? ` (${claims.entityUid})` : ""}`
|
|
130
|
+
: claims.email;
|
|
107
131
|
const expiring = isExpiring(cached);
|
|
108
132
|
const label = who ? `${who} — ` : "";
|
|
109
133
|
console.log(expiring
|
|
@@ -112,4 +136,4 @@ export function registerAuthCommands(program) {
|
|
|
112
136
|
});
|
|
113
137
|
}
|
|
114
138
|
//# sourceMappingURL=auth.js.map
|
|
115
|
-
//# debugId=
|
|
139
|
+
//# debugId=b9690c95-1d23-5c58-8cb3-0e3bdc8b35e8
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -8,6 +8,12 @@ export interface DmRecipient {
|
|
|
8
8
|
* email/prs_ heuristic used by `hq members`. Returns null for neither.
|
|
9
9
|
*/
|
|
10
10
|
export declare function detectRecipient(recipient: string): DmRecipient | null;
|
|
11
|
+
/**
|
|
12
|
+
* A comma in the recipient arg signals a GROUP DM. Split into trimmed, de-duped
|
|
13
|
+
* recipient tokens (emails or personUids). Returns null when there's no comma
|
|
14
|
+
* (the normal 1:1 path). Pure → unit-testable.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseGroupRecipients(recipient: string): string[] | null;
|
|
11
17
|
/**
|
|
12
18
|
* Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
|
|
13
19
|
* Returns null on anything that doesn't match. Pure → unit-testable.
|
package/dist/commands/dm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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]="
|
|
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]="c29bef98-d624-5da1-86c9-b4dc9d6d1d7f")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
@@ -18,6 +18,20 @@ export function detectRecipient(recipient) {
|
|
|
18
18
|
return { toPersonUid: r };
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* A comma in the recipient arg signals a GROUP DM. Split into trimmed, de-duped
|
|
23
|
+
* recipient tokens (emails or personUids). Returns null when there's no comma
|
|
24
|
+
* (the normal 1:1 path). Pure → unit-testable.
|
|
25
|
+
*/
|
|
26
|
+
export function parseGroupRecipients(recipient) {
|
|
27
|
+
if (!recipient.includes(","))
|
|
28
|
+
return null;
|
|
29
|
+
const parts = recipient
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((s) => s.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
return [...new Set(parts)];
|
|
34
|
+
}
|
|
21
35
|
/**
|
|
22
36
|
* Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
|
|
23
37
|
* Returns null on anything that doesn't match. Pure → unit-testable.
|
|
@@ -178,7 +192,76 @@ async function runConnectionAction(action, identifier) {
|
|
|
178
192
|
process.exit(1);
|
|
179
193
|
}
|
|
180
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Group DM path: `hq dm send "a@x.com,b@y.com" "msg"`. Creates (or reopens, via
|
|
197
|
+
* the server's idempotent participant-key dedupe) a group channel, then posts
|
|
198
|
+
* the message into it. Reuses the same vault API client + auth as the 1:1 path.
|
|
199
|
+
*/
|
|
200
|
+
async function runGroupSend(recipients, message) {
|
|
201
|
+
try {
|
|
202
|
+
const participants = [];
|
|
203
|
+
for (const r of recipients) {
|
|
204
|
+
const rc = detectRecipient(r);
|
|
205
|
+
if (!rc) {
|
|
206
|
+
console.error(chalk.red(`Invalid recipient '${r}': each must be an email address or a personUid (prs_…).`));
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
participants.push(rc.toEmail ?? rc.toPersonUid);
|
|
210
|
+
}
|
|
211
|
+
if (participants.length < 2) {
|
|
212
|
+
console.error(chalk.red('A group DM needs at least 2 other people — list them comma-separated, e.g. hq dm send "a@x.com,b@y.com" "hi".'));
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
const body = (message ?? "").trim();
|
|
216
|
+
if (!body) {
|
|
217
|
+
console.error(chalk.red('A message body is required: hq dm send "a@x,b@y" <message>'));
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
const token = await ensureCognitoToken();
|
|
221
|
+
// 1) Create or reopen the group (idempotent on the participant set).
|
|
222
|
+
const createRes = await vaultApiFetch({
|
|
223
|
+
token,
|
|
224
|
+
path: "/v1/notify/channels",
|
|
225
|
+
method: "POST",
|
|
226
|
+
body: { scope: "group", participants },
|
|
227
|
+
});
|
|
228
|
+
if (!createRes.ok) {
|
|
229
|
+
const err = (await createRes.json().catch(() => ({})));
|
|
230
|
+
console.error(chalk.red(friendlyDmError(createRes.status, err.code, err.error ?? err.message ?? createRes.statusText)));
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
const createData = (await createRes.json());
|
|
234
|
+
const channelId = createData.channel?.channelId;
|
|
235
|
+
if (!channelId) {
|
|
236
|
+
console.error(chalk.red("Group create returned no channel id."));
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
// 2) Post the message into the group.
|
|
240
|
+
const sendRes = await vaultApiFetch({
|
|
241
|
+
token,
|
|
242
|
+
path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
|
|
243
|
+
method: "POST",
|
|
244
|
+
body: { body },
|
|
245
|
+
});
|
|
246
|
+
if (!sendRes.ok) {
|
|
247
|
+
const err = (await sendRes.json().catch(() => ({})));
|
|
248
|
+
console.error(chalk.red(friendlyDmError(sendRes.status, err.code, err.error ?? err.message ?? sendRes.statusText)));
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
console.log(chalk.green(`Group DM sent to ${participants.length} people${createData.created ? " (new group)" : ""}.`));
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
181
258
|
async function runDmSend(recipient, message, opts) {
|
|
259
|
+
// A comma in the recipient means a group DM — fan into the channel path.
|
|
260
|
+
const group = parseGroupRecipients(recipient);
|
|
261
|
+
if (group) {
|
|
262
|
+
await runGroupSend(group, message);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
182
265
|
try {
|
|
183
266
|
// Resolve prompt/details from inline text or a file.
|
|
184
267
|
let prompt = opts.prompt;
|
|
@@ -261,7 +344,7 @@ export function registerDmCommand(program) {
|
|
|
261
344
|
.description("Send a direct message and manage connection requests.");
|
|
262
345
|
dm
|
|
263
346
|
.command("send <recipient> [message]", { isDefault: true, hidden: true })
|
|
264
|
-
.description(
|
|
347
|
+
.description('Send a direct message to someone (email or personUid). They receive it as an HQ Sync notification. If you aren\'t connected yet, it sends a connection request that holds your message. For a GROUP DM, pass a comma-separated recipient: hq dm send "a@x.com,b@y.com" "hi".')
|
|
265
348
|
.option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent")
|
|
266
349
|
.option("--prompt-file <path>", "Read the agent prompt from a file")
|
|
267
350
|
.option("--details <text>", "Longer detail shown in the recipient's DM detail window")
|
|
@@ -297,4 +380,4 @@ export function registerDmCommand(program) {
|
|
|
297
380
|
});
|
|
298
381
|
}
|
|
299
382
|
//# sourceMappingURL=dm.js.map
|
|
300
|
-
//# debugId=
|
|
383
|
+
//# debugId=c29bef98-d624-5da1-86c9-b4dc9d6d1d7f
|
package/dist/commands/members.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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]="
|
|
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]="b2b63537-b45f-59d6-8783-172b7edcacd6")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
5
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
@@ -366,6 +366,16 @@ export function registerMembersCommand(program) {
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
catch (err) {
|
|
369
|
+
// The target is ALREADY an active member (409 MEMBERSHIP_ALREADY_EXISTS).
|
|
370
|
+
// Terminal, not a failure: the person is already in the company, so
|
|
371
|
+
// there's nothing to invite or retry. Print a plain note and exit 0 —
|
|
372
|
+
// re-attempting just re-fires the server's (correctly captured) 409,
|
|
373
|
+
// and a bulk-invite script shouldn't abort on an already-member. HQ-11.
|
|
374
|
+
if (err instanceof InviteHttpError &&
|
|
375
|
+
err.code === "MEMBERSHIP_ALREADY_EXISTS") {
|
|
376
|
+
console.log(chalk.yellow(`${target.toLowerCase()} is already a member of this company — nothing to do.`));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
369
379
|
if (err instanceof InviteHttpError) {
|
|
370
380
|
console.error(chalk.red(formatInviteHttpError(err.status, err.message, err.code)));
|
|
371
381
|
process.exit(1);
|
|
@@ -451,4 +461,4 @@ export function registerMembersCommand(program) {
|
|
|
451
461
|
});
|
|
452
462
|
}
|
|
453
463
|
//# sourceMappingURL=members.js.map
|
|
454
|
-
//# debugId=
|
|
464
|
+
//# debugId=b2b63537-b45f-59d6-8783-172b7edcacd6
|
package/dist/commands/whoami.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* hq whoami — displays current user or 'not logged in'
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
!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]="
|
|
5
|
+
!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]="1603fcc9-75f6-5855-b7b2-8333ffda9389")}catch(e){}}();
|
|
6
6
|
import chalk from 'chalk';
|
|
7
|
-
import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
|
|
7
|
+
import { loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, } from '@indigoai-us/hq-cloud';
|
|
8
8
|
function peekIdToken(idToken) {
|
|
9
9
|
try {
|
|
10
10
|
const payload = idToken.split('.')[1];
|
|
@@ -13,7 +13,12 @@ function peekIdToken(idToken) {
|
|
|
13
13
|
const pad = payload.length % 4 === 0 ? '' : '='.repeat(4 - (payload.length % 4));
|
|
14
14
|
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
|
15
15
|
const decoded = JSON.parse(Buffer.from(normalized, 'base64').toString('utf-8'));
|
|
16
|
-
return {
|
|
16
|
+
return {
|
|
17
|
+
email: decoded.email,
|
|
18
|
+
sub: decoded.sub,
|
|
19
|
+
entityType: decoded['custom:entityType'],
|
|
20
|
+
entityUid: decoded['custom:entityUid'],
|
|
21
|
+
};
|
|
17
22
|
}
|
|
18
23
|
catch {
|
|
19
24
|
return {};
|
|
@@ -25,7 +30,19 @@ export function registerWhoamiCommand(program) {
|
|
|
25
30
|
.description('Show the currently authenticated user')
|
|
26
31
|
.action(async () => {
|
|
27
32
|
try {
|
|
33
|
+
const machine = isMachineIdentity();
|
|
28
34
|
const cached = loadCachedTokens();
|
|
35
|
+
if (machine) {
|
|
36
|
+
// Machine identities (company agents) mint sessions on demand from
|
|
37
|
+
// long-lived creds — report the machine identity honestly even when
|
|
38
|
+
// no session is cached yet.
|
|
39
|
+
const username = loadMachineCreds()?.username ?? 'unknown';
|
|
40
|
+
const entityUid = cached
|
|
41
|
+
? peekIdToken(cached.idToken).entityUid
|
|
42
|
+
: undefined;
|
|
43
|
+
console.log(`Machine identity ${username}${entityUid ? ` (agent ${entityUid})` : ''} — sessions mint automatically`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
29
46
|
if (!cached) {
|
|
30
47
|
console.log("Not logged in. Run 'hq login' to authenticate.");
|
|
31
48
|
return;
|
|
@@ -45,4 +62,4 @@ export function registerWhoamiCommand(program) {
|
|
|
45
62
|
});
|
|
46
63
|
}
|
|
47
64
|
//# sourceMappingURL=whoami.js.map
|
|
48
|
-
//# debugId=
|
|
65
|
+
//# debugId=1603fcc9-75f6-5855-b7b2-8333ffda9389
|
|
@@ -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]="783fe478-9e12-55a7-a3d8-241ea2cf5886")}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, } from "@indigoai-us/hq-cloud";
|
|
28
|
+
import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, mintMachineTokens, } 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",
|
|
@@ -232,6 +232,18 @@ export const DEFAULT_HQ_ROOT = resolveDefaultHqRoot();
|
|
|
232
232
|
*/
|
|
233
233
|
export async function ensureCognitoToken(options = {}) {
|
|
234
234
|
const interactive = options.interactive ?? true;
|
|
235
|
+
// Machine identities (company agents) mint sessions on demand via
|
|
236
|
+
// USER_PASSWORD_AUTH — no refresh-token dance, no browser. The vault API's
|
|
237
|
+
// JWT authorizer accepts ID tokens, and the agent's identity claims
|
|
238
|
+
// (custom:entityType=agent, custom:entityUid=agt_*) ride the ID token ONLY,
|
|
239
|
+
// so vault-API calls from a machine identity send the ID token. The cached
|
|
240
|
+
// token file keeps correct field semantics (real access token in
|
|
241
|
+
// accessToken) for consumers that need token_use=access, e.g. the deploy
|
|
242
|
+
// API via the deploy skill.
|
|
243
|
+
if (isMachineIdentity()) {
|
|
244
|
+
const machine = await getValidMachineTokens(DEFAULT_COGNITO);
|
|
245
|
+
return machine.idToken;
|
|
246
|
+
}
|
|
235
247
|
const cached = loadCachedTokens();
|
|
236
248
|
if (cached && !isExpiring(cached, 120)) {
|
|
237
249
|
return cached.accessToken;
|
|
@@ -286,6 +298,20 @@ export function buildVaultConfig(authToken) {
|
|
|
286
298
|
* with a reason string so the caller can decide what to do.
|
|
287
299
|
*/
|
|
288
300
|
export async function refreshCachedSession() {
|
|
301
|
+
// Machine identities have no refresh token — a "refresh" is a fresh mint
|
|
302
|
+
// from the long-lived machine creds.
|
|
303
|
+
if (isMachineIdentity()) {
|
|
304
|
+
try {
|
|
305
|
+
await mintMachineTokens(DEFAULT_COGNITO);
|
|
306
|
+
return { refreshed: true };
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
return {
|
|
310
|
+
refreshed: false,
|
|
311
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
289
315
|
const cached = loadCachedTokens();
|
|
290
316
|
if (!cached) {
|
|
291
317
|
return { refreshed: false, reason: "no cached session" };
|
|
@@ -302,4 +328,4 @@ export async function refreshCachedSession() {
|
|
|
302
328
|
}
|
|
303
329
|
}
|
|
304
330
|
//# sourceMappingURL=cognito-session.js.map
|
|
305
|
-
//# debugId=
|
|
331
|
+
//# debugId=783fe478-9e12-55a7-a3d8-241ea2cf5886
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
4
|
-
"description": "HQ by Indigo management CLI
|
|
3
|
+
"version": "5.40.0",
|
|
4
|
+
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"hq": "dist/index.js",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"clean": "rm -rf dist"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@indigoai-us/hq-cloud": "^6.
|
|
18
|
+
"@indigoai-us/hq-cloud": "^6.7.0",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|
package/src/commands/auth.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
clearCachedTokens,
|
|
22
22
|
loadCachedTokens,
|
|
23
23
|
isExpiring,
|
|
24
|
+
isMachineIdentity,
|
|
25
|
+
loadMachineCreds,
|
|
24
26
|
CognitoAuthError,
|
|
25
27
|
} from "@indigoai-us/hq-cloud";
|
|
26
28
|
import {
|
|
@@ -36,7 +38,7 @@ import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
|
|
|
36
38
|
*/
|
|
37
39
|
function peekIdToken(
|
|
38
40
|
idToken: string,
|
|
39
|
-
): { email?: string; sub?: string } {
|
|
41
|
+
): { email?: string; sub?: string; entityType?: string; entityUid?: string } {
|
|
40
42
|
try {
|
|
41
43
|
const payload = idToken.split(".")[1];
|
|
42
44
|
if (!payload) return {};
|
|
@@ -47,12 +49,23 @@ function peekIdToken(
|
|
|
47
49
|
const decoded = JSON.parse(
|
|
48
50
|
Buffer.from(normalized, "base64").toString("utf-8"),
|
|
49
51
|
);
|
|
50
|
-
return {
|
|
52
|
+
return {
|
|
53
|
+
email: decoded.email,
|
|
54
|
+
sub: decoded.sub,
|
|
55
|
+
entityType: decoded["custom:entityType"],
|
|
56
|
+
entityUid: decoded["custom:entityUid"],
|
|
57
|
+
};
|
|
51
58
|
} catch {
|
|
52
59
|
return {};
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
/** Display label for the local machine identity (company agents). */
|
|
64
|
+
function machineIdentityLabel(): string {
|
|
65
|
+
const creds = loadMachineCreds();
|
|
66
|
+
return creds ? `machine identity ${creds.username}` : "machine identity";
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
export function registerAuthCommands(program: Command): void {
|
|
57
70
|
const authCmd = program
|
|
58
71
|
.command("auth")
|
|
@@ -68,6 +81,14 @@ export function registerAuthCommands(program: Command): void {
|
|
|
68
81
|
"OAuth provider to use: google, microsoft, or picker",
|
|
69
82
|
)
|
|
70
83
|
.action(async (options: { provider?: string }) => {
|
|
84
|
+
if (isMachineIdentity()) {
|
|
85
|
+
console.log(
|
|
86
|
+
chalk.green(
|
|
87
|
+
`Running as ${machineIdentityLabel()} — sessions mint automatically; no browser login needed.`,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
71
92
|
const existing = loadCachedTokens();
|
|
72
93
|
if (existing && !isExpiring(existing, 120)) {
|
|
73
94
|
const who = peekIdToken(existing.idToken).email ?? "cached session";
|
|
@@ -134,12 +155,26 @@ export function registerAuthCommands(program: Command): void {
|
|
|
134
155
|
.command("status")
|
|
135
156
|
.description("Show whether a valid HQ session is cached")
|
|
136
157
|
.action(() => {
|
|
158
|
+
const machine = isMachineIdentity();
|
|
137
159
|
const cached = loadCachedTokens();
|
|
138
160
|
if (!cached) {
|
|
161
|
+
if (machine) {
|
|
162
|
+
// No cached session yet, but machine creds mint one on demand —
|
|
163
|
+
// report ready, not signed-out.
|
|
164
|
+
console.log(
|
|
165
|
+
chalk.green(
|
|
166
|
+
`${machineIdentityLabel()} — no cached session yet (mints automatically on first use)`,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
139
171
|
console.log(chalk.yellow("No cached HQ session — run `hq auth login`"));
|
|
140
172
|
process.exit(1);
|
|
141
173
|
}
|
|
142
|
-
const
|
|
174
|
+
const claims = peekIdToken(cached.idToken);
|
|
175
|
+
const who = machine
|
|
176
|
+
? `${machineIdentityLabel()}${claims.entityUid ? ` (${claims.entityUid})` : ""}`
|
|
177
|
+
: claims.email;
|
|
143
178
|
const expiring = isExpiring(cached);
|
|
144
179
|
const label = who ? `${who} — ` : "";
|
|
145
180
|
console.log(
|
package/src/commands/dm.test.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { Command } from "commander";
|
|
|
21
21
|
import {
|
|
22
22
|
detectRecipient,
|
|
23
23
|
parseDuration,
|
|
24
|
+
parseGroupRecipients,
|
|
24
25
|
buildDmBody,
|
|
25
26
|
matchRequest,
|
|
26
27
|
buildConnectionActionBody,
|
|
@@ -28,6 +29,19 @@ import {
|
|
|
28
29
|
type ConnectionRequest,
|
|
29
30
|
} from "./dm.js";
|
|
30
31
|
|
|
32
|
+
describe("parseGroupRecipients", () => {
|
|
33
|
+
it("returns null with no comma (the 1:1 path)", () => {
|
|
34
|
+
expect(parseGroupRecipients("a@x.com")).toBeNull();
|
|
35
|
+
expect(parseGroupRecipients("prs_1")).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
it("splits, trims, and de-dupes a comma list", () => {
|
|
38
|
+
expect(parseGroupRecipients("a@x.com, b@y.com ,a@x.com")).toEqual([
|
|
39
|
+
"a@x.com",
|
|
40
|
+
"b@y.com",
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
31
45
|
describe("detectRecipient", () => {
|
|
32
46
|
it("classifies an email", () => {
|
|
33
47
|
expect(detectRecipient("Stefan@Getindigo.ai")).toEqual({
|
|
@@ -220,6 +234,36 @@ describe("dm command actions", () => {
|
|
|
220
234
|
expect(errSpy).not.toHaveBeenCalled();
|
|
221
235
|
});
|
|
222
236
|
|
|
237
|
+
it("group send: a comma recipient creates a group then posts the message", async () => {
|
|
238
|
+
fetchSpy
|
|
239
|
+
.mockResolvedValueOnce(
|
|
240
|
+
jsonResponse(200, { channel: { channelId: "chn_grp1" }, created: true }),
|
|
241
|
+
)
|
|
242
|
+
.mockResolvedValueOnce(jsonResponse(200, { eventId: "evt_g" }));
|
|
243
|
+
await program.parseAsync(
|
|
244
|
+
["dm", "alice@example.com,bob@example.com", "hey team"],
|
|
245
|
+
{ from: "user" },
|
|
246
|
+
);
|
|
247
|
+
// 1st call creates the group channel...
|
|
248
|
+
const createCall = fetchSpy.mock.calls[0];
|
|
249
|
+
expect(String(createCall[0])).toContain("/v1/notify/channels");
|
|
250
|
+
const createBody = JSON.parse((createCall[1]?.body as string) ?? "{}");
|
|
251
|
+
expect(createBody.scope).toBe("group");
|
|
252
|
+
expect(createBody.participants).toEqual(["alice@example.com", "bob@example.com"]);
|
|
253
|
+
// ...2nd posts the message into it.
|
|
254
|
+
const sendCall = fetchSpy.mock.calls[1];
|
|
255
|
+
expect(String(sendCall[0])).toContain("/v1/notify/channels/chn_grp1/messages");
|
|
256
|
+
expect(JSON.parse((sendCall[1]?.body as string) ?? "{}").body).toBe("hey team");
|
|
257
|
+
expect(logged()).toMatch(/Group DM sent to 2 people \(new group\)\./);
|
|
258
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("group send: a single recipient still uses the 1:1 dm path", async () => {
|
|
262
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { eventId: "evt_1" }));
|
|
263
|
+
await program.parseAsync(["dm", "alice@example.com", "hi"], { from: "user" });
|
|
264
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain("/v1/notify/dm");
|
|
265
|
+
});
|
|
266
|
+
|
|
223
267
|
it("send: prints pending request on 202 connection_requested (not an error)", async () => {
|
|
224
268
|
fetchSpy.mockResolvedValueOnce(
|
|
225
269
|
jsonResponse(202, { state: "connection_requested" }),
|
package/src/commands/dm.ts
CHANGED
|
@@ -23,6 +23,20 @@ export function detectRecipient(recipient: string): DmRecipient | null {
|
|
|
23
23
|
return null;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* A comma in the recipient arg signals a GROUP DM. Split into trimmed, de-duped
|
|
28
|
+
* recipient tokens (emails or personUids). Returns null when there's no comma
|
|
29
|
+
* (the normal 1:1 path). Pure → unit-testable.
|
|
30
|
+
*/
|
|
31
|
+
export function parseGroupRecipients(recipient: string): string[] | null {
|
|
32
|
+
if (!recipient.includes(",")) return null;
|
|
33
|
+
const parts = recipient
|
|
34
|
+
.split(",")
|
|
35
|
+
.map((s) => s.trim())
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
return [...new Set(parts)];
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
/**
|
|
27
41
|
* Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
|
|
28
42
|
* Returns null on anything that doesn't match. Pure → unit-testable.
|
|
@@ -248,11 +262,124 @@ interface DmSendOpts {
|
|
|
248
262
|
in?: string;
|
|
249
263
|
}
|
|
250
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Group DM path: `hq dm send "a@x.com,b@y.com" "msg"`. Creates (or reopens, via
|
|
267
|
+
* the server's idempotent participant-key dedupe) a group channel, then posts
|
|
268
|
+
* the message into it. Reuses the same vault API client + auth as the 1:1 path.
|
|
269
|
+
*/
|
|
270
|
+
async function runGroupSend(
|
|
271
|
+
recipients: string[],
|
|
272
|
+
message: string | undefined,
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
try {
|
|
275
|
+
const participants: string[] = [];
|
|
276
|
+
for (const r of recipients) {
|
|
277
|
+
const rc = detectRecipient(r);
|
|
278
|
+
if (!rc) {
|
|
279
|
+
console.error(
|
|
280
|
+
chalk.red(
|
|
281
|
+
`Invalid recipient '${r}': each must be an email address or a personUid (prs_…).`,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
participants.push(rc.toEmail ?? rc.toPersonUid!);
|
|
287
|
+
}
|
|
288
|
+
if (participants.length < 2) {
|
|
289
|
+
console.error(
|
|
290
|
+
chalk.red(
|
|
291
|
+
'A group DM needs at least 2 other people — list them comma-separated, e.g. hq dm send "a@x.com,b@y.com" "hi".',
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
const body = (message ?? "").trim();
|
|
297
|
+
if (!body) {
|
|
298
|
+
console.error(
|
|
299
|
+
chalk.red('A message body is required: hq dm send "a@x,b@y" <message>'),
|
|
300
|
+
);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const token = await ensureCognitoToken();
|
|
305
|
+
|
|
306
|
+
// 1) Create or reopen the group (idempotent on the participant set).
|
|
307
|
+
const createRes = await vaultApiFetch({
|
|
308
|
+
token,
|
|
309
|
+
path: "/v1/notify/channels",
|
|
310
|
+
method: "POST",
|
|
311
|
+
body: { scope: "group", participants },
|
|
312
|
+
});
|
|
313
|
+
if (!createRes.ok) {
|
|
314
|
+
const err = (await createRes.json().catch(() => ({}))) as Record<string, string>;
|
|
315
|
+
console.error(
|
|
316
|
+
chalk.red(
|
|
317
|
+
friendlyDmError(
|
|
318
|
+
createRes.status,
|
|
319
|
+
err.code,
|
|
320
|
+
err.error ?? err.message ?? createRes.statusText,
|
|
321
|
+
),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
const createData = (await createRes.json()) as {
|
|
327
|
+
channel?: { channelId?: string };
|
|
328
|
+
created?: boolean;
|
|
329
|
+
};
|
|
330
|
+
const channelId = createData.channel?.channelId;
|
|
331
|
+
if (!channelId) {
|
|
332
|
+
console.error(chalk.red("Group create returned no channel id."));
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 2) Post the message into the group.
|
|
337
|
+
const sendRes = await vaultApiFetch({
|
|
338
|
+
token,
|
|
339
|
+
path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
|
|
340
|
+
method: "POST",
|
|
341
|
+
body: { body },
|
|
342
|
+
});
|
|
343
|
+
if (!sendRes.ok) {
|
|
344
|
+
const err = (await sendRes.json().catch(() => ({}))) as Record<string, string>;
|
|
345
|
+
console.error(
|
|
346
|
+
chalk.red(
|
|
347
|
+
friendlyDmError(
|
|
348
|
+
sendRes.status,
|
|
349
|
+
err.code,
|
|
350
|
+
err.error ?? err.message ?? sendRes.statusText,
|
|
351
|
+
),
|
|
352
|
+
),
|
|
353
|
+
);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
console.log(
|
|
357
|
+
chalk.green(
|
|
358
|
+
`Group DM sent to ${participants.length} people${
|
|
359
|
+
createData.created ? " (new group)" : ""
|
|
360
|
+
}.`,
|
|
361
|
+
),
|
|
362
|
+
);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
console.error(
|
|
365
|
+
chalk.red("Error:"),
|
|
366
|
+
err instanceof Error ? err.message : String(err),
|
|
367
|
+
);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
251
372
|
async function runDmSend(
|
|
252
373
|
recipient: string,
|
|
253
374
|
message: string | undefined,
|
|
254
375
|
opts: DmSendOpts,
|
|
255
376
|
): Promise<void> {
|
|
377
|
+
// A comma in the recipient means a group DM — fan into the channel path.
|
|
378
|
+
const group = parseGroupRecipients(recipient);
|
|
379
|
+
if (group) {
|
|
380
|
+
await runGroupSend(group, message);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
256
383
|
try {
|
|
257
384
|
// Resolve prompt/details from inline text or a file.
|
|
258
385
|
let prompt = opts.prompt;
|
|
@@ -376,7 +503,7 @@ export function registerDmCommand(program: Command): void {
|
|
|
376
503
|
dm
|
|
377
504
|
.command("send <recipient> [message]", { isDefault: true, hidden: true })
|
|
378
505
|
.description(
|
|
379
|
-
|
|
506
|
+
'Send a direct message to someone (email or personUid). They receive it as an HQ Sync notification. If you aren\'t connected yet, it sends a connection request that holds your message. For a GROUP DM, pass a comma-separated recipient: hq dm send "a@x.com,b@y.com" "hi".',
|
|
380
507
|
)
|
|
381
508
|
.option(
|
|
382
509
|
"--prompt <text>",
|
|
@@ -225,6 +225,31 @@ describe("inviteMember", () => {
|
|
|
225
225
|
).rejects.toBeInstanceOf(InviteHttpError);
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
+
// HQ-11: inviting an already-active member returns 409 with the machine code
|
|
229
|
+
// MEMBERSHIP_ALREADY_EXISTS. The code must ride on the InviteHttpError so the
|
|
230
|
+
// `invite` command can treat it as a terminal "already a member" (no retry).
|
|
231
|
+
it("surfaces the server `code` (MEMBERSHIP_ALREADY_EXISTS) on the InviteHttpError", async () => {
|
|
232
|
+
fetchSpy.mockResolvedValueOnce(
|
|
233
|
+
jsonResponse(409, {
|
|
234
|
+
error:
|
|
235
|
+
"Membership already exists for person email:alice@example.com in company cmp_acme",
|
|
236
|
+
code: "MEMBERSHIP_ALREADY_EXISTS",
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const err = await inviteMember({
|
|
241
|
+
target: "alice@example.com",
|
|
242
|
+
role: "member",
|
|
243
|
+
companyUid: "cmp_acme",
|
|
244
|
+
callerUid: "prs_admin",
|
|
245
|
+
token: "test-token",
|
|
246
|
+
}).catch((e: unknown) => e);
|
|
247
|
+
|
|
248
|
+
expect(err).toBeInstanceOf(InviteHttpError);
|
|
249
|
+
expect((err as InviteHttpError).status).toBe(409);
|
|
250
|
+
expect((err as InviteHttpError).code).toBe("MEMBERSHIP_ALREADY_EXISTS");
|
|
251
|
+
});
|
|
252
|
+
|
|
228
253
|
it("resolves the token when it is nested on the membership row", async () => {
|
|
229
254
|
fetchSpy.mockResolvedValueOnce(
|
|
230
255
|
jsonResponse(200, {
|
package/src/commands/members.ts
CHANGED
|
@@ -650,6 +650,22 @@ export function registerMembersCommand(program: Command): void {
|
|
|
650
650
|
);
|
|
651
651
|
}
|
|
652
652
|
} catch (err) {
|
|
653
|
+
// The target is ALREADY an active member (409 MEMBERSHIP_ALREADY_EXISTS).
|
|
654
|
+
// Terminal, not a failure: the person is already in the company, so
|
|
655
|
+
// there's nothing to invite or retry. Print a plain note and exit 0 —
|
|
656
|
+
// re-attempting just re-fires the server's (correctly captured) 409,
|
|
657
|
+
// and a bulk-invite script shouldn't abort on an already-member. HQ-11.
|
|
658
|
+
if (
|
|
659
|
+
err instanceof InviteHttpError &&
|
|
660
|
+
err.code === "MEMBERSHIP_ALREADY_EXISTS"
|
|
661
|
+
) {
|
|
662
|
+
console.log(
|
|
663
|
+
chalk.yellow(
|
|
664
|
+
`${target.toLowerCase()} is already a member of this company — nothing to do.`,
|
|
665
|
+
),
|
|
666
|
+
);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
653
669
|
if (err instanceof InviteHttpError) {
|
|
654
670
|
console.error(
|
|
655
671
|
chalk.red(
|
package/src/commands/whoami.ts
CHANGED
|
@@ -4,16 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
import { Command } from 'commander';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
loadCachedTokens,
|
|
9
|
+
isExpiring,
|
|
10
|
+
isMachineIdentity,
|
|
11
|
+
loadMachineCreds,
|
|
12
|
+
} from '@indigoai-us/hq-cloud';
|
|
8
13
|
|
|
9
|
-
function peekIdToken(idToken: string): {
|
|
14
|
+
function peekIdToken(idToken: string): {
|
|
15
|
+
email?: string;
|
|
16
|
+
sub?: string;
|
|
17
|
+
entityType?: string;
|
|
18
|
+
entityUid?: string;
|
|
19
|
+
} {
|
|
10
20
|
try {
|
|
11
21
|
const payload = idToken.split('.')[1];
|
|
12
22
|
if (!payload) return {};
|
|
13
23
|
const pad = payload.length % 4 === 0 ? '' : '='.repeat(4 - (payload.length % 4));
|
|
14
24
|
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
|
15
25
|
const decoded = JSON.parse(Buffer.from(normalized, 'base64').toString('utf-8'));
|
|
16
|
-
return {
|
|
26
|
+
return {
|
|
27
|
+
email: decoded.email,
|
|
28
|
+
sub: decoded.sub,
|
|
29
|
+
entityType: decoded['custom:entityType'],
|
|
30
|
+
entityUid: decoded['custom:entityUid'],
|
|
31
|
+
};
|
|
17
32
|
} catch {
|
|
18
33
|
return {};
|
|
19
34
|
}
|
|
@@ -25,8 +40,23 @@ export function registerWhoamiCommand(program: Command): void {
|
|
|
25
40
|
.description('Show the currently authenticated user')
|
|
26
41
|
.action(async () => {
|
|
27
42
|
try {
|
|
43
|
+
const machine = isMachineIdentity();
|
|
28
44
|
const cached = loadCachedTokens();
|
|
29
45
|
|
|
46
|
+
if (machine) {
|
|
47
|
+
// Machine identities (company agents) mint sessions on demand from
|
|
48
|
+
// long-lived creds — report the machine identity honestly even when
|
|
49
|
+
// no session is cached yet.
|
|
50
|
+
const username = loadMachineCreds()?.username ?? 'unknown';
|
|
51
|
+
const entityUid = cached
|
|
52
|
+
? peekIdToken(cached.idToken).entityUid
|
|
53
|
+
: undefined;
|
|
54
|
+
console.log(
|
|
55
|
+
`Machine identity ${username}${entityUid ? ` (agent ${entityUid})` : ''} — sessions mint automatically`
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
30
60
|
if (!cached) {
|
|
31
61
|
console.log("Not logged in. Run 'hq login' to authenticate.");
|
|
32
62
|
return;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-identity behavior of the shared Cognito session helpers.
|
|
3
|
+
*
|
|
4
|
+
* When machine creds are present (company agent boxes), the CLI must:
|
|
5
|
+
* - return the ID token from ensureCognitoToken() — the vault API's JWT
|
|
6
|
+
* authorizer accepts ID tokens and the agent claims
|
|
7
|
+
* (custom:entityType/custom:entityUid) ride the ID token only
|
|
8
|
+
* - never refresh or open a browser — sessions re-mint on demand
|
|
9
|
+
* - treat `hq auth refresh` as a fresh mint instead of "no cached session"
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
const mocks = vi.hoisted(() => ({
|
|
15
|
+
isMachineIdentity: vi.fn(),
|
|
16
|
+
getValidMachineTokens: vi.fn(),
|
|
17
|
+
mintMachineTokens: vi.fn(),
|
|
18
|
+
loadCachedTokens: vi.fn(),
|
|
19
|
+
refreshTokens: vi.fn(),
|
|
20
|
+
browserLogin: vi.fn(),
|
|
21
|
+
isExpiring: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
|
|
25
|
+
const actual = await importOriginal<typeof import("@indigoai-us/hq-cloud")>();
|
|
26
|
+
return { ...actual, ...mocks };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
ensureCognitoToken,
|
|
31
|
+
refreshCachedSession,
|
|
32
|
+
} from "./cognito-session.js";
|
|
33
|
+
|
|
34
|
+
const MACHINE_TOKENS = {
|
|
35
|
+
accessToken: "machine-access-token",
|
|
36
|
+
idToken: "machine-id-token",
|
|
37
|
+
refreshToken: "",
|
|
38
|
+
expiresAt: Date.now() + 3600_000,
|
|
39
|
+
tokenType: "Bearer" as const,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.resetAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("ensureCognitoToken in machine mode", () => {
|
|
47
|
+
it("returns the ID token (agent claims ride the ID token only)", async () => {
|
|
48
|
+
mocks.isMachineIdentity.mockReturnValue(true);
|
|
49
|
+
mocks.getValidMachineTokens.mockResolvedValue(MACHINE_TOKENS);
|
|
50
|
+
|
|
51
|
+
const token = await ensureCognitoToken({ interactive: false });
|
|
52
|
+
|
|
53
|
+
expect(token).toBe("machine-id-token");
|
|
54
|
+
expect(mocks.getValidMachineTokens).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(mocks.refreshTokens).not.toHaveBeenCalled();
|
|
56
|
+
expect(mocks.browserLogin).not.toHaveBeenCalled();
|
|
57
|
+
expect(mocks.loadCachedTokens).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls through to the browser-session path for human identities", async () => {
|
|
61
|
+
mocks.isMachineIdentity.mockReturnValue(false);
|
|
62
|
+
mocks.loadCachedTokens.mockReturnValue({
|
|
63
|
+
...MACHINE_TOKENS,
|
|
64
|
+
accessToken: "human-access-token",
|
|
65
|
+
});
|
|
66
|
+
mocks.isExpiring.mockReturnValue(false);
|
|
67
|
+
|
|
68
|
+
const token = await ensureCognitoToken({ interactive: false });
|
|
69
|
+
|
|
70
|
+
expect(token).toBe("human-access-token");
|
|
71
|
+
expect(mocks.getValidMachineTokens).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("propagates mint failures instead of opening a browser", async () => {
|
|
75
|
+
mocks.isMachineIdentity.mockReturnValue(true);
|
|
76
|
+
mocks.getValidMachineTokens.mockRejectedValue(new Error("mint failed"));
|
|
77
|
+
|
|
78
|
+
await expect(ensureCognitoToken()).rejects.toThrow("mint failed");
|
|
79
|
+
expect(mocks.browserLogin).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("refreshCachedSession in machine mode", () => {
|
|
84
|
+
it("re-mints instead of answering 'no cached session'", async () => {
|
|
85
|
+
mocks.isMachineIdentity.mockReturnValue(true);
|
|
86
|
+
mocks.mintMachineTokens.mockResolvedValue(MACHINE_TOKENS);
|
|
87
|
+
mocks.loadCachedTokens.mockReturnValue(null);
|
|
88
|
+
|
|
89
|
+
const result = await refreshCachedSession();
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual({ refreshed: true });
|
|
92
|
+
expect(mocks.mintMachineTokens).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(mocks.refreshTokens).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("surfaces the mint error as the reason on failure", async () => {
|
|
97
|
+
mocks.isMachineIdentity.mockReturnValue(true);
|
|
98
|
+
mocks.mintMachineTokens.mockRejectedValue(new Error("NotAuthorized"));
|
|
99
|
+
|
|
100
|
+
const result = await refreshCachedSession();
|
|
101
|
+
|
|
102
|
+
expect(result.refreshed).toBe(false);
|
|
103
|
+
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" });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -30,6 +30,9 @@ import {
|
|
|
30
30
|
refreshTokens,
|
|
31
31
|
browserLogin,
|
|
32
32
|
detectHqCoreVersion,
|
|
33
|
+
isMachineIdentity,
|
|
34
|
+
getValidMachineTokens,
|
|
35
|
+
mintMachineTokens,
|
|
33
36
|
type CognitoAuthConfig,
|
|
34
37
|
type ClientInfo,
|
|
35
38
|
type VaultServiceConfig,
|
|
@@ -266,6 +269,20 @@ export async function ensureCognitoToken(options: {
|
|
|
266
269
|
interactive?: boolean;
|
|
267
270
|
} = {}): Promise<string> {
|
|
268
271
|
const interactive = options.interactive ?? true;
|
|
272
|
+
|
|
273
|
+
// Machine identities (company agents) mint sessions on demand via
|
|
274
|
+
// USER_PASSWORD_AUTH — no refresh-token dance, no browser. The vault API's
|
|
275
|
+
// JWT authorizer accepts ID tokens, and the agent's identity claims
|
|
276
|
+
// (custom:entityType=agent, custom:entityUid=agt_*) ride the ID token ONLY,
|
|
277
|
+
// so vault-API calls from a machine identity send the ID token. The cached
|
|
278
|
+
// token file keeps correct field semantics (real access token in
|
|
279
|
+
// accessToken) for consumers that need token_use=access, e.g. the deploy
|
|
280
|
+
// API via the deploy skill.
|
|
281
|
+
if (isMachineIdentity()) {
|
|
282
|
+
const machine = await getValidMachineTokens(DEFAULT_COGNITO);
|
|
283
|
+
return machine.idToken;
|
|
284
|
+
}
|
|
285
|
+
|
|
269
286
|
const cached = loadCachedTokens();
|
|
270
287
|
|
|
271
288
|
if (cached && !isExpiring(cached, 120)) {
|
|
@@ -335,6 +352,20 @@ export async function refreshCachedSession(): Promise<{
|
|
|
335
352
|
refreshed: boolean;
|
|
336
353
|
reason?: string;
|
|
337
354
|
}> {
|
|
355
|
+
// Machine identities have no refresh token — a "refresh" is a fresh mint
|
|
356
|
+
// from the long-lived machine creds.
|
|
357
|
+
if (isMachineIdentity()) {
|
|
358
|
+
try {
|
|
359
|
+
await mintMachineTokens(DEFAULT_COGNITO);
|
|
360
|
+
return { refreshed: true };
|
|
361
|
+
} catch (err) {
|
|
362
|
+
return {
|
|
363
|
+
refreshed: false,
|
|
364
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
338
369
|
const cached = loadCachedTokens();
|
|
339
370
|
if (!cached) {
|
|
340
371
|
return { refreshed: false, reason: "no cached session" };
|