@indigoai-us/hq-cli 5.62.0 → 5.62.2
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 +7 -0
- package/dist/commands/cloud.d.ts +11 -0
- package/dist/commands/cloud.js +40 -2
- package/dist/commands/db-provision.js +16 -9
- package/dist/commands/db-status.js +16 -12
- package/dist/commands/members.d.ts +12 -1
- package/dist/commands/members.js +70 -11
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +53 -0
- package/src/commands/cloud.ts +53 -0
- package/src/commands/db-provision.ts +20 -8
- package/src/commands/db-status.ts +19 -11
- package/src/commands/members.test.ts +60 -0
- package/src/commands/members.ts +100 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.62.1]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`hq db provision` / `hq db status --remote` use Cognito + vault API** to call the live control plane (`POST /v1/db/provision`, `GET /v1/db/status`). Team plan required for provision; local `status|sql|migrate` unchanged.
|
|
10
|
+
|
|
11
|
+
|
|
5
12
|
## [5.55.1]
|
|
6
13
|
|
|
7
14
|
### Fixed
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -66,6 +66,17 @@ export interface SyncCallOptions {
|
|
|
66
66
|
*/
|
|
67
67
|
syncMode?: SyncMode;
|
|
68
68
|
prefixSet?: string[];
|
|
69
|
+
/**
|
|
70
|
+
* Company-relative prefixes the pull must NOT materialize even when the
|
|
71
|
+
* caller's STS scope is wide enough to read them (sessions-exclusion /
|
|
72
|
+
* company-work-corpus). Derived by `resolvePullScope` in the hq-cloud release
|
|
73
|
+
* carrying the sessions/ pull-exclusion, forwarded to `sync()` here so the CLI
|
|
74
|
+
* honors the same "session transcripts never sync DOWN" invariant the
|
|
75
|
+
* background sync engine enforces. A company OWNER can read `sessions/` keys,
|
|
76
|
+
* so without this the CLI would bulk-download everyone's full-content session
|
|
77
|
+
* transcripts to local disk. Undefined/empty ⇒ nothing excluded (legacy).
|
|
78
|
+
*/
|
|
79
|
+
excludePrefixes?: string[];
|
|
69
80
|
/** Honor a `--force-scope-shrink` on a foreground pull (dirty files kept). */
|
|
70
81
|
forceScopeShrink?: boolean;
|
|
71
82
|
}
|
package/dist/commands/cloud.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* hq sync status — show local journal summary
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
!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]="
|
|
16
|
+
!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]="5e2703d2-1874-5625-8508-67ff641ff1a6")}catch(e){}}();
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
@@ -74,6 +74,25 @@ function pickCanonicalPerson(persons) {
|
|
|
74
74
|
return a.uid < b.uid ? -1 : 1;
|
|
75
75
|
})[0];
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Read the sessions-exclusion prefix set off a resolved {@link PullScope}.
|
|
79
|
+
*
|
|
80
|
+
* `resolvePullScope` in hq-cloud derives the sessions/ pull-exclusion and
|
|
81
|
+
* derives `excludePrefixes` (e.g. `["sessions/"]`) so a company OWNER — whose STS
|
|
82
|
+
* scope is wide enough to read `sessions/` keys — does not bulk-download
|
|
83
|
+
* everyone's full-content session transcripts to local disk, upholding the
|
|
84
|
+
* "session transcripts never sync DOWN" invariant hq-cloud enforces for the
|
|
85
|
+
* background engine. Keep the small runtime shape check even though PullScope
|
|
86
|
+
* now declares the field: it protects the CLI when an older external resolver
|
|
87
|
+
* implementation is injected by tests or downstream callers.
|
|
88
|
+
*/
|
|
89
|
+
function readScopeExcludePrefixes(scope) {
|
|
90
|
+
const raw = scope?.excludePrefixes;
|
|
91
|
+
if (!Array.isArray(raw))
|
|
92
|
+
return undefined;
|
|
93
|
+
const prefixes = raw.filter((p) => typeof p === "string" && p.length > 0);
|
|
94
|
+
return prefixes.length > 0 ? prefixes : undefined;
|
|
95
|
+
}
|
|
77
96
|
export async function pullAll(options, deps) {
|
|
78
97
|
const memberships = await deps.vaultClient.listMyMemberships();
|
|
79
98
|
const persons = await deps.vaultClient.listPersonEntities();
|
|
@@ -145,6 +164,12 @@ export async function pullAll(options, deps) {
|
|
|
145
164
|
if (scope.prefixSet !== undefined) {
|
|
146
165
|
entry.syncOptions.prefixSet = scope.prefixSet;
|
|
147
166
|
}
|
|
167
|
+
// Sessions-exclusion: thread the resolver's excludePrefixes into the
|
|
168
|
+
// pull so a wide-STS OWNER never bulk-downloads session transcripts.
|
|
169
|
+
const excludePrefixes = readScopeExcludePrefixes(scope);
|
|
170
|
+
if (excludePrefixes) {
|
|
171
|
+
entry.syncOptions.excludePrefixes = excludePrefixes;
|
|
172
|
+
}
|
|
148
173
|
}
|
|
149
174
|
catch {
|
|
150
175
|
resolvedMode = undefined;
|
|
@@ -742,6 +767,7 @@ export function registerCloudCommands(program) {
|
|
|
742
767
|
"to migrate, or re-run with --mode-all."));
|
|
743
768
|
process.exit(1);
|
|
744
769
|
}
|
|
770
|
+
const excludePrefixes = readScopeExcludePrefixes(pullScope);
|
|
745
771
|
const result = await sync({
|
|
746
772
|
company: options.company,
|
|
747
773
|
onConflict: options.onConflict,
|
|
@@ -753,6 +779,9 @@ export function registerCloudCommands(program) {
|
|
|
753
779
|
...(pullScope?.prefixSet !== undefined
|
|
754
780
|
? { prefixSet: pullScope.prefixSet }
|
|
755
781
|
: {}),
|
|
782
|
+
// Sessions-exclusion: never materialize excluded prefixes even for
|
|
783
|
+
// a wide-STS owner.
|
|
784
|
+
...(excludePrefixes ? { excludePrefixes } : {}),
|
|
756
785
|
...(options.forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
757
786
|
});
|
|
758
787
|
if (result.aborted) {
|
|
@@ -944,6 +973,11 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
|
|
|
944
973
|
: {}),
|
|
945
974
|
...(opts.syncMode !== undefined ? { syncMode: opts.syncMode } : {}),
|
|
946
975
|
...(opts.prefixSet !== undefined ? { prefixSet: opts.prefixSet } : {}),
|
|
976
|
+
// Sessions-exclusion: forward the per-company excludePrefixes that
|
|
977
|
+
// pullAll stamped onto SyncCallOptions into the real hq-cloud sync().
|
|
978
|
+
...(opts.excludePrefixes?.length
|
|
979
|
+
? { excludePrefixes: opts.excludePrefixes }
|
|
980
|
+
: {}),
|
|
947
981
|
...(opts.forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
948
982
|
}),
|
|
949
983
|
});
|
|
@@ -1226,6 +1260,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1226
1260
|
}
|
|
1227
1261
|
}
|
|
1228
1262
|
console.log(chalk.dim(" → pull leg"));
|
|
1263
|
+
const excludePrefixes = readScopeExcludePrefixes(pullScope);
|
|
1229
1264
|
const pullResult = await sync({
|
|
1230
1265
|
company: targetCompany,
|
|
1231
1266
|
vaultConfig,
|
|
@@ -1239,6 +1274,9 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1239
1274
|
...(pullScope?.prefixSet !== undefined
|
|
1240
1275
|
? { prefixSet: pullScope.prefixSet }
|
|
1241
1276
|
: {}),
|
|
1277
|
+
// Sessions-exclusion: honor the resolver's excludePrefixes on the
|
|
1278
|
+
// `hq sync now` pull leg too.
|
|
1279
|
+
...(excludePrefixes ? { excludePrefixes } : {}),
|
|
1242
1280
|
...(forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
1243
1281
|
});
|
|
1244
1282
|
console.log(` ${pullResult.aborted ? chalk.yellow("⚠") : chalk.green("✓")} ` +
|
|
@@ -1368,4 +1406,4 @@ function resolveUploadAuthorFromCache() {
|
|
|
1368
1406
|
}
|
|
1369
1407
|
}
|
|
1370
1408
|
//# sourceMappingURL=cloud.js.map
|
|
1371
|
-
//# debugId=
|
|
1409
|
+
//# debugId=5e2703d2-1874-5625-8508-67ff641ff1a6
|
|
@@ -2,31 +2,34 @@
|
|
|
2
2
|
* hq db provision — request remote vault DB binding (US-009).
|
|
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]="f0d312c6-3730-5e46-b21f-c694723d0f5b")}catch(e){}}();
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
import { ControlPlaneDbClient } from "../lib/db/control-plane.js";
|
|
8
|
+
import { DEFAULT_VAULT_API_URL, ensureCognitoToken, } from "../utils/cognito-session.js";
|
|
9
|
+
import { getCompanyUid } from "../utils/vault-api.js";
|
|
8
10
|
const defaultDeps = () => ({
|
|
9
11
|
async resolveCompany(slug) {
|
|
10
|
-
// Production path: resolve via vault API membership. Tests inject mocks.
|
|
11
|
-
// Keep slug as placeholder uid only when HQ_DB_MOCK_CONTROL_PLANE=1.
|
|
12
12
|
if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1") {
|
|
13
13
|
return { companyUid: `cmp_${slug}`, companySlug: slug };
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
const token = await ensureCognitoToken({ interactive: false });
|
|
16
|
+
const companyUid = await getCompanyUid(token, slug);
|
|
17
|
+
return { companyUid, companySlug: slug.trim().toLowerCase() };
|
|
16
18
|
},
|
|
17
19
|
async getAccessToken() {
|
|
18
20
|
if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1") {
|
|
19
21
|
return "mock-token";
|
|
20
22
|
}
|
|
21
|
-
|
|
23
|
+
return ensureCognitoToken({ interactive: false });
|
|
22
24
|
},
|
|
23
|
-
apiBaseUrl: process.env.
|
|
25
|
+
apiBaseUrl: process.env.HQ_VAULT_API_URL ||
|
|
26
|
+
process.env.HQ_API_BASE_URL ||
|
|
24
27
|
process.env.HQ_CLOUD_API_URL ||
|
|
25
|
-
|
|
28
|
+
DEFAULT_VAULT_API_URL,
|
|
26
29
|
});
|
|
27
30
|
export function registerDbProvisionCommand(db, depsFactory = defaultDeps) {
|
|
28
31
|
db.command("provision")
|
|
29
|
-
.description("Provision (or re-bind) the company remote vault DB via HQ control plane")
|
|
32
|
+
.description("Provision (or re-bind) the company remote vault DB via HQ control plane (Team plan)")
|
|
30
33
|
.requiredOption("--company <slug>", "Company slug")
|
|
31
34
|
.option("--region <region>", "AWS region", "us-east-1")
|
|
32
35
|
.action(async (opts) => {
|
|
@@ -64,6 +67,10 @@ export function registerDbProvisionCommand(db, depsFactory = defaultDeps) {
|
|
|
64
67
|
console.error(chalk.red("Error:"), "Remote vault DB requires the HQ Team plan ($500/mo).");
|
|
65
68
|
console.error(chalk.dim("Local databases still work: hq db status|sql|migrate — no Team plan required."));
|
|
66
69
|
}
|
|
70
|
+
else if (status === 404 || /Unknown route|Not Found/i.test(msg)) {
|
|
71
|
+
console.error(chalk.red("Error:"), "Remote DB control plane is not available yet (routes not deployed).");
|
|
72
|
+
console.error(chalk.dim("Local hq db status|sql|migrate still work offline."));
|
|
73
|
+
}
|
|
67
74
|
else {
|
|
68
75
|
console.error(chalk.red("Error:"), msg);
|
|
69
76
|
}
|
|
@@ -75,4 +82,4 @@ export function registerDbProvisionCommand(db, depsFactory = defaultDeps) {
|
|
|
75
82
|
});
|
|
76
83
|
}
|
|
77
84
|
//# sourceMappingURL=db-provision.js.map
|
|
78
|
-
//# debugId=
|
|
85
|
+
//# debugId=f0d312c6-3730-5e46-b21f-c694723d0f5b
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Optionally reports remote tier when control plane returns a binding (US-009).
|
|
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]="b0f21394-45cb-572c-890b-bcdf0fc9e7d7")}catch(e){}}();
|
|
7
7
|
import chalk from "chalk";
|
|
8
8
|
import { ControlPlaneDbClient } from "../lib/db/control-plane.js";
|
|
9
9
|
import { ensureAndStatusLocalDb, formatLocalDbStatus, } from "../lib/db/local.js";
|
|
@@ -26,19 +26,23 @@ export function registerDbStatusCommand(db) {
|
|
|
26
26
|
// Never print remote connection material.
|
|
27
27
|
console.log(formatLocalDbStatus(status));
|
|
28
28
|
if (opts.remote) {
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
process.env.HQ_CLOUD_API_URL ||
|
|
32
|
-
"https://hqapi.getindigo.ai";
|
|
29
|
+
const { DEFAULT_VAULT_API_URL, ensureCognitoToken } = await import("../utils/cognito-session.js");
|
|
30
|
+
const { getCompanyUid } = await import("../utils/vault-api.js");
|
|
33
31
|
try {
|
|
32
|
+
const token = process.env.HQ_DB_MOCK_CONTROL_PLANE === "1"
|
|
33
|
+
? "mock-token"
|
|
34
|
+
: await ensureCognitoToken({ interactive: false });
|
|
35
|
+
const companyUid = opts.companyUid ||
|
|
36
|
+
(process.env.HQ_DB_MOCK_CONTROL_PLANE === "1"
|
|
37
|
+
? `cmp_${company}`
|
|
38
|
+
: await getCompanyUid(token, company));
|
|
39
|
+
const baseUrl = process.env.HQ_VAULT_API_URL ||
|
|
40
|
+
process.env.HQ_API_BASE_URL ||
|
|
41
|
+
process.env.HQ_CLOUD_API_URL ||
|
|
42
|
+
DEFAULT_VAULT_API_URL;
|
|
34
43
|
const client = new ControlPlaneDbClient({
|
|
35
44
|
baseUrl,
|
|
36
|
-
getAccessToken: async () =>
|
|
37
|
-
if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1") {
|
|
38
|
-
return "mock-token";
|
|
39
|
-
}
|
|
40
|
-
throw new Error("auth required for remote status");
|
|
41
|
-
},
|
|
45
|
+
getAccessToken: async () => token,
|
|
42
46
|
});
|
|
43
47
|
const remote = await client.status(companyUid);
|
|
44
48
|
if (!remote.remote) {
|
|
@@ -67,4 +71,4 @@ export function registerDbStatusCommand(db) {
|
|
|
67
71
|
});
|
|
68
72
|
}
|
|
69
73
|
//# sourceMappingURL=db-status.js.map
|
|
70
|
-
//# debugId=
|
|
74
|
+
//# debugId=b0f21394-45cb-572c-890b-bcdf0fc9e7d7
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
export declare const VALID_ROLES: Set<string>;
|
|
3
|
+
/** Roles allowed when inviting an existing fleet agent into a company. */
|
|
4
|
+
export declare const AGENT_INVITE_ROLES: Set<string>;
|
|
3
5
|
export type Role = "owner" | "admin" | "member" | "guest";
|
|
4
6
|
export interface PendingInvite {
|
|
5
7
|
membershipKey: string;
|
|
@@ -81,6 +83,13 @@ export interface InviteResult {
|
|
|
81
83
|
emailSkipped?: boolean;
|
|
82
84
|
/** Resend was attempted but failed — human-readable reason. */
|
|
83
85
|
emailError?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Present when hq-pro treated the target as a fleet agent (guest invite).
|
|
88
|
+
* Always `false` on the agent path — host keeps billing.
|
|
89
|
+
*/
|
|
90
|
+
activationBilled?: boolean;
|
|
91
|
+
/** Host company uid when the server returned an agent guest-invite result. */
|
|
92
|
+
hostCompanyUid?: string;
|
|
84
93
|
}
|
|
85
94
|
export interface ResendInviteOptions {
|
|
86
95
|
inviteeEmail: string;
|
|
@@ -100,8 +109,10 @@ export interface ResendInviteResult {
|
|
|
100
109
|
emailError?: string;
|
|
101
110
|
}
|
|
102
111
|
export interface DetectedTarget {
|
|
103
|
-
type: "email" | "person";
|
|
112
|
+
type: "email" | "person" | "agent";
|
|
104
113
|
value: string;
|
|
114
|
+
/** True when the target is a fleet agent (uid or machine email). */
|
|
115
|
+
isAgent?: boolean;
|
|
105
116
|
}
|
|
106
117
|
export declare function detectTarget(target: string): DetectedTarget | null;
|
|
107
118
|
export declare function shortDate(iso: string): string;
|
package/dist/commands/members.js
CHANGED
|
@@ -1,17 +1,33 @@
|
|
|
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]="9dbfcbea-adf6-5852-b0de-aa6ee4391615")}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";
|
|
6
6
|
const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
|
|
7
7
|
const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
|
|
8
|
+
const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/i;
|
|
9
|
+
/** Fleet agent machine email: agt-…@agents.getindigo.ai */
|
|
10
|
+
const AGENT_EMAIL_PATTERN = /^agt-[a-z0-9]+@agents\.getindigo\.ai$/i;
|
|
8
11
|
export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
|
|
12
|
+
/** Roles allowed when inviting an existing fleet agent into a company. */
|
|
13
|
+
export const AGENT_INVITE_ROLES = new Set(["admin", "member"]);
|
|
9
14
|
export function detectTarget(target) {
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
const trimmed = target.trim();
|
|
16
|
+
if (AGENT_UID_PATTERN.test(trimmed)) {
|
|
17
|
+
return { type: "agent", value: trimmed, isAgent: true };
|
|
12
18
|
}
|
|
13
|
-
if (
|
|
14
|
-
return {
|
|
19
|
+
if (AGENT_EMAIL_PATTERN.test(trimmed)) {
|
|
20
|
+
return {
|
|
21
|
+
type: "email",
|
|
22
|
+
value: trimmed.toLowerCase(),
|
|
23
|
+
isAgent: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (EMAIL_PATTERN.test(trimmed)) {
|
|
27
|
+
return { type: "email", value: trimmed.toLowerCase() };
|
|
28
|
+
}
|
|
29
|
+
if (PERSON_UID_PATTERN.test(trimmed)) {
|
|
30
|
+
return { type: "person", value: trimmed };
|
|
15
31
|
}
|
|
16
32
|
return null;
|
|
17
33
|
}
|
|
@@ -45,9 +61,24 @@ export async function inviteMember(options) {
|
|
|
45
61
|
}
|
|
46
62
|
const detected = detectTarget(options.target);
|
|
47
63
|
if (!detected) {
|
|
48
|
-
throw new Error(`Invalid target '${options.target}': must be an email
|
|
64
|
+
throw new Error(`Invalid target '${options.target}': must be an email, personUid (prs_…), agent uid (agt_…), or agent email (agt-…@agents.getindigo.ai)`);
|
|
49
65
|
}
|
|
50
|
-
|
|
66
|
+
// Fleet agents: same invite command as humans, but only member|admin.
|
|
67
|
+
// Host company keeps billing — server never double-bills on guest invite.
|
|
68
|
+
if (detected.isAgent) {
|
|
69
|
+
if (!AGENT_INVITE_ROLES.has(options.role)) {
|
|
70
|
+
throw new Error(`Agents can only be invited as member or admin (got '${options.role}'). Host company keeps billing.`);
|
|
71
|
+
}
|
|
72
|
+
if (options.paths) {
|
|
73
|
+
throw new Error("--paths / guest role is not valid for agents — invite as member or admin");
|
|
74
|
+
}
|
|
75
|
+
if (options.groupIds && options.groupIds.length > 0) {
|
|
76
|
+
throw new Error("--groups is not supported on agent invites — share secrets after membership with `hq secrets share`");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (options.groupIds &&
|
|
80
|
+
options.groupIds.length > 0 &&
|
|
81
|
+
(detected.type === "person" || detected.type === "agent")) {
|
|
51
82
|
throw new Error("--groups is only valid on email-keyed invites (server rejects personUid + groupIds with 400)");
|
|
52
83
|
}
|
|
53
84
|
const allowedPrefixes = options.paths
|
|
@@ -108,6 +139,12 @@ export async function inviteMember(options) {
|
|
|
108
139
|
? { emailSkipped: data.emailSkipped }
|
|
109
140
|
: {}),
|
|
110
141
|
...(data.emailError ? { emailError: data.emailError } : {}),
|
|
142
|
+
...(typeof data.activationBilled === "boolean"
|
|
143
|
+
? { activationBilled: data.activationBilled }
|
|
144
|
+
: {}),
|
|
145
|
+
...(typeof data.hostCompanyUid === "string"
|
|
146
|
+
? { hostCompanyUid: data.hostCompanyUid }
|
|
147
|
+
: {}),
|
|
111
148
|
};
|
|
112
149
|
}
|
|
113
150
|
/**
|
|
@@ -337,8 +374,8 @@ export function registerMembersCommand(program) {
|
|
|
337
374
|
});
|
|
338
375
|
members
|
|
339
376
|
.command("invite <target>")
|
|
340
|
-
.description("Invite a person to the company
|
|
341
|
-
.option("--role <role>", "Role
|
|
377
|
+
.description("Invite a person or fleet agent to the company. People: email or prs_…. Agents: agt_… or agt-…@agents.getindigo.ai (member|admin; host keeps billing, no double charge).")
|
|
378
|
+
.option("--role <role>", "Role: person → owner|admin|member|guest; agent → member|admin only", "member")
|
|
342
379
|
.option("--paths <prefixes>", "Comma-separated allowed prefixes (only valid with --role guest)")
|
|
343
380
|
.option("--groups <ids>", "Comma-separated secret-group ids the invitee will be added to on claim (email-keyed invites only)")
|
|
344
381
|
.option("--send-email", "Have hq-pro send an invitation email via Resend (default true for email-keyed invites). Pre-resend hq-pro versions ignore this and the legacy 'no email sent' instructions print instead.", true)
|
|
@@ -376,12 +413,34 @@ export function registerMembersCommand(program) {
|
|
|
376
413
|
role: opts.role,
|
|
377
414
|
paths: opts.paths,
|
|
378
415
|
groupIds,
|
|
379
|
-
|
|
416
|
+
// Agents join actively — no Resend email.
|
|
417
|
+
sendEmail: detectTarget(target)?.isAgent ? false : opts.sendEmail,
|
|
380
418
|
companyUid,
|
|
381
419
|
callerUid,
|
|
382
420
|
token,
|
|
383
421
|
});
|
|
422
|
+
const agentTarget = detectTarget(target)?.isAgent === true;
|
|
384
423
|
console.log(chalk.green(`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`));
|
|
424
|
+
// Agent path is confirmed only when the server returned the guest-invite
|
|
425
|
+
// envelope (activationBilled present) AND status is active. A pending
|
|
426
|
+
// row means this hq-pro stage has not rolled the agent invite path yet —
|
|
427
|
+
// do NOT claim active join / no-double-bill.
|
|
428
|
+
if (agentTarget &&
|
|
429
|
+
result.membership.status === "active" &&
|
|
430
|
+
typeof result.activationBilled === "boolean") {
|
|
431
|
+
console.log(chalk.dim(`Fleet agent: active membership granted immediately` +
|
|
432
|
+
(result.hostCompanyUid
|
|
433
|
+
? ` (host ${result.hostCompanyUid} keeps billing)`
|
|
434
|
+
: " (host keeps billing, no double charge)") +
|
|
435
|
+
`. Next: share secrets / vault paths as needed (\`hq secrets share\`, \`/new-agent\`).`));
|
|
436
|
+
console.log();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (agentTarget && result.membership.status !== "active") {
|
|
440
|
+
console.log(chalk.yellow("⚠ Agent invite landed as a pending membership — this hq-pro stage may not yet have multi-company agent invite. Wait for prod deploy of the agent membership path, then re-invite (or revoke this pending row)."));
|
|
441
|
+
console.log();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
385
444
|
console.log();
|
|
386
445
|
if (result.magicLink) {
|
|
387
446
|
// Legacy server schema — magic-link redemption.
|
|
@@ -564,4 +623,4 @@ export function registerMembersCommand(program) {
|
|
|
564
623
|
});
|
|
565
624
|
}
|
|
566
625
|
//# sourceMappingURL=members.js.map
|
|
567
|
-
//# debugId=
|
|
626
|
+
//# debugId=9dbfcbea-adf6-5852-b0de-aa6ee4391615
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.62.
|
|
3
|
+
"version": "5.62.2",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"clean": "rm -rf dist"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@indigoai-us/hq-cloud": "^6.
|
|
22
|
+
"@indigoai-us/hq-cloud": "^6.14.2",
|
|
23
23
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
24
24
|
"@sentry/node": "^10.49.0",
|
|
25
25
|
"better-sqlite3": "^12.11.1",
|
|
@@ -180,6 +180,59 @@ describe("pullAll", () => {
|
|
|
180
180
|
expect(companyCall?.prefixSet).toEqual(["knowledge/", "policies/"]);
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
// ── 1c. Sessions-exclusion: excludePrefixes is threaded into sync() ───────
|
|
184
|
+
//
|
|
185
|
+
// Guards the "session transcripts never sync DOWN" invariant on the CLI path:
|
|
186
|
+
// a company OWNER's STS scope is wide enough to read `sessions/` keys, so if
|
|
187
|
+
// the 4-site scope→sync-options mapping dropped `excludePrefixes` the CLI
|
|
188
|
+
// would bulk-download everyone's full-content session transcripts to local
|
|
189
|
+
// disk. This asserts the resolver's excludePrefixes reaches the SyncCallOptions
|
|
190
|
+
// handed to sync(); it FAILS if any mapping site drops the field.
|
|
191
|
+
it("threads the resolved excludePrefixes into sync() (sessions never pull DOWN)", async () => {
|
|
192
|
+
const vaultClient = makeVaultClient({
|
|
193
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
194
|
+
entitiesBySlug: { cmp_a: { slug: "acme" } },
|
|
195
|
+
});
|
|
196
|
+
const sync = makeSyncSpy();
|
|
197
|
+
// hq-cloud derives excludePrefixes on the resolved scope.
|
|
198
|
+
const resolveScope = vi.fn(async (_uid: string, _slug: string) => ({
|
|
199
|
+
syncMode: "shared" as const,
|
|
200
|
+
prefixSet: ["knowledge/", "policies/"],
|
|
201
|
+
excludePrefixes: ["sessions/"],
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
await pullAll(
|
|
205
|
+
{ hqRoot: "/tmp/hq" },
|
|
206
|
+
{ vaultClient, sync: sync.fn, resolveScope },
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const companyCall = sync.calls.find((c) => c.company === "cmp_a");
|
|
210
|
+
expect(companyCall?.excludePrefixes).toEqual(["sessions/"]);
|
|
211
|
+
// Sibling scope fields still flow — excludePrefixes rides alongside them.
|
|
212
|
+
expect(companyCall?.syncMode).toBe("shared");
|
|
213
|
+
expect(companyCall?.prefixSet).toEqual(["knowledge/", "policies/"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("omits excludePrefixes when the resolved scope carries none", async () => {
|
|
217
|
+
const vaultClient = makeVaultClient({
|
|
218
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
219
|
+
entitiesBySlug: { cmp_a: { slug: "acme" } },
|
|
220
|
+
});
|
|
221
|
+
const sync = makeSyncSpy();
|
|
222
|
+
const resolveScope = vi.fn(async () => ({
|
|
223
|
+
syncMode: "shared" as const,
|
|
224
|
+
prefixSet: ["knowledge/"],
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
await pullAll(
|
|
228
|
+
{ hqRoot: "/tmp/hq" },
|
|
229
|
+
{ vaultClient, sync: sync.fn, resolveScope },
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const companyCall = sync.calls.find((c) => c.company === "cmp_a");
|
|
233
|
+
expect(companyCall?.excludePrefixes).toBeUndefined();
|
|
234
|
+
});
|
|
235
|
+
|
|
183
236
|
it("passes no prefixSet for an all-mode membership (full pull preserved)", async () => {
|
|
184
237
|
const vaultClient = makeVaultClient({
|
|
185
238
|
memberships: [{ companyUid: "cmp_a" }],
|
package/src/commands/cloud.ts
CHANGED
|
@@ -153,6 +153,17 @@ export interface SyncCallOptions {
|
|
|
153
153
|
*/
|
|
154
154
|
syncMode?: SyncMode;
|
|
155
155
|
prefixSet?: string[];
|
|
156
|
+
/**
|
|
157
|
+
* Company-relative prefixes the pull must NOT materialize even when the
|
|
158
|
+
* caller's STS scope is wide enough to read them (sessions-exclusion /
|
|
159
|
+
* company-work-corpus). Derived by `resolvePullScope` in the hq-cloud release
|
|
160
|
+
* carrying the sessions/ pull-exclusion, forwarded to `sync()` here so the CLI
|
|
161
|
+
* honors the same "session transcripts never sync DOWN" invariant the
|
|
162
|
+
* background sync engine enforces. A company OWNER can read `sessions/` keys,
|
|
163
|
+
* so without this the CLI would bulk-download everyone's full-content session
|
|
164
|
+
* transcripts to local disk. Undefined/empty ⇒ nothing excluded (legacy).
|
|
165
|
+
*/
|
|
166
|
+
excludePrefixes?: string[];
|
|
156
167
|
/** Honor a `--force-scope-shrink` on a foreground pull (dirty files kept). */
|
|
157
168
|
forceScopeShrink?: boolean;
|
|
158
169
|
}
|
|
@@ -320,6 +331,29 @@ function pickCanonicalPerson<
|
|
|
320
331
|
})[0];
|
|
321
332
|
}
|
|
322
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Read the sessions-exclusion prefix set off a resolved {@link PullScope}.
|
|
336
|
+
*
|
|
337
|
+
* `resolvePullScope` in hq-cloud derives the sessions/ pull-exclusion and
|
|
338
|
+
* derives `excludePrefixes` (e.g. `["sessions/"]`) so a company OWNER — whose STS
|
|
339
|
+
* scope is wide enough to read `sessions/` keys — does not bulk-download
|
|
340
|
+
* everyone's full-content session transcripts to local disk, upholding the
|
|
341
|
+
* "session transcripts never sync DOWN" invariant hq-cloud enforces for the
|
|
342
|
+
* background engine. Keep the small runtime shape check even though PullScope
|
|
343
|
+
* now declares the field: it protects the CLI when an older external resolver
|
|
344
|
+
* implementation is injected by tests or downstream callers.
|
|
345
|
+
*/
|
|
346
|
+
function readScopeExcludePrefixes(
|
|
347
|
+
scope: PullScope | undefined,
|
|
348
|
+
): string[] | undefined {
|
|
349
|
+
const raw: unknown = scope?.excludePrefixes;
|
|
350
|
+
if (!Array.isArray(raw)) return undefined;
|
|
351
|
+
const prefixes = raw.filter(
|
|
352
|
+
(p): p is string => typeof p === "string" && p.length > 0,
|
|
353
|
+
);
|
|
354
|
+
return prefixes.length > 0 ? prefixes : undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
323
357
|
export async function pullAll(
|
|
324
358
|
options: PullAllOptions,
|
|
325
359
|
deps: PullAllDeps,
|
|
@@ -398,6 +432,12 @@ export async function pullAll(
|
|
|
398
432
|
if (scope.prefixSet !== undefined) {
|
|
399
433
|
entry.syncOptions.prefixSet = scope.prefixSet;
|
|
400
434
|
}
|
|
435
|
+
// Sessions-exclusion: thread the resolver's excludePrefixes into the
|
|
436
|
+
// pull so a wide-STS OWNER never bulk-downloads session transcripts.
|
|
437
|
+
const excludePrefixes = readScopeExcludePrefixes(scope);
|
|
438
|
+
if (excludePrefixes) {
|
|
439
|
+
entry.syncOptions.excludePrefixes = excludePrefixes;
|
|
440
|
+
}
|
|
401
441
|
} catch {
|
|
402
442
|
resolvedMode = undefined;
|
|
403
443
|
}
|
|
@@ -1205,6 +1245,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1205
1245
|
process.exit(1);
|
|
1206
1246
|
}
|
|
1207
1247
|
|
|
1248
|
+
const excludePrefixes = readScopeExcludePrefixes(pullScope);
|
|
1208
1249
|
const result = await sync({
|
|
1209
1250
|
company: options.company,
|
|
1210
1251
|
onConflict: options.onConflict,
|
|
@@ -1216,6 +1257,9 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1216
1257
|
...(pullScope?.prefixSet !== undefined
|
|
1217
1258
|
? { prefixSet: pullScope.prefixSet }
|
|
1218
1259
|
: {}),
|
|
1260
|
+
// Sessions-exclusion: never materialize excluded prefixes even for
|
|
1261
|
+
// a wide-STS owner.
|
|
1262
|
+
...(excludePrefixes ? { excludePrefixes } : {}),
|
|
1219
1263
|
...(options.forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
1220
1264
|
});
|
|
1221
1265
|
|
|
@@ -1519,6 +1563,11 @@ async function runPullAll(
|
|
|
1519
1563
|
: {}),
|
|
1520
1564
|
...(opts.syncMode !== undefined ? { syncMode: opts.syncMode } : {}),
|
|
1521
1565
|
...(opts.prefixSet !== undefined ? { prefixSet: opts.prefixSet } : {}),
|
|
1566
|
+
// Sessions-exclusion: forward the per-company excludePrefixes that
|
|
1567
|
+
// pullAll stamped onto SyncCallOptions into the real hq-cloud sync().
|
|
1568
|
+
...(opts.excludePrefixes?.length
|
|
1569
|
+
? { excludePrefixes: opts.excludePrefixes }
|
|
1570
|
+
: {}),
|
|
1522
1571
|
...(opts.forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
1523
1572
|
}),
|
|
1524
1573
|
},
|
|
@@ -1877,6 +1926,7 @@ async function runNowSingle(
|
|
|
1877
1926
|
}
|
|
1878
1927
|
|
|
1879
1928
|
console.log(chalk.dim(" → pull leg"));
|
|
1929
|
+
const excludePrefixes = readScopeExcludePrefixes(pullScope);
|
|
1880
1930
|
const pullResult = await sync({
|
|
1881
1931
|
company: targetCompany,
|
|
1882
1932
|
vaultConfig,
|
|
@@ -1890,6 +1940,9 @@ async function runNowSingle(
|
|
|
1890
1940
|
...(pullScope?.prefixSet !== undefined
|
|
1891
1941
|
? { prefixSet: pullScope.prefixSet }
|
|
1892
1942
|
: {}),
|
|
1943
|
+
// Sessions-exclusion: honor the resolver's excludePrefixes on the
|
|
1944
|
+
// `hq sync now` pull leg too.
|
|
1945
|
+
...(excludePrefixes ? { excludePrefixes } : {}),
|
|
1893
1946
|
...(forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
1894
1947
|
});
|
|
1895
1948
|
console.log(
|
|
@@ -6,6 +6,11 @@ import { Command } from "commander";
|
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
|
|
8
8
|
import { ControlPlaneDbClient } from "../lib/db/control-plane.js";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_VAULT_API_URL,
|
|
11
|
+
ensureCognitoToken,
|
|
12
|
+
} from "../utils/cognito-session.js";
|
|
13
|
+
import { getCompanyUid } from "../utils/vault-api.js";
|
|
9
14
|
|
|
10
15
|
export interface DbProvisionCommandDeps {
|
|
11
16
|
resolveCompany: (slug: string) => Promise<{ companyUid: string; companySlug: string }>;
|
|
@@ -16,25 +21,24 @@ export interface DbProvisionCommandDeps {
|
|
|
16
21
|
|
|
17
22
|
const defaultDeps = (): DbProvisionCommandDeps => ({
|
|
18
23
|
async resolveCompany(slug) {
|
|
19
|
-
// Production path: resolve via vault API membership. Tests inject mocks.
|
|
20
|
-
// Keep slug as placeholder uid only when HQ_DB_MOCK_CONTROL_PLANE=1.
|
|
21
24
|
if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1") {
|
|
22
25
|
return { companyUid: `cmp_${slug}`, companySlug: slug };
|
|
23
26
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
);
|
|
27
|
+
const token = await ensureCognitoToken({ interactive: false });
|
|
28
|
+
const companyUid = await getCompanyUid(token, slug);
|
|
29
|
+
return { companyUid, companySlug: slug.trim().toLowerCase() };
|
|
27
30
|
},
|
|
28
31
|
async getAccessToken() {
|
|
29
32
|
if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1") {
|
|
30
33
|
return "mock-token";
|
|
31
34
|
}
|
|
32
|
-
|
|
35
|
+
return ensureCognitoToken({ interactive: false });
|
|
33
36
|
},
|
|
34
37
|
apiBaseUrl:
|
|
38
|
+
process.env.HQ_VAULT_API_URL ||
|
|
35
39
|
process.env.HQ_API_BASE_URL ||
|
|
36
40
|
process.env.HQ_CLOUD_API_URL ||
|
|
37
|
-
|
|
41
|
+
DEFAULT_VAULT_API_URL,
|
|
38
42
|
});
|
|
39
43
|
|
|
40
44
|
export function registerDbProvisionCommand(
|
|
@@ -43,7 +47,7 @@ export function registerDbProvisionCommand(
|
|
|
43
47
|
): void {
|
|
44
48
|
db.command("provision")
|
|
45
49
|
.description(
|
|
46
|
-
"Provision (or re-bind) the company remote vault DB via HQ control plane",
|
|
50
|
+
"Provision (or re-bind) the company remote vault DB via HQ control plane (Team plan)",
|
|
47
51
|
)
|
|
48
52
|
.requiredOption("--company <slug>", "Company slug")
|
|
49
53
|
.option("--region <region>", "AWS region", "us-east-1")
|
|
@@ -90,6 +94,14 @@ export function registerDbProvisionCommand(
|
|
|
90
94
|
"Local databases still work: hq db status|sql|migrate — no Team plan required.",
|
|
91
95
|
),
|
|
92
96
|
);
|
|
97
|
+
} else if (status === 404 || /Unknown route|Not Found/i.test(msg)) {
|
|
98
|
+
console.error(
|
|
99
|
+
chalk.red("Error:"),
|
|
100
|
+
"Remote DB control plane is not available yet (routes not deployed).",
|
|
101
|
+
);
|
|
102
|
+
console.error(
|
|
103
|
+
chalk.dim("Local hq db status|sql|migrate still work offline."),
|
|
104
|
+
);
|
|
93
105
|
} else {
|
|
94
106
|
console.error(chalk.red("Error:"), msg);
|
|
95
107
|
}
|
|
@@ -55,20 +55,28 @@ export function registerDbStatusCommand(db: Command): void {
|
|
|
55
55
|
console.log(formatLocalDbStatus(status));
|
|
56
56
|
|
|
57
57
|
if (opts.remote) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"https://hqapi.getindigo.ai";
|
|
58
|
+
const { DEFAULT_VAULT_API_URL, ensureCognitoToken } = await import(
|
|
59
|
+
"../utils/cognito-session.js"
|
|
60
|
+
);
|
|
61
|
+
const { getCompanyUid } = await import("../utils/vault-api.js");
|
|
63
62
|
try {
|
|
63
|
+
const token =
|
|
64
|
+
process.env.HQ_DB_MOCK_CONTROL_PLANE === "1"
|
|
65
|
+
? "mock-token"
|
|
66
|
+
: await ensureCognitoToken({ interactive: false });
|
|
67
|
+
const companyUid =
|
|
68
|
+
opts.companyUid ||
|
|
69
|
+
(process.env.HQ_DB_MOCK_CONTROL_PLANE === "1"
|
|
70
|
+
? `cmp_${company}`
|
|
71
|
+
: await getCompanyUid(token, company));
|
|
72
|
+
const baseUrl =
|
|
73
|
+
process.env.HQ_VAULT_API_URL ||
|
|
74
|
+
process.env.HQ_API_BASE_URL ||
|
|
75
|
+
process.env.HQ_CLOUD_API_URL ||
|
|
76
|
+
DEFAULT_VAULT_API_URL;
|
|
64
77
|
const client = new ControlPlaneDbClient({
|
|
65
78
|
baseUrl,
|
|
66
|
-
getAccessToken: async () =>
|
|
67
|
-
if (process.env.HQ_DB_MOCK_CONTROL_PLANE === "1") {
|
|
68
|
-
return "mock-token";
|
|
69
|
-
}
|
|
70
|
-
throw new Error("auth required for remote status");
|
|
71
|
-
},
|
|
79
|
+
getAccessToken: async () => token,
|
|
72
80
|
});
|
|
73
81
|
const remote = await client.status(companyUid);
|
|
74
82
|
if (!remote.remote) {
|
|
@@ -95,6 +95,24 @@ describe("detectTarget", () => {
|
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
it("recognizes fleet agent uids", () => {
|
|
99
|
+
expect(detectTarget("agt_01HXYZABCDEFGHJKMNPQRSTVWX")).toEqual({
|
|
100
|
+
type: "agent",
|
|
101
|
+
value: "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
|
|
102
|
+
isAgent: true,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("recognizes fleet agent machine emails", () => {
|
|
107
|
+
expect(
|
|
108
|
+
detectTarget("agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai"),
|
|
109
|
+
).toEqual({
|
|
110
|
+
type: "email",
|
|
111
|
+
value: "agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
|
|
112
|
+
isAgent: true,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
98
116
|
it("returns null for invalid targets", () => {
|
|
99
117
|
expect(detectTarget("not-a-target")).toBeNull();
|
|
100
118
|
expect(detectTarget("cmp_company")).toBeNull();
|
|
@@ -234,6 +252,48 @@ describe("inviteMember", () => {
|
|
|
234
252
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
235
253
|
});
|
|
236
254
|
|
|
255
|
+
it("invites a fleet agent by uid as member via /membership/invite", async () => {
|
|
256
|
+
fetchSpy.mockResolvedValueOnce(
|
|
257
|
+
jsonResponse(201, {
|
|
258
|
+
membership: {
|
|
259
|
+
membershipKey: "agt_01HXYZ#cmp_acme",
|
|
260
|
+
role: "member",
|
|
261
|
+
status: "active",
|
|
262
|
+
},
|
|
263
|
+
agentUid: "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
|
|
264
|
+
hostCompanyUid: "cmp_host",
|
|
265
|
+
activationBilled: false,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const result = await inviteMember({
|
|
270
|
+
target: "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
|
|
271
|
+
role: "member",
|
|
272
|
+
companyUid: "cmp_acme",
|
|
273
|
+
callerUid: "prs_admin",
|
|
274
|
+
token: "test-token",
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(result.membership.status).toBe("active");
|
|
278
|
+
expect(result.membership.role).toBe("member");
|
|
279
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
280
|
+
expect(body.personUid).toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX");
|
|
281
|
+
expect(body.role).toBe("member");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("rejects owner/guest roles for agent invites", async () => {
|
|
285
|
+
await expect(
|
|
286
|
+
inviteMember({
|
|
287
|
+
target: "agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
|
|
288
|
+
role: "owner",
|
|
289
|
+
companyUid: "cmp_acme",
|
|
290
|
+
callerUid: "prs_admin",
|
|
291
|
+
token: "test-token",
|
|
292
|
+
}),
|
|
293
|
+
).rejects.toThrow(/member or admin/);
|
|
294
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
|
|
237
297
|
it("rejects an unknown role", async () => {
|
|
238
298
|
await expect(
|
|
239
299
|
inviteMember({
|
package/src/commands/members.ts
CHANGED
|
@@ -5,7 +5,13 @@ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
|
5
5
|
|
|
6
6
|
const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
|
|
7
7
|
const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
|
|
8
|
+
const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/i;
|
|
9
|
+
/** Fleet agent machine email: agt-…@agents.getindigo.ai */
|
|
10
|
+
const AGENT_EMAIL_PATTERN =
|
|
11
|
+
/^agt-[a-z0-9]+@agents\.getindigo\.ai$/i;
|
|
8
12
|
export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
|
|
13
|
+
/** Roles allowed when inviting an existing fleet agent into a company. */
|
|
14
|
+
export const AGENT_INVITE_ROLES = new Set(["admin", "member"]);
|
|
9
15
|
|
|
10
16
|
export type Role = "owner" | "admin" | "member" | "guest";
|
|
11
17
|
|
|
@@ -100,6 +106,13 @@ export interface InviteResult {
|
|
|
100
106
|
emailSkipped?: boolean;
|
|
101
107
|
/** Resend was attempted but failed — human-readable reason. */
|
|
102
108
|
emailError?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Present when hq-pro treated the target as a fleet agent (guest invite).
|
|
111
|
+
* Always `false` on the agent path — host keeps billing.
|
|
112
|
+
*/
|
|
113
|
+
activationBilled?: boolean;
|
|
114
|
+
/** Host company uid when the server returned an agent guest-invite result. */
|
|
115
|
+
hostCompanyUid?: string;
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
export interface ResendInviteOptions {
|
|
@@ -122,16 +135,29 @@ export interface ResendInviteResult {
|
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
export interface DetectedTarget {
|
|
125
|
-
type: "email" | "person";
|
|
138
|
+
type: "email" | "person" | "agent";
|
|
126
139
|
value: string;
|
|
140
|
+
/** True when the target is a fleet agent (uid or machine email). */
|
|
141
|
+
isAgent?: boolean;
|
|
127
142
|
}
|
|
128
143
|
|
|
129
144
|
export function detectTarget(target: string): DetectedTarget | null {
|
|
130
|
-
|
|
131
|
-
|
|
145
|
+
const trimmed = target.trim();
|
|
146
|
+
if (AGENT_UID_PATTERN.test(trimmed)) {
|
|
147
|
+
return { type: "agent", value: trimmed, isAgent: true };
|
|
148
|
+
}
|
|
149
|
+
if (AGENT_EMAIL_PATTERN.test(trimmed)) {
|
|
150
|
+
return {
|
|
151
|
+
type: "email",
|
|
152
|
+
value: trimmed.toLowerCase(),
|
|
153
|
+
isAgent: true,
|
|
154
|
+
};
|
|
132
155
|
}
|
|
133
|
-
if (
|
|
134
|
-
return { type: "
|
|
156
|
+
if (EMAIL_PATTERN.test(trimmed)) {
|
|
157
|
+
return { type: "email", value: trimmed.toLowerCase() };
|
|
158
|
+
}
|
|
159
|
+
if (PERSON_UID_PATTERN.test(trimmed)) {
|
|
160
|
+
return { type: "person", value: trimmed };
|
|
135
161
|
}
|
|
136
162
|
return null;
|
|
137
163
|
}
|
|
@@ -180,11 +206,35 @@ export async function inviteMember(
|
|
|
180
206
|
const detected = detectTarget(options.target);
|
|
181
207
|
if (!detected) {
|
|
182
208
|
throw new Error(
|
|
183
|
-
`Invalid target '${options.target}': must be an email
|
|
209
|
+
`Invalid target '${options.target}': must be an email, personUid (prs_…), agent uid (agt_…), or agent email (agt-…@agents.getindigo.ai)`,
|
|
184
210
|
);
|
|
185
211
|
}
|
|
186
212
|
|
|
187
|
-
|
|
213
|
+
// Fleet agents: same invite command as humans, but only member|admin.
|
|
214
|
+
// Host company keeps billing — server never double-bills on guest invite.
|
|
215
|
+
if (detected.isAgent) {
|
|
216
|
+
if (!AGENT_INVITE_ROLES.has(options.role)) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Agents can only be invited as member or admin (got '${options.role}'). Host company keeps billing.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (options.paths) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"--paths / guest role is not valid for agents — invite as member or admin",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (options.groupIds && options.groupIds.length > 0) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"--groups is not supported on agent invites — share secrets after membership with `hq secrets share`",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (
|
|
234
|
+
options.groupIds &&
|
|
235
|
+
options.groupIds.length > 0 &&
|
|
236
|
+
(detected.type === "person" || detected.type === "agent")
|
|
237
|
+
) {
|
|
188
238
|
throw new Error(
|
|
189
239
|
"--groups is only valid on email-keyed invites (server rejects personUid + groupIds with 400)",
|
|
190
240
|
);
|
|
@@ -236,6 +286,8 @@ export async function inviteMember(
|
|
|
236
286
|
emailSent?: boolean;
|
|
237
287
|
emailSkipped?: boolean;
|
|
238
288
|
emailError?: string;
|
|
289
|
+
activationBilled?: boolean;
|
|
290
|
+
hostCompanyUid?: string;
|
|
239
291
|
};
|
|
240
292
|
if (!data.membership) {
|
|
241
293
|
const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
|
|
@@ -267,6 +319,12 @@ export async function inviteMember(
|
|
|
267
319
|
? { emailSkipped: data.emailSkipped }
|
|
268
320
|
: {}),
|
|
269
321
|
...(data.emailError ? { emailError: data.emailError } : {}),
|
|
322
|
+
...(typeof data.activationBilled === "boolean"
|
|
323
|
+
? { activationBilled: data.activationBilled }
|
|
324
|
+
: {}),
|
|
325
|
+
...(typeof data.hostCompanyUid === "string"
|
|
326
|
+
? { hostCompanyUid: data.hostCompanyUid }
|
|
327
|
+
: {}),
|
|
270
328
|
};
|
|
271
329
|
}
|
|
272
330
|
|
|
@@ -604,11 +662,11 @@ export function registerMembersCommand(program: Command): void {
|
|
|
604
662
|
members
|
|
605
663
|
.command("invite <target>")
|
|
606
664
|
.description(
|
|
607
|
-
"Invite a person to the company
|
|
665
|
+
"Invite a person or fleet agent to the company. People: email or prs_…. Agents: agt_… or agt-…@agents.getindigo.ai (member|admin; host keeps billing, no double charge).",
|
|
608
666
|
)
|
|
609
667
|
.option(
|
|
610
668
|
"--role <role>",
|
|
611
|
-
"Role
|
|
669
|
+
"Role: person → owner|admin|member|guest; agent → member|admin only",
|
|
612
670
|
"member",
|
|
613
671
|
)
|
|
614
672
|
.option(
|
|
@@ -681,17 +739,49 @@ export function registerMembersCommand(program: Command): void {
|
|
|
681
739
|
role: opts.role,
|
|
682
740
|
paths: opts.paths,
|
|
683
741
|
groupIds,
|
|
684
|
-
|
|
742
|
+
// Agents join actively — no Resend email.
|
|
743
|
+
sendEmail: detectTarget(target)?.isAgent ? false : opts.sendEmail,
|
|
685
744
|
companyUid,
|
|
686
745
|
callerUid,
|
|
687
746
|
token,
|
|
688
747
|
});
|
|
689
748
|
|
|
749
|
+
const agentTarget = detectTarget(target)?.isAgent === true;
|
|
690
750
|
console.log(
|
|
691
751
|
chalk.green(
|
|
692
752
|
`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`,
|
|
693
753
|
),
|
|
694
754
|
);
|
|
755
|
+
// Agent path is confirmed only when the server returned the guest-invite
|
|
756
|
+
// envelope (activationBilled present) AND status is active. A pending
|
|
757
|
+
// row means this hq-pro stage has not rolled the agent invite path yet —
|
|
758
|
+
// do NOT claim active join / no-double-bill.
|
|
759
|
+
if (
|
|
760
|
+
agentTarget &&
|
|
761
|
+
result.membership.status === "active" &&
|
|
762
|
+
typeof result.activationBilled === "boolean"
|
|
763
|
+
) {
|
|
764
|
+
console.log(
|
|
765
|
+
chalk.dim(
|
|
766
|
+
`Fleet agent: active membership granted immediately` +
|
|
767
|
+
(result.hostCompanyUid
|
|
768
|
+
? ` (host ${result.hostCompanyUid} keeps billing)`
|
|
769
|
+
: " (host keeps billing, no double charge)") +
|
|
770
|
+
`. Next: share secrets / vault paths as needed (\`hq secrets share\`, \`/new-agent\`).`,
|
|
771
|
+
),
|
|
772
|
+
);
|
|
773
|
+
console.log();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (agentTarget && result.membership.status !== "active") {
|
|
777
|
+
console.log(
|
|
778
|
+
chalk.yellow(
|
|
779
|
+
"⚠ Agent invite landed as a pending membership — this hq-pro stage may not yet have multi-company agent invite. Wait for prod deploy of the agent membership path, then re-invite (or revoke this pending row).",
|
|
780
|
+
),
|
|
781
|
+
);
|
|
782
|
+
console.log();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
695
785
|
console.log();
|
|
696
786
|
if (result.magicLink) {
|
|
697
787
|
// Legacy server schema — magic-link redemption.
|