@indigoai-us/hq-cli 5.50.2 → 5.52.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 +23 -0
- package/dist/bin/hq-auth-refresh.d.ts +1 -1
- package/dist/bin/hq-auth-refresh.js +5 -2
- package/dist/commands/files.js +33 -3
- package/dist/commands/members.d.ts +27 -0
- package/dist/commands/members.js +95 -37
- package/dist/commands/people.d.ts +26 -1
- package/dist/commands/people.js +70 -7
- package/dist/commands/secrets-scope.d.ts +20 -0
- package/dist/commands/secrets-scope.js +19 -0
- package/dist/commands/secrets.js +21 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +44 -14
- package/dist/node-preflight.d.ts +39 -0
- package/dist/node-preflight.js +55 -0
- package/dist/sentry.d.ts +12 -0
- package/dist/sentry.js +19 -3
- package/dist/utils/epipe.d.ts +8 -0
- package/dist/utils/epipe.js +30 -0
- package/dist/utils/intercepted-process-exit.d.ts +7 -0
- package/dist/utils/intercepted-process-exit.js +38 -0
- package/e2e/cli.test.ts +35 -0
- package/package.json +1 -1
- package/src/bin/hq-auth-refresh.ts +3 -0
- package/src/commands/files.test.ts +130 -0
- package/src/commands/files.ts +55 -3
- package/src/commands/members.test.ts +292 -0
- package/src/commands/members.ts +153 -43
- package/src/commands/people.test.ts +212 -5
- package/src/commands/people.ts +141 -5
- package/src/commands/secrets-scope.test.ts +56 -0
- package/src/commands/secrets-scope.ts +32 -0
- package/src/commands/secrets.ts +24 -10
- package/src/index.ts +40 -12
- package/src/node-preflight.test.ts +60 -0
- package/src/node-preflight.ts +67 -0
- package/src/sentry-epipe.test.ts +37 -0
- package/src/sentry-release.test.ts +54 -0
- package/src/sentry.ts +21 -1
- package/src/utils/epipe.test.ts +28 -0
- package/src/utils/epipe.ts +29 -0
- package/src/utils/intercepted-process-exit.test.ts +37 -0
- package/src/utils/intercepted-process-exit.ts +36 -0
|
@@ -34,10 +34,12 @@ 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,
|
|
40
41
|
inviteMember,
|
|
42
|
+
listActiveMembers,
|
|
41
43
|
listPendingInvites,
|
|
42
44
|
registerMembersCommand,
|
|
43
45
|
resendInvite,
|
|
@@ -743,6 +745,181 @@ describe("listPendingInvites", () => {
|
|
|
743
745
|
});
|
|
744
746
|
});
|
|
745
747
|
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
// listActiveMembers
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
|
|
752
|
+
describe("listActiveMembers", () => {
|
|
753
|
+
it("GETs /membership/company/{uid} and returns the members array", async () => {
|
|
754
|
+
fetchSpy.mockResolvedValueOnce(
|
|
755
|
+
jsonResponse(200, {
|
|
756
|
+
members: [
|
|
757
|
+
{
|
|
758
|
+
membershipKey: "k1",
|
|
759
|
+
personUid: "prs_alice",
|
|
760
|
+
companyUid: "cmp_acme",
|
|
761
|
+
role: "owner",
|
|
762
|
+
status: "active",
|
|
763
|
+
personEmail: "alice@example.com",
|
|
764
|
+
personName: "Alice",
|
|
765
|
+
},
|
|
766
|
+
],
|
|
767
|
+
}),
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const members = await listActiveMembers("test-token", "cmp_acme");
|
|
771
|
+
|
|
772
|
+
const call = fetchSpy.mock.calls[0];
|
|
773
|
+
expect(String(call[0])).toMatch(/\/membership\/company\/cmp_acme$/);
|
|
774
|
+
expect(members).toHaveLength(1);
|
|
775
|
+
expect(members[0].personEmail).toBe("alice@example.com");
|
|
776
|
+
expect(members[0].role).toBe("owner");
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("returns [] when the members key is missing", async () => {
|
|
780
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
781
|
+
await expect(
|
|
782
|
+
listActiveMembers("test-token", "cmp_acme"),
|
|
783
|
+
).resolves.toEqual([]);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("returns [] when members is null", async () => {
|
|
787
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: null }));
|
|
788
|
+
await expect(
|
|
789
|
+
listActiveMembers("test-token", "cmp_acme"),
|
|
790
|
+
).resolves.toEqual([]);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("throws InviteHttpError on non-ok", async () => {
|
|
794
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden" }));
|
|
795
|
+
await expect(
|
|
796
|
+
listActiveMembers("test-token", "cmp_acme"),
|
|
797
|
+
).rejects.toBeInstanceOf(InviteHttpError);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
// registerMembersCommand list
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
describe("registerMembersCommand list", () => {
|
|
806
|
+
it("defaults to ACTIVE members: renders EMAIL/ROLE/NAME + the share hint", async () => {
|
|
807
|
+
fetchSpy.mockResolvedValueOnce(
|
|
808
|
+
jsonResponse(200, {
|
|
809
|
+
members: [
|
|
810
|
+
{
|
|
811
|
+
membershipKey: "k1",
|
|
812
|
+
personUid: "prs_alice",
|
|
813
|
+
companyUid: "cmp_acme",
|
|
814
|
+
role: "owner",
|
|
815
|
+
status: "active",
|
|
816
|
+
personEmail: "alice@example.com",
|
|
817
|
+
personName: "Alice",
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
membershipKey: "k2",
|
|
821
|
+
personUid: "prs_bob",
|
|
822
|
+
companyUid: "cmp_acme",
|
|
823
|
+
role: "member",
|
|
824
|
+
status: "active",
|
|
825
|
+
personSlug: "bob",
|
|
826
|
+
},
|
|
827
|
+
],
|
|
828
|
+
}),
|
|
829
|
+
);
|
|
830
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
831
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
832
|
+
|
|
833
|
+
await buildMembersProgram().parseAsync(
|
|
834
|
+
["members", "--company", "acme", "list"],
|
|
835
|
+
{ from: "user" },
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
const call = fetchSpy.mock.calls[0];
|
|
839
|
+
expect(String(call[0])).toMatch(/\/membership\/company\/cmp_acme$/);
|
|
840
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
841
|
+
expect(output).toContain("EMAIL");
|
|
842
|
+
expect(output).toContain("ROLE");
|
|
843
|
+
expect(output).toContain("NAME");
|
|
844
|
+
expect(output).toContain("alice@example.com");
|
|
845
|
+
expect(output).toContain("owner");
|
|
846
|
+
expect(output).toContain("Alice");
|
|
847
|
+
// unresolved email falls back to personUid; name falls back to slug
|
|
848
|
+
expect(output).toContain("prs_bob");
|
|
849
|
+
expect(output).toContain("bob");
|
|
850
|
+
expect(output).toContain(
|
|
851
|
+
"hq secrets share <path> --with <email>",
|
|
852
|
+
);
|
|
853
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("default active: empty roster prints the no-active-members message", async () => {
|
|
857
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: [] }));
|
|
858
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
859
|
+
|
|
860
|
+
await buildMembersProgram().parseAsync(
|
|
861
|
+
["members", "--company", "acme", "list"],
|
|
862
|
+
{ from: "user" },
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
866
|
+
expect.stringContaining("No active members found for this company."),
|
|
867
|
+
);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("--pending preserves the OLD pending-invites table verbatim", async () => {
|
|
871
|
+
fetchSpy.mockResolvedValueOnce(
|
|
872
|
+
jsonResponse(200, {
|
|
873
|
+
pending: [
|
|
874
|
+
{
|
|
875
|
+
membershipKey: "email:alice@example.com#cmp_acme",
|
|
876
|
+
inviteeEmail: "alice@example.com",
|
|
877
|
+
companyUid: "cmp_acme",
|
|
878
|
+
role: "member",
|
|
879
|
+
status: "pending",
|
|
880
|
+
invitedBy: "prs_admin",
|
|
881
|
+
invitedAt: "2026-05-21T12:00:00Z",
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
}),
|
|
885
|
+
);
|
|
886
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
887
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
888
|
+
|
|
889
|
+
await buildMembersProgram().parseAsync(
|
|
890
|
+
["members", "--company", "acme", "list", "--pending"],
|
|
891
|
+
{ from: "user" },
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
const call = fetchSpy.mock.calls[0];
|
|
895
|
+
expect(String(call[0])).toMatch(
|
|
896
|
+
/\/membership\/company\/cmp_acme\/pending$/,
|
|
897
|
+
);
|
|
898
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
899
|
+
expect(output).toContain("TARGET");
|
|
900
|
+
expect(output).toContain("INVITED_BY");
|
|
901
|
+
expect(output).toContain("MEMBERSHIP_KEY");
|
|
902
|
+
expect(output).toContain("alice@example.com");
|
|
903
|
+
// the active-only share hint must NOT appear in the pending view
|
|
904
|
+
expect(output).not.toContain("hq secrets share");
|
|
905
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("--pending: empty invites prints the no-pending-invites message", async () => {
|
|
909
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { pending: [] }));
|
|
910
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
911
|
+
|
|
912
|
+
await buildMembersProgram().parseAsync(
|
|
913
|
+
["members", "--company", "acme", "list", "--pending"],
|
|
914
|
+
{ from: "user" },
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
918
|
+
expect.stringContaining("No pending invites for this company."),
|
|
919
|
+
);
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
|
|
746
923
|
// ---------------------------------------------------------------------------
|
|
747
924
|
// revokeInvite
|
|
748
925
|
// ---------------------------------------------------------------------------
|
|
@@ -822,6 +999,121 @@ describe("registerMembersCommand set-role", () => {
|
|
|
822
999
|
});
|
|
823
1000
|
});
|
|
824
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
|
+
|
|
825
1117
|
// ---------------------------------------------------------------------------
|
|
826
1118
|
// resolveRevokeTargetToMembershipKey
|
|
827
1119
|
// ---------------------------------------------------------------------------
|
package/src/commands/members.ts
CHANGED
|
@@ -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;
|
|
@@ -31,6 +29,23 @@ interface MyMembership {
|
|
|
31
29
|
status: string;
|
|
32
30
|
}
|
|
33
31
|
|
|
32
|
+
/**
|
|
33
|
+
* An ACTIVE member of a company, as returned by
|
|
34
|
+
* `GET /membership/company/{companyUid}`. The server filters to
|
|
35
|
+
* `status: "active"` and enriches each row with resolved person metadata
|
|
36
|
+
* (`personEmail` / `personName` / `personSlug`) when available.
|
|
37
|
+
*/
|
|
38
|
+
export interface ActiveMember {
|
|
39
|
+
membershipKey: string;
|
|
40
|
+
personUid: string;
|
|
41
|
+
companyUid: string;
|
|
42
|
+
role: string;
|
|
43
|
+
status: string;
|
|
44
|
+
personEmail?: string;
|
|
45
|
+
personName?: string;
|
|
46
|
+
personSlug?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
export interface InviteOptions {
|
|
35
50
|
target: string;
|
|
36
51
|
role: string;
|
|
@@ -374,6 +389,30 @@ export async function listPendingInvites(
|
|
|
374
389
|
return data?.pending ?? data?.invites ?? [];
|
|
375
390
|
}
|
|
376
391
|
|
|
392
|
+
export async function listActiveMembers(
|
|
393
|
+
token: string,
|
|
394
|
+
companyUid: string,
|
|
395
|
+
): Promise<ActiveMember[]> {
|
|
396
|
+
const res = await vaultApiFetch({
|
|
397
|
+
token,
|
|
398
|
+
path: `/membership/company/${encodeURIComponent(companyUid)}`,
|
|
399
|
+
});
|
|
400
|
+
if (!res.ok) {
|
|
401
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
402
|
+
throw new InviteHttpError(
|
|
403
|
+
res.status,
|
|
404
|
+
err.message ?? err.error ?? res.statusText,
|
|
405
|
+
err.code,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
// Server schema: `{ members: [...] }` — active members only, enriched with
|
|
409
|
+
// resolved person metadata (personEmail / personName / personSlug).
|
|
410
|
+
const data = (await res.json()) as {
|
|
411
|
+
members?: ActiveMember[] | null;
|
|
412
|
+
};
|
|
413
|
+
return data?.members ?? [];
|
|
414
|
+
}
|
|
415
|
+
|
|
377
416
|
/**
|
|
378
417
|
* Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
|
|
379
418
|
* the server requires. Accepts three input forms:
|
|
@@ -457,15 +496,24 @@ export async function revokeInvite(
|
|
|
457
496
|
}
|
|
458
497
|
}
|
|
459
498
|
|
|
460
|
-
|
|
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(
|
|
461
509
|
token: string,
|
|
462
510
|
companyUid: string,
|
|
463
511
|
membershipKey: string,
|
|
464
|
-
newRole:
|
|
512
|
+
newRole: Role,
|
|
465
513
|
): Promise<void> {
|
|
466
|
-
if (!
|
|
514
|
+
if (!VALID_ROLES.has(newRole)) {
|
|
467
515
|
throw new Error(
|
|
468
|
-
`Invalid role '${newRole}': must be one of admin, member`,
|
|
516
|
+
`Invalid role '${newRole}': must be one of owner, admin, member, guest`,
|
|
469
517
|
);
|
|
470
518
|
}
|
|
471
519
|
|
|
@@ -491,23 +539,41 @@ export function registerMembersCommand(program: Command): void {
|
|
|
491
539
|
.description("Manage company memberships and invites")
|
|
492
540
|
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
493
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.
|
|
494
546
|
members
|
|
495
|
-
.command("
|
|
496
|
-
.
|
|
497
|
-
.
|
|
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) => {
|
|
498
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
|
+
|
|
499
566
|
const token = await ensureCognitoToken();
|
|
500
567
|
const companySlug = members.opts().company as string | undefined;
|
|
501
568
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
token,
|
|
569
|
+
const membershipKey = resolveRevokeTargetToMembershipKey(
|
|
570
|
+
target,
|
|
505
571
|
companyUid,
|
|
506
|
-
membershipKey,
|
|
507
|
-
role as MemberSetRole,
|
|
508
572
|
);
|
|
573
|
+
|
|
574
|
+
await changeMemberRole(token, companyUid, membershipKey, role as Role);
|
|
509
575
|
console.log(
|
|
510
|
-
chalk.green(`Updated role for '${
|
|
576
|
+
chalk.green(`Updated role for '${target}' to ${role}`),
|
|
511
577
|
);
|
|
512
578
|
} catch (err) {
|
|
513
579
|
if (err instanceof InviteHttpError) {
|
|
@@ -746,55 +812,99 @@ export function registerMembersCommand(program: Command): void {
|
|
|
746
812
|
|
|
747
813
|
members
|
|
748
814
|
.command("list")
|
|
749
|
-
.description(
|
|
750
|
-
|
|
815
|
+
.description(
|
|
816
|
+
"List the company's active members (use --pending for pending invites)",
|
|
817
|
+
)
|
|
818
|
+
.option("--pending", "List pending invites instead of active members")
|
|
819
|
+
.action(async (opts: { pending?: boolean }) => {
|
|
751
820
|
try {
|
|
752
821
|
const token = await ensureCognitoToken();
|
|
753
822
|
const companySlug = members.opts().company as string | undefined;
|
|
754
823
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
755
824
|
|
|
756
|
-
|
|
825
|
+
if (opts.pending) {
|
|
826
|
+
// --pending: preserve the original pending-invites view verbatim.
|
|
827
|
+
const invites = await listPendingInvites(token, companyUid);
|
|
828
|
+
|
|
829
|
+
if (invites.length === 0) {
|
|
830
|
+
console.log(chalk.gray("No pending invites for this company."));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const targetW = Math.max(
|
|
835
|
+
6,
|
|
836
|
+
...invites.map((i) => (i.inviteeEmail ?? i.personUid ?? "").length),
|
|
837
|
+
);
|
|
838
|
+
const roleW = Math.max(4, ...invites.map((i) => i.role.length));
|
|
839
|
+
const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
|
|
840
|
+
const keyW = Math.max(
|
|
841
|
+
14,
|
|
842
|
+
...invites.map((i) => i.membershipKey.length),
|
|
843
|
+
);
|
|
844
|
+
console.log(
|
|
845
|
+
chalk.bold(
|
|
846
|
+
[
|
|
847
|
+
"TARGET".padEnd(targetW),
|
|
848
|
+
"ROLE".padEnd(roleW),
|
|
849
|
+
"INVITED_BY".padEnd(byW),
|
|
850
|
+
"INVITED_AT",
|
|
851
|
+
"MEMBERSHIP_KEY".padEnd(keyW),
|
|
852
|
+
].join(" "),
|
|
853
|
+
),
|
|
854
|
+
);
|
|
855
|
+
for (const inv of invites) {
|
|
856
|
+
const target = inv.inviteeEmail ?? inv.personUid ?? "";
|
|
857
|
+
console.log(
|
|
858
|
+
[
|
|
859
|
+
target.padEnd(targetW),
|
|
860
|
+
inv.role.padEnd(roleW),
|
|
861
|
+
inv.invitedBy.padEnd(byW),
|
|
862
|
+
shortDate(inv.invitedAt),
|
|
863
|
+
inv.membershipKey.padEnd(keyW),
|
|
864
|
+
].join(" "),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Default: list ACTIVE members so their emails drop straight into
|
|
871
|
+
// `hq secrets share <path> --with <email>`.
|
|
872
|
+
const activeMembers = await listActiveMembers(token, companyUid);
|
|
757
873
|
|
|
758
|
-
if (
|
|
759
|
-
console.log(chalk.gray("No
|
|
874
|
+
if (activeMembers.length === 0) {
|
|
875
|
+
console.log(chalk.gray("No active members found for this company."));
|
|
760
876
|
return;
|
|
761
877
|
}
|
|
762
878
|
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
...
|
|
879
|
+
const emailW = Math.max(
|
|
880
|
+
5,
|
|
881
|
+
...activeMembers.map((m) => (m.personEmail ?? m.personUid).length),
|
|
766
882
|
);
|
|
767
|
-
const roleW = Math.max(4, ...
|
|
768
|
-
const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
|
|
769
|
-
const keyW = Math.max(14, ...invites.map((i) => i.membershipKey.length));
|
|
883
|
+
const roleW = Math.max(4, ...activeMembers.map((m) => m.role.length));
|
|
770
884
|
console.log(
|
|
771
885
|
chalk.bold(
|
|
772
|
-
[
|
|
773
|
-
"TARGET".padEnd(targetW),
|
|
774
|
-
"ROLE".padEnd(roleW),
|
|
775
|
-
"INVITED_BY".padEnd(byW),
|
|
776
|
-
"INVITED_AT",
|
|
777
|
-
"MEMBERSHIP_KEY".padEnd(keyW),
|
|
778
|
-
].join(" "),
|
|
886
|
+
["EMAIL".padEnd(emailW), "ROLE".padEnd(roleW), "NAME"].join(" "),
|
|
779
887
|
),
|
|
780
888
|
);
|
|
781
|
-
for (const
|
|
782
|
-
const
|
|
889
|
+
for (const m of activeMembers) {
|
|
890
|
+
const email = m.personEmail ?? m.personUid;
|
|
891
|
+
const name = m.personName ?? m.personSlug ?? "";
|
|
783
892
|
console.log(
|
|
784
|
-
[
|
|
785
|
-
target.padEnd(targetW),
|
|
786
|
-
inv.role.padEnd(roleW),
|
|
787
|
-
inv.invitedBy.padEnd(byW),
|
|
788
|
-
shortDate(inv.invitedAt),
|
|
789
|
-
inv.membershipKey.padEnd(keyW),
|
|
790
|
-
].join(" "),
|
|
893
|
+
[email.padEnd(emailW), m.role.padEnd(roleW), name].join(" "),
|
|
791
894
|
);
|
|
792
895
|
}
|
|
896
|
+
console.log(
|
|
897
|
+
chalk.gray(
|
|
898
|
+
"Share a secret with a member: hq secrets share <path> --with <email>",
|
|
899
|
+
),
|
|
900
|
+
);
|
|
793
901
|
} catch (err) {
|
|
794
902
|
if (err instanceof InviteHttpError) {
|
|
795
903
|
const msg =
|
|
796
904
|
err.status === 403
|
|
797
|
-
?
|
|
905
|
+
? opts.pending
|
|
906
|
+
? "Not authorized — only admins and owners can list invites"
|
|
907
|
+
: "Not authorized — only company members can list members"
|
|
798
908
|
: formatInviteHttpError(err.status, err.message);
|
|
799
909
|
console.error(chalk.red(msg));
|
|
800
910
|
process.exit(1);
|