@hexclave/cli 1.0.67 → 1.0.68
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/dist/index.js +245 -24
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/init.ts +0 -4
- package/src/commands/project.ts +4 -5
- package/src/commands/team.command.test.ts +124 -0
- package/src/commands/team.test.ts +25 -0
- package/src/commands/team.ts +301 -0
- package/src/index.ts +2 -0
- package/src/lib/create-project.ts +11 -13
- package/src/lib/resolve-team.test.ts +58 -0
- package/src/lib/resolve-team.ts +62 -0
- package/dist/errors-CyPbl0LV.js +0 -21
- package/dist/errors-CyPbl0LV.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hexclave/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.68",
|
|
4
4
|
"repository": "https://github.com/hexclave/hexclave",
|
|
5
5
|
"description": "The CLI for Hexclave. https://hexclave.com",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"commander": "^13.1.0",
|
|
26
26
|
"extract-zip": "^2.0.1",
|
|
27
27
|
"jiti": "^2.4.2",
|
|
28
|
-
"@hexclave/shared-backend": "1.0.
|
|
29
|
-
"@hexclave/
|
|
30
|
-
"@hexclave/
|
|
28
|
+
"@hexclave/shared-backend": "1.0.68",
|
|
29
|
+
"@hexclave/js": "1.0.68",
|
|
30
|
+
"@hexclave/shared": "1.0.68"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "20.17.6",
|
package/src/commands/init.ts
CHANGED
|
@@ -277,11 +277,9 @@ async function handleCreateCloud(_flags: Record<string, unknown>, opts: InitOpti
|
|
|
277
277
|
const sessionAuth = await ensureLoggedInSession();
|
|
278
278
|
const user = await withProgress("Loading account", async () => await getInternalUser(sessionAuth));
|
|
279
279
|
|
|
280
|
-
const { dashboardUrl } = resolveLoginConfig();
|
|
281
280
|
const newProject = await createProjectInteractively(user, {
|
|
282
281
|
displayName: opts.displayName,
|
|
283
282
|
defaultDisplayName: path.basename(outputDir),
|
|
284
|
-
dashboardUrl,
|
|
285
283
|
});
|
|
286
284
|
console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
|
|
287
285
|
|
|
@@ -317,10 +315,8 @@ async function handleLinkFromCloud(_flags: Record<string, unknown>, opts: InitOp
|
|
|
317
315
|
throw new CliError(`You don't own any projects. Create one at ${dashboardUrl} or re-run and choose to create one.`);
|
|
318
316
|
}
|
|
319
317
|
|
|
320
|
-
const { dashboardUrl } = resolveLoginConfig();
|
|
321
318
|
const newProject = await createProjectInteractively(user, {
|
|
322
319
|
defaultDisplayName: path.basename(outputDir),
|
|
323
|
-
dashboardUrl,
|
|
324
320
|
});
|
|
325
321
|
console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
|
|
326
322
|
projects = [newProject];
|
package/src/commands/project.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { getInternalUser } from "../lib/app.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSessionAuth } from "../lib/auth.js";
|
|
4
4
|
import { createProjectInteractively } from "../lib/create-project.js";
|
|
5
5
|
import { CliError } from "../lib/errors.js";
|
|
6
6
|
import { withProgress } from "../lib/progress.js";
|
|
@@ -84,22 +84,21 @@ export function registerProjectCommand(program: Command) {
|
|
|
84
84
|
.description("Create a new cloud project")
|
|
85
85
|
.option("--cloud", "Confirm that this creates a cloud project")
|
|
86
86
|
.option("--display-name <name>", "Project display name")
|
|
87
|
+
.option("--team-id <id>", "Team that owns the project")
|
|
87
88
|
.action(async (opts) => {
|
|
88
89
|
if (!opts.cloud) {
|
|
89
90
|
throw new CliError("hexclave project create currently only creates cloud projects. Pass --cloud to confirm.");
|
|
90
91
|
}
|
|
91
|
-
const [{ getInternalUser }, {
|
|
92
|
+
const [{ getInternalUser }, { resolveSessionAuth }, { createProjectInteractively }] = await Promise.all([
|
|
92
93
|
import("../lib/app.js"),
|
|
93
94
|
import("../lib/auth.js"),
|
|
94
95
|
import("../lib/create-project.js"),
|
|
95
96
|
]);
|
|
96
97
|
const auth = resolveSessionAuth();
|
|
97
98
|
const user = await withProgress("Loading account", async () => await getInternalUser(auth));
|
|
98
|
-
const { dashboardUrl } = resolveLoginConfig();
|
|
99
|
-
|
|
100
99
|
const newProject = await createProjectInteractively(user, {
|
|
101
100
|
displayName: opts.displayName,
|
|
102
|
-
|
|
101
|
+
teamId: opts.teamId,
|
|
103
102
|
});
|
|
104
103
|
|
|
105
104
|
if (program.opts().json) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { registerTeamCommand } from "./team.js";
|
|
4
|
+
|
|
5
|
+
type TestTeam = {
|
|
6
|
+
id: string,
|
|
7
|
+
displayName: string,
|
|
8
|
+
listUsers: () => Promise<Array<{ id: string, teamProfile: { displayName: string | null } }>>,
|
|
9
|
+
listInvitations: () => Promise<Array<{
|
|
10
|
+
id: string,
|
|
11
|
+
recipientEmail: string | null,
|
|
12
|
+
expiresAt: Date,
|
|
13
|
+
revoke: () => Promise<void>,
|
|
14
|
+
}>>,
|
|
15
|
+
removeUser: (userId: string) => Promise<void>,
|
|
16
|
+
inviteUser: (options: { email: string }) => Promise<void>,
|
|
17
|
+
update: (options: { displayName?: string }) => Promise<void>,
|
|
18
|
+
delete: () => Promise<void>,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type TestUser = {
|
|
22
|
+
listTeams: () => Promise<TestTeam[]>,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const mockState = vi.hoisted(() => ({
|
|
26
|
+
user: null as TestUser | null,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("../lib/app.js", () => ({
|
|
30
|
+
getInternalUser: vi.fn(async () => {
|
|
31
|
+
if (mockState.user == null) {
|
|
32
|
+
throw new Error("Expected test user to be configured.");
|
|
33
|
+
}
|
|
34
|
+
return mockState.user;
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("../lib/auth.js", () => ({
|
|
39
|
+
resolveSessionAuth: vi.fn(() => ({})),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
function createTeam(id: string, displayName: string): TestTeam {
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
displayName,
|
|
46
|
+
listUsers: vi.fn(async () => [{ id: "user-2", teamProfile: { displayName: "Member" } }]),
|
|
47
|
+
listInvitations: vi.fn(async () => []),
|
|
48
|
+
removeUser: vi.fn(async () => {}),
|
|
49
|
+
inviteUser: vi.fn(async () => {}),
|
|
50
|
+
update: vi.fn(async () => {}),
|
|
51
|
+
delete: vi.fn(async () => {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createProgram() {
|
|
56
|
+
const program = new Command();
|
|
57
|
+
program.exitOverride();
|
|
58
|
+
registerTeamCommand(program);
|
|
59
|
+
return program;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
mockState.user = null;
|
|
64
|
+
vi.restoreAllMocks();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("team command parsing", () => {
|
|
68
|
+
it("parses the team id on members remove", async () => {
|
|
69
|
+
const team = createTeam("team-1", "Acme");
|
|
70
|
+
mockState.user = {
|
|
71
|
+
listTeams: vi.fn(async () => [team]),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
75
|
+
await createProgram().parseAsync([
|
|
76
|
+
"node",
|
|
77
|
+
"hexclave",
|
|
78
|
+
"team",
|
|
79
|
+
"members",
|
|
80
|
+
"remove",
|
|
81
|
+
"--team-id",
|
|
82
|
+
"team-1",
|
|
83
|
+
"--user-id",
|
|
84
|
+
"user-2",
|
|
85
|
+
"--yes",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
expect(team.removeUser).toHaveBeenCalledWith("user-2");
|
|
89
|
+
expect(logSpy.mock.calls).toMatchInlineSnapshot(`
|
|
90
|
+
[
|
|
91
|
+
[
|
|
92
|
+
"Removed team member: user-2",
|
|
93
|
+
],
|
|
94
|
+
]
|
|
95
|
+
`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("parses the team id on invitations list", async () => {
|
|
99
|
+
const team = createTeam("team-1", "Acme");
|
|
100
|
+
mockState.user = {
|
|
101
|
+
listTeams: vi.fn(async () => [team]),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
105
|
+
await createProgram().parseAsync([
|
|
106
|
+
"node",
|
|
107
|
+
"hexclave",
|
|
108
|
+
"team",
|
|
109
|
+
"invitations",
|
|
110
|
+
"list",
|
|
111
|
+
"--team-id",
|
|
112
|
+
"team-1",
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
expect(team.listInvitations).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(logSpy.mock.calls).toMatchInlineSnapshot(`
|
|
117
|
+
[
|
|
118
|
+
[
|
|
119
|
+
"No invitations found.",
|
|
120
|
+
],
|
|
121
|
+
]
|
|
122
|
+
`);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatTeamList, formatTeamMembers } from "./team.js";
|
|
3
|
+
|
|
4
|
+
describe("formatTeamList", () => {
|
|
5
|
+
it("formats teams as tab-separated rows", () => {
|
|
6
|
+
expect(formatTeamList([
|
|
7
|
+
{ id: "team-1", displayName: "Acme" },
|
|
8
|
+
{ id: "team-2", displayName: "Beta" },
|
|
9
|
+
])).toMatchInlineSnapshot(`"team-1\tAcme\nteam-2\tBeta"`);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns an empty-list sentinel", () => {
|
|
13
|
+
expect(formatTeamList([])).toMatchInlineSnapshot(`"No teams found."`);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("formatTeamMembers", () => {
|
|
18
|
+
it("uses a placeholder for missing member display names", () => {
|
|
19
|
+
expect(formatTeamMembers([{ id: "user-1", displayName: null }])).toMatchInlineSnapshot(`"user-1\t(none)"`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns an empty-list sentinel", () => {
|
|
23
|
+
expect(formatTeamMembers([])).toMatchInlineSnapshot(`"No team members found."`);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { confirm, input } from "@inquirer/prompts";
|
|
2
|
+
import { KnownErrors } from "@hexclave/shared";
|
|
3
|
+
import type { CurrentInternalUser, Team } from "@hexclave/js";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { getInternalUser } from "../lib/app.js";
|
|
6
|
+
import { resolveSessionAuth } from "../lib/auth.js";
|
|
7
|
+
import { CliError } from "../lib/errors.js";
|
|
8
|
+
import { isNonInteractiveEnv } from "../lib/interactive.js";
|
|
9
|
+
import { resolveTeam } from "../lib/resolve-team.js";
|
|
10
|
+
|
|
11
|
+
type TeamOptions = {
|
|
12
|
+
teamId?: string,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type ConfirmationOptions = {
|
|
16
|
+
yes?: boolean,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isJson(program: Command): boolean {
|
|
20
|
+
return Boolean(program.opts<{ json?: boolean }>().json);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function printResult(program: Command, result: unknown, humanOutput: string): void {
|
|
24
|
+
console.log(isJson(program) ? JSON.stringify(result, null, 2) : humanOutput);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function withTeamPermissionError<T>(operation: string, callback: () => Promise<T>): Promise<T> {
|
|
28
|
+
try {
|
|
29
|
+
return await callback();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (KnownErrors.TeamPermissionRequired.isInstance(error)) {
|
|
32
|
+
throw new CliError(`Cannot ${operation}. You do not have the required permission for this team.`);
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function getTeam(user: CurrentInternalUser, options: TeamOptions): Promise<Team> {
|
|
39
|
+
return await resolveTeam(await user.listTeams(), options);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function getUser(): Promise<CurrentInternalUser> {
|
|
43
|
+
return await getInternalUser(resolveSessionAuth());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function confirmDestructive(action: string, options: ConfirmationOptions): Promise<void> {
|
|
47
|
+
if (options.yes) return;
|
|
48
|
+
if (isNonInteractiveEnv()) {
|
|
49
|
+
throw new CliError(`--yes is required to ${action} in non-interactive environments (CI).`);
|
|
50
|
+
}
|
|
51
|
+
const shouldContinue = await confirm({
|
|
52
|
+
message: `Are you sure you want to ${action}?`,
|
|
53
|
+
default: false,
|
|
54
|
+
});
|
|
55
|
+
if (!shouldContinue) {
|
|
56
|
+
throw new CliError("Aborted.");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function printTeams(program: Command, teams: Team[]): void {
|
|
61
|
+
const result = teams.map((team) => ({ id: team.id, displayName: team.displayName }));
|
|
62
|
+
printResult(program, result, formatTeamList(result));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatTeamList(teams: Array<{ id: string, displayName: string }>): string {
|
|
66
|
+
if (teams.length === 0) return "No teams found.";
|
|
67
|
+
return teams.map((team) => `${team.id}\t${team.displayName}`).join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatTeamMembers(members: Array<{ id: string, displayName: string | null }>): string {
|
|
71
|
+
if (members.length === 0) return "No team members found.";
|
|
72
|
+
return members.map((member) => `${member.id}\t${member.displayName ?? "(none)"}`).join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function printTeam(program: Command, team: Team, prefix: string): void {
|
|
76
|
+
const result = { id: team.id, displayName: team.displayName };
|
|
77
|
+
printResult(program, result, `${prefix}: ${result.id} (${result.displayName})`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function registerTeamMembersCommand(team: Command, program: Command): void {
|
|
81
|
+
const members = team
|
|
82
|
+
.command("members")
|
|
83
|
+
.description("Manage team members");
|
|
84
|
+
|
|
85
|
+
members
|
|
86
|
+
.command("list")
|
|
87
|
+
.description("List members of a team")
|
|
88
|
+
.option("--team-id <id>", "Team ID")
|
|
89
|
+
.action(async (options: TeamOptions) => {
|
|
90
|
+
const user = await getUser();
|
|
91
|
+
const selectedTeam = await getTeam(user, options);
|
|
92
|
+
const teamUsers = await withTeamPermissionError("list team members", () => selectedTeam.listUsers());
|
|
93
|
+
const result = teamUsers.map((member) => ({
|
|
94
|
+
id: member.id,
|
|
95
|
+
displayName: member.teamProfile.displayName,
|
|
96
|
+
}));
|
|
97
|
+
printResult(program, result, formatTeamMembers(result));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
members
|
|
101
|
+
.command("remove")
|
|
102
|
+
.description("Remove a member from a team")
|
|
103
|
+
.option("--team-id <id>", "Team ID")
|
|
104
|
+
.requiredOption("--user-id <id>", "User ID to remove")
|
|
105
|
+
.option("-y, --yes", "Skip the confirmation prompt")
|
|
106
|
+
.action(async (options: TeamOptions & { userId: string } & ConfirmationOptions) => {
|
|
107
|
+
const user = await getUser();
|
|
108
|
+
const selectedTeam = await getTeam(user, options);
|
|
109
|
+
await confirmDestructive(`remove user ${options.userId} from team ${selectedTeam.id}`, options);
|
|
110
|
+
await withTeamPermissionError("remove this team member", () => selectedTeam.removeUser(options.userId));
|
|
111
|
+
printResult(program, { teamId: selectedTeam.id, userId: options.userId }, `Removed team member: ${options.userId}`);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function registerTeamInvitationsCommand(team: Command, program: Command): void {
|
|
116
|
+
const invitations = team
|
|
117
|
+
.command("invitations")
|
|
118
|
+
.description("Manage team invitations");
|
|
119
|
+
|
|
120
|
+
invitations
|
|
121
|
+
.command("list")
|
|
122
|
+
.description("List invitations sent by a team")
|
|
123
|
+
.option("--team-id <id>", "Team ID")
|
|
124
|
+
.action(async (options: TeamOptions) => {
|
|
125
|
+
const user = await getUser();
|
|
126
|
+
const selectedTeam = await getTeam(user, options);
|
|
127
|
+
const sentInvitations = await withTeamPermissionError("list team invitations", () => selectedTeam.listInvitations());
|
|
128
|
+
const result = sentInvitations.map((invitation) => ({
|
|
129
|
+
id: invitation.id,
|
|
130
|
+
email: invitation.recipientEmail,
|
|
131
|
+
expiresAt: invitation.expiresAt.toISOString(),
|
|
132
|
+
}));
|
|
133
|
+
const humanOutput = result.length === 0
|
|
134
|
+
? "No invitations found."
|
|
135
|
+
: result.map((invitation) => `${invitation.id}\t${invitation.email ?? "(unknown)"}\t${invitation.expiresAt}`).join("\n");
|
|
136
|
+
printResult(program, result, humanOutput);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
invitations
|
|
140
|
+
.command("revoke")
|
|
141
|
+
.description("Revoke a team invitation")
|
|
142
|
+
.option("--team-id <id>", "Team ID")
|
|
143
|
+
.requiredOption("--invitation-id <id>", "Invitation ID")
|
|
144
|
+
.option("-y, --yes", "Skip the confirmation prompt")
|
|
145
|
+
.action(async (options: TeamOptions & { invitationId: string } & ConfirmationOptions) => {
|
|
146
|
+
const user = await getUser();
|
|
147
|
+
const selectedTeam = await getTeam(user, options);
|
|
148
|
+
await confirmDestructive(`revoke invitation ${options.invitationId}`, options);
|
|
149
|
+
const invitation = (await withTeamPermissionError("list team invitations", () => selectedTeam.listInvitations()))
|
|
150
|
+
.find((candidate) => candidate.id === options.invitationId);
|
|
151
|
+
if (invitation == null) {
|
|
152
|
+
throw new CliError(`Invitation '${options.invitationId}' was not found for team '${selectedTeam.id}'.`);
|
|
153
|
+
}
|
|
154
|
+
await withTeamPermissionError("revoke this invitation", () => invitation.revoke());
|
|
155
|
+
printResult(program, { teamId: selectedTeam.id, invitationId: options.invitationId }, `Invitation revoked: ${options.invitationId}`);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
invitations
|
|
159
|
+
.command("received")
|
|
160
|
+
.description("List invitations received by the current user")
|
|
161
|
+
.action(async () => {
|
|
162
|
+
const user = await getUser();
|
|
163
|
+
const receivedInvitations = await user.listTeamInvitations();
|
|
164
|
+
const result = receivedInvitations.map((invitation) => ({
|
|
165
|
+
id: invitation.id,
|
|
166
|
+
teamId: invitation.teamId,
|
|
167
|
+
teamDisplayName: invitation.teamDisplayName,
|
|
168
|
+
email: invitation.recipientEmail,
|
|
169
|
+
expiresAt: invitation.expiresAt.toISOString(),
|
|
170
|
+
}));
|
|
171
|
+
const humanOutput = result.length === 0
|
|
172
|
+
? "No received invitations found."
|
|
173
|
+
: result.map((invitation) => `${invitation.id}\t${invitation.teamId}\t${invitation.teamDisplayName}\t${invitation.expiresAt}`).join("\n");
|
|
174
|
+
printResult(program, result, humanOutput);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
invitations
|
|
178
|
+
.command("accept")
|
|
179
|
+
.description("Accept an invitation received by the current user")
|
|
180
|
+
.requiredOption("--invitation-id <id>", "Invitation ID")
|
|
181
|
+
.action(async (options: { invitationId: string }) => {
|
|
182
|
+
const user = await getUser();
|
|
183
|
+
const invitation = (await user.listTeamInvitations())
|
|
184
|
+
.find((candidate) => candidate.id === options.invitationId);
|
|
185
|
+
if (invitation == null) {
|
|
186
|
+
throw new CliError(`Received invitation '${options.invitationId}' was not found.`);
|
|
187
|
+
}
|
|
188
|
+
await invitation.accept();
|
|
189
|
+
printResult(program, { invitationId: options.invitationId, teamId: invitation.teamId }, `Invitation accepted for team: ${invitation.teamId}`);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function registerTeamCommand(program: Command) {
|
|
194
|
+
const team = program
|
|
195
|
+
.command("team")
|
|
196
|
+
.description("Manage teams");
|
|
197
|
+
|
|
198
|
+
team
|
|
199
|
+
.command("list")
|
|
200
|
+
.description("List your teams")
|
|
201
|
+
.action(async () => {
|
|
202
|
+
const user = await getUser();
|
|
203
|
+
printTeams(program, await user.listTeams());
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
team
|
|
207
|
+
.command("create")
|
|
208
|
+
.description("Create a team")
|
|
209
|
+
.option("--display-name <name>", "Team display name")
|
|
210
|
+
.action(async (options: { displayName?: string }) => {
|
|
211
|
+
let displayName = options.displayName?.trim();
|
|
212
|
+
if (!displayName) {
|
|
213
|
+
if (isNonInteractiveEnv()) {
|
|
214
|
+
throw new CliError("--display-name is required in non-interactive environments (CI).");
|
|
215
|
+
}
|
|
216
|
+
displayName = (await input({
|
|
217
|
+
message: "Team display name:",
|
|
218
|
+
validate: (value) => value.trim().length > 0 || "Display name cannot be empty.",
|
|
219
|
+
})).trim();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const user = await getUser();
|
|
223
|
+
const newTeam = await user.createTeam({ displayName });
|
|
224
|
+
printTeam(program, newTeam, "Team created");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
registerTeamMembersCommand(team, program);
|
|
228
|
+
|
|
229
|
+
team
|
|
230
|
+
.command("invite")
|
|
231
|
+
.description("Invite a user to a team")
|
|
232
|
+
.option("--team-id <id>", "Team ID")
|
|
233
|
+
.option("--email <email>", "Email address to invite")
|
|
234
|
+
.action(async (options: TeamOptions & { email?: string }) => {
|
|
235
|
+
const user = await getUser();
|
|
236
|
+
const selectedTeam = await getTeam(user, options);
|
|
237
|
+
let email = options.email?.trim();
|
|
238
|
+
if (!email) {
|
|
239
|
+
if (isNonInteractiveEnv()) {
|
|
240
|
+
throw new CliError("--email is required in non-interactive environments (CI).");
|
|
241
|
+
}
|
|
242
|
+
email = (await input({
|
|
243
|
+
message: "Email address:",
|
|
244
|
+
validate: (value) => value.trim().length > 0 || "Email address cannot be empty.",
|
|
245
|
+
})).trim();
|
|
246
|
+
}
|
|
247
|
+
await withTeamPermissionError("invite this user", () => selectedTeam.inviteUser({ email }));
|
|
248
|
+
printResult(program, { teamId: selectedTeam.id, email }, `Invitation sent to ${email}.`);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
registerTeamInvitationsCommand(team, program);
|
|
252
|
+
|
|
253
|
+
team
|
|
254
|
+
.command("leave")
|
|
255
|
+
.description("Leave a team")
|
|
256
|
+
.option("--team-id <id>", "Team ID")
|
|
257
|
+
.option("-y, --yes", "Skip the confirmation prompt")
|
|
258
|
+
.action(async (options: TeamOptions & ConfirmationOptions) => {
|
|
259
|
+
const user = await getUser();
|
|
260
|
+
const selectedTeam = await getTeam(user, options);
|
|
261
|
+
await confirmDestructive(`leave team ${selectedTeam.id}`, options);
|
|
262
|
+
await user.leaveTeam(selectedTeam);
|
|
263
|
+
printResult(program, { teamId: selectedTeam.id }, `Left team: ${selectedTeam.id}`);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
team
|
|
267
|
+
.command("update")
|
|
268
|
+
.description("Update a team")
|
|
269
|
+
.option("--team-id <id>", "Team ID")
|
|
270
|
+
.option("--display-name <name>", "New team display name")
|
|
271
|
+
.action(async (options: TeamOptions & { displayName?: string }) => {
|
|
272
|
+
const user = await getUser();
|
|
273
|
+
const selectedTeam = await getTeam(user, options);
|
|
274
|
+
let displayName = options.displayName?.trim();
|
|
275
|
+
if (!displayName) {
|
|
276
|
+
if (isNonInteractiveEnv()) {
|
|
277
|
+
throw new CliError("--display-name is required in non-interactive environments (CI).");
|
|
278
|
+
}
|
|
279
|
+
displayName = (await input({
|
|
280
|
+
message: "New team display name:",
|
|
281
|
+
default: selectedTeam.displayName,
|
|
282
|
+
validate: (value) => value.trim().length > 0 || "Display name cannot be empty.",
|
|
283
|
+
})).trim();
|
|
284
|
+
}
|
|
285
|
+
await withTeamPermissionError("update this team", () => selectedTeam.update({ displayName }));
|
|
286
|
+
printResult(program, { id: selectedTeam.id, displayName }, `Team updated: ${selectedTeam.id} (${displayName})`);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
team
|
|
290
|
+
.command("delete")
|
|
291
|
+
.description("Delete a team")
|
|
292
|
+
.option("--team-id <id>", "Team ID")
|
|
293
|
+
.option("-y, --yes", "Skip the confirmation prompt")
|
|
294
|
+
.action(async (options: TeamOptions & ConfirmationOptions) => {
|
|
295
|
+
const user = await getUser();
|
|
296
|
+
const selectedTeam = await getTeam(user, options);
|
|
297
|
+
await confirmDestructive(`delete team ${selectedTeam.id}`, options);
|
|
298
|
+
await withTeamPermissionError("delete this team", () => selectedTeam.delete());
|
|
299
|
+
printResult(program, { teamId: selectedTeam.id }, `Team deleted: ${selectedTeam.id}`);
|
|
300
|
+
});
|
|
301
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { registerDevCommand } from "./commands/dev.js";
|
|
|
17
17
|
import { registerFixCommand } from "./commands/fix.js";
|
|
18
18
|
import { registerDoctorCommand } from "./commands/doctor.js";
|
|
19
19
|
import { registerWhoamiCommand } from "./commands/whoami.js";
|
|
20
|
+
import { registerTeamCommand } from "./commands/team.js";
|
|
20
21
|
|
|
21
22
|
const program = new Command();
|
|
22
23
|
|
|
@@ -35,6 +36,7 @@ registerInitCommand(program);
|
|
|
35
36
|
registerProjectCommand(program);
|
|
36
37
|
registerDevCommand(program);
|
|
37
38
|
registerWhoamiCommand(program);
|
|
39
|
+
registerTeamCommand(program);
|
|
38
40
|
registerFixCommand(program);
|
|
39
41
|
registerDoctorCommand(program);
|
|
40
42
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { input } from "@inquirer/prompts";
|
|
2
2
|
import type { CurrentInternalUser } from "@hexclave/js";
|
|
3
|
-
import { DEFAULT_DASHBOARD_URL } from "./auth.js";
|
|
4
3
|
import { CliError } from "./errors.js";
|
|
5
4
|
import { isNonInteractiveEnv } from "./interactive.js";
|
|
6
5
|
import { withProgress } from "./progress.js";
|
|
6
|
+
import { resolveTeam } from "./resolve-team.js";
|
|
7
7
|
|
|
8
8
|
type CreateProjectOptions = {
|
|
9
9
|
displayName?: string,
|
|
10
|
+
teamId?: string,
|
|
10
11
|
defaultDisplayName?: string,
|
|
11
|
-
dashboardUrl?: string,
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
export async function createProjectInteractively(
|
|
@@ -27,16 +27,14 @@ export async function createProjectInteractively(
|
|
|
27
27
|
})).trim();
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
throw new CliError(`No teams found on your account. Create a team at ${dashboardUrl} first.`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return await user.createProject({
|
|
38
|
-
displayName,
|
|
39
|
-
teamId: teams[0].id,
|
|
40
|
-
});
|
|
30
|
+
const teams = await user.listTeams();
|
|
31
|
+
const team = await resolveTeam(teams, {
|
|
32
|
+
teamId: opts.teamId,
|
|
33
|
+
createTeam: async (teamDisplayName) => await user.createTeam({ displayName: teamDisplayName }),
|
|
41
34
|
});
|
|
35
|
+
|
|
36
|
+
return await withProgress("Creating project", async () => await user.createProject({
|
|
37
|
+
displayName,
|
|
38
|
+
teamId: team.id,
|
|
39
|
+
}));
|
|
42
40
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
import { resolveTeam } from "./resolve-team.js";
|
|
4
|
+
|
|
5
|
+
function makeTeam(id: string, displayName: string) {
|
|
6
|
+
return { id, displayName };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const savedCi = process.env.CI;
|
|
10
|
+
const savedGithubActions = process.env.GITHUB_ACTIONS;
|
|
11
|
+
const savedNoninteractive = process.env.NONINTERACTIVE;
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (savedCi === undefined) delete process.env.CI;
|
|
15
|
+
else process.env.CI = savedCi;
|
|
16
|
+
if (savedGithubActions === undefined) delete process.env.GITHUB_ACTIONS;
|
|
17
|
+
else process.env.GITHUB_ACTIONS = savedGithubActions;
|
|
18
|
+
if (savedNoninteractive === undefined) delete process.env.NONINTERACTIVE;
|
|
19
|
+
else process.env.NONINTERACTIVE = savedNoninteractive;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("resolveTeam", () => {
|
|
23
|
+
it("returns the explicitly requested team id", async () => {
|
|
24
|
+
const teams = [makeTeam("team-1", "Acme"), makeTeam("team-2", "Beta")];
|
|
25
|
+
|
|
26
|
+
await expect(resolveTeam(teams, { teamId: "team-2" })).resolves.toBe(teams[1]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("throws when an explicit team id does not exist", async () => {
|
|
30
|
+
const teams = [makeTeam("team-1", "Acme")];
|
|
31
|
+
|
|
32
|
+
await expect(resolveTeam(teams, { teamId: "missing-team" })).rejects.toSatisfy((error: unknown) => (
|
|
33
|
+
error instanceof CliError && error.message === "Team 'missing-team' was not found on your account."
|
|
34
|
+
));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("auto-picks the only team without prompting", async () => {
|
|
38
|
+
const team = makeTeam("team-1", "Acme");
|
|
39
|
+
|
|
40
|
+
await expect(resolveTeam([team])).resolves.toBe(team);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects multi-team resolution in non-interactive environments", async () => {
|
|
44
|
+
process.env.CI = "1";
|
|
45
|
+
|
|
46
|
+
await expect(resolveTeam([makeTeam("team-1", "Acme"), makeTeam("team-2", "Beta")])).rejects.toSatisfy((error: unknown) => (
|
|
47
|
+
error instanceof CliError && error.message === "--team-id is required in non-interactive environments (CI)."
|
|
48
|
+
));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects zero-team resolution in non-interactive environments", async () => {
|
|
52
|
+
process.env.CI = "1";
|
|
53
|
+
|
|
54
|
+
await expect(resolveTeam([], { createTeam: async () => makeTeam("team-1", "Acme") })).rejects.toSatisfy((error: unknown) => (
|
|
55
|
+
error instanceof CliError && error.message === "No teams found. Run `hexclave team create` first, or pass --team-id."
|
|
56
|
+
));
|
|
57
|
+
});
|
|
58
|
+
});
|