@indigoai-us/hq-cli 5.51.0 → 5.53.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.
@@ -34,6 +34,7 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
34
34
  import { getCompanyUid } from "../utils/vault-api.js";
35
35
  import {
36
36
  InviteHttpError,
37
+ changeMemberRole,
37
38
  detectTarget,
38
39
  formatInviteHttpError,
39
40
  getCallerPersonUid,
@@ -998,6 +999,121 @@ describe("registerMembersCommand set-role", () => {
998
999
  });
999
1000
  });
1000
1001
 
1002
+ // ---------------------------------------------------------------------------
1003
+ // changeMemberRole + the unified `promote` command (alias `set-role`)
1004
+ // ---------------------------------------------------------------------------
1005
+
1006
+ describe("changeMemberRole", () => {
1007
+ it("rejects an invalid role WITHOUT making a network call", async () => {
1008
+ await expect(
1009
+ changeMemberRole(
1010
+ "test-token",
1011
+ "cmp_acme",
1012
+ "prs_x#cmp_acme",
1013
+ "superuser" as never,
1014
+ ),
1015
+ ).rejects.toThrow("must be one of owner, admin, member, guest");
1016
+ expect(fetchSpy).not.toHaveBeenCalled();
1017
+ });
1018
+
1019
+ it("accepts the full role set and POSTs to /membership/role", async () => {
1020
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
1021
+ await changeMemberRole("test-token", "cmp_acme", "prs_x#cmp_acme", "guest");
1022
+ const call = fetchSpy.mock.calls[0];
1023
+ expect(String(call[0])).toMatch(/\/membership\/role$/);
1024
+ const body = JSON.parse((call[1]?.body as string) ?? "{}");
1025
+ expect(body).toEqual({
1026
+ companyUid: "cmp_acme",
1027
+ membershipKey: "prs_x#cmp_acme",
1028
+ newRole: "guest",
1029
+ });
1030
+ });
1031
+ });
1032
+
1033
+ describe("registerMembersCommand promote", () => {
1034
+ it("resolves an email target and promotes to a full-set role the old set-role couldn't (admin)", async () => {
1035
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
1036
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
1037
+
1038
+ await buildMembersProgram().parseAsync(
1039
+ ["members", "--company", "acme", "promote", "alice@example.com", "admin"],
1040
+ { from: "user" },
1041
+ );
1042
+
1043
+ const call = fetchSpy.mock.calls[0];
1044
+ expect(String(call[0])).toMatch(/\/membership\/role$/);
1045
+ const body = JSON.parse((call[1]?.body as string) ?? "{}");
1046
+ expect(body).toEqual({
1047
+ companyUid: "cmp_acme",
1048
+ membershipKey: "email:alice@example.com#cmp_acme",
1049
+ newRole: "admin",
1050
+ });
1051
+ expect(logSpy).toHaveBeenCalledWith(
1052
+ expect.stringContaining("Updated role for 'alice@example.com' to admin"),
1053
+ );
1054
+ });
1055
+
1056
+ it("forwards a personUid target and the guest role (beyond set-role's admin|member cap)", async () => {
1057
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
1058
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
1059
+
1060
+ await buildMembersProgram().parseAsync(
1061
+ ["members", "--company", "acme", "promote", "prs_bob", "guest"],
1062
+ { from: "user" },
1063
+ );
1064
+
1065
+ const body = JSON.parse(
1066
+ (fetchSpy.mock.calls[0]?.[1]?.body as string) ?? "{}",
1067
+ );
1068
+ expect(body).toEqual({
1069
+ companyUid: "cmp_acme",
1070
+ membershipKey: "prs_bob#cmp_acme",
1071
+ newRole: "guest",
1072
+ });
1073
+ });
1074
+
1075
+ it("rejects an invalid role at the CLI and exits non-zero without a network call", async () => {
1076
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1077
+ vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
1078
+ throw new Error(`__EXIT__:${code ?? 0}`);
1079
+ }) as never);
1080
+
1081
+ await expect(
1082
+ buildMembersProgram().parseAsync(
1083
+ ["members", "--company", "acme", "promote", "alice@example.com", "wizard"],
1084
+ { from: "user" },
1085
+ ),
1086
+ ).rejects.toThrow("__EXIT__:1");
1087
+
1088
+ expect(errSpy).toHaveBeenCalledWith(
1089
+ expect.stringContaining("must be one of owner, admin, member, guest"),
1090
+ );
1091
+ expect(fetchSpy).not.toHaveBeenCalled();
1092
+ });
1093
+
1094
+ it("surfaces a backend 403 verbatim (server-enforced authorization)", async () => {
1095
+ fetchSpy.mockResolvedValueOnce(
1096
+ jsonResponse(403, { error: "Only an owner may promote a member to owner" }),
1097
+ );
1098
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1099
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
1100
+ vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
1101
+ throw new Error(`__EXIT__:${code ?? 0}`);
1102
+ }) as never);
1103
+
1104
+ await expect(
1105
+ buildMembersProgram().parseAsync(
1106
+ ["members", "--company", "acme", "promote", "prs_bob", "owner"],
1107
+ { from: "user" },
1108
+ ),
1109
+ ).rejects.toThrow("__EXIT__:1");
1110
+
1111
+ expect(errSpy).toHaveBeenCalledWith(
1112
+ expect.stringContaining("Only an owner may promote a member to owner"),
1113
+ );
1114
+ });
1115
+ });
1116
+
1001
1117
  // ---------------------------------------------------------------------------
1002
1118
  // resolveRevokeTargetToMembershipKey
1003
1119
  // ---------------------------------------------------------------------------
@@ -6,10 +6,8 @@ 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
8
  export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
9
- const VALID_MEMBER_SET_ROLES = new Set(["admin", "member"]);
10
9
 
11
10
  export type Role = "owner" | "admin" | "member" | "guest";
12
- type MemberSetRole = "admin" | "member";
13
11
 
14
12
  export interface PendingInvite {
15
13
  membershipKey: string;
@@ -498,15 +496,24 @@ export async function revokeInvite(
498
496
  }
499
497
  }
500
498
 
501
- async function setMemberRole(
499
+ /**
500
+ * Change a member's role via `POST /membership/role`, accepting the FULL role
501
+ * set (owner|admin|member|guest). This is a GENERAL role change — it can promote
502
+ * OR demote. Authorization (owner-or-admin `changeRoles`, owner-only
503
+ * promote-to-owner / change-an-owner) is enforced SERVER-side; this function
504
+ * only validates the role string locally and surfaces the server's error.
505
+ * Role string is validated BEFORE any network call so callers/tests can rely on
506
+ * a synchronous-shaped rejection for a bad role.
507
+ */
508
+ export async function changeMemberRole(
502
509
  token: string,
503
510
  companyUid: string,
504
511
  membershipKey: string,
505
- newRole: MemberSetRole,
512
+ newRole: Role,
506
513
  ): Promise<void> {
507
- if (!VALID_MEMBER_SET_ROLES.has(newRole)) {
514
+ if (!VALID_ROLES.has(newRole)) {
508
515
  throw new Error(
509
- `Invalid role '${newRole}': must be one of admin, member`,
516
+ `Invalid role '${newRole}': must be one of owner, admin, member, guest`,
510
517
  );
511
518
  }
512
519
 
@@ -532,23 +539,41 @@ export function registerMembersCommand(program: Command): void {
532
539
  .description("Manage company memberships and invites")
533
540
  .option("--company <slug>", "Company slug (resolves to companyUid)");
534
541
 
542
+ // Canonical role-change command. `promote` is the discoverable verb users
543
+ // reach for; `set-role` is kept as an alias for the older name. The route is a
544
+ // GENERAL role change, so the help text and success message are honest that it
545
+ // can demote as well as promote. Authorization is enforced server-side.
535
546
  members
536
- .command("set-role <membershipKey> <role>")
537
- .description("Change a member or agent role to admin or member")
538
- .action(async (membershipKey: string, role: string) => {
547
+ .command("promote <target> <newRole>")
548
+ .alias("set-role")
549
+ .description(
550
+ "Change a member's role (owner|admin|member|guest) — promotes OR demotes. " +
551
+ "<target> may be an email, a prs_ personUid, or a full membership key. " +
552
+ "Owner-or-admin only; setting a target to owner (or changing an owner) is owner-only. Server-enforced.",
553
+ )
554
+ .action(async (target: string, newRole: string) => {
539
555
  try {
556
+ const role = newRole.trim().toLowerCase();
557
+ if (!VALID_ROLES.has(role)) {
558
+ console.error(
559
+ chalk.red(
560
+ `Invalid role '${newRole}': must be one of owner, admin, member, guest`,
561
+ ),
562
+ );
563
+ process.exit(1);
564
+ }
565
+
540
566
  const token = await ensureCognitoToken();
541
567
  const companySlug = members.opts().company as string | undefined;
542
568
  const companyUid = await getCompanyUid(token, companySlug);
543
-
544
- await setMemberRole(
545
- token,
569
+ const membershipKey = resolveRevokeTargetToMembershipKey(
570
+ target,
546
571
  companyUid,
547
- membershipKey,
548
- role as MemberSetRole,
549
572
  );
573
+
574
+ await changeMemberRole(token, companyUid, membershipKey, role as Role);
550
575
  console.log(
551
- chalk.green(`Updated role for '${membershipKey}' to ${role}`),
576
+ chalk.green(`Updated role for '${target}' to ${role}`),
552
577
  );
553
578
  } catch (err) {
554
579
  if (err instanceof InviteHttpError) {
package/src/index.ts CHANGED
@@ -48,6 +48,8 @@ import { registerSignalsCommand } from "./commands/signals.js";
48
48
  import { registerReindexCommand } from "./commands/reindex.js";
49
49
  import { registerRescueCommand } from "./commands/rescue.js";
50
50
  import { registerMcpCommand } from "./commands/mcp-status.js";
51
+ import { registerCrmCommand } from "./commands/crm.js";
52
+ import { registerCompanyCommand } from "./commands/company.js";
51
53
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
52
54
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
53
55
  import { isEpipe } from "./utils/epipe.js";
@@ -214,6 +216,15 @@ registerRescueCommand(program);
214
216
  // off the configs, NOT linkStatus), with secret-redacted output + `--json`.
215
217
  registerMcpCommand(program);
216
218
 
219
+ // Native CRM entity upsert (subcommand group — `hq crm entity upsert`). Wraps
220
+ // POST /crm/entities (the ontology write gate) so an authenticated company
221
+ // member can create/update canonical CRM entities in the company vault.
222
+ registerCrmCommand(program);
223
+
224
+ // Company settings (subcommand group — `hq company settings set`). Owner-only
225
+ // toggles for crmEnabled / ontologyEnabled via PUT /company-settings.
226
+ registerCompanyCommand(program);
227
+
217
228
  (async () => {
218
229
  try {
219
230
  Sentry.addBreadcrumb({