Skip to content

Commit 68ee6be

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(cli): add orgs list and activate the Free plan in projects create
Mono-RevId: c4c0a14ad8d9eb58850c8e3a69d05ccb5149f6fd
1 parent e3f7812 commit 68ee6be

7 files changed

Lines changed: 172 additions & 14 deletions

File tree

‎.changeset/cli-orgs-list.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
Add `trigger.dev orgs list` to show the organizations you belong to, with their slugs and IDs. Use the slug with `trigger.dev projects create --org`, including for organizations that don't have any projects yet. `trigger.dev projects create` now activates the Free plan for organizations that don't have a plan yet, matching `trigger.dev init`.

‎packages/cli-v3/src/commands/init.ts‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
writeConfigHasSeenMCPInstallPrompt,
4141
} from "../utilities/configFiles.js";
4242
import { installMcpServer } from "./install-mcp.js";
43+
import { createProjectWithFreePlanFallback } from "./projects/common.js";
4344
import { installSkillsFromInit, markSkillsPromptSeen } from "./skills.js";
4445

4546
const cliVersion = VERSION as string;
@@ -937,18 +938,7 @@ async function createFirstProject(apiClient: CliApiClient, options: SelectProjec
937938
"--project-name is required to create a project when using --yes",
938939
{ label: "Project names", minLength: 1, maxLength: 255 }
939940
));
940-
let projectResponse = await apiClient.createProject(orgParam, { name: projectName });
941-
942-
if (!projectResponse.success && projectResponse.statusCode === 402) {
943-
const planResponse = await apiClient.activateFreePlan(orgParam);
944-
945-
if (!planResponse.success) {
946-
throw new Error(`Failed to activate the Free plan: ${planResponse.error}`);
947-
}
948-
949-
log.success("Activated the Free plan");
950-
projectResponse = await apiClient.createProject(orgParam, { name: projectName });
951-
}
941+
const projectResponse = await createProjectWithFreePlanFallback(apiClient, orgParam, projectName);
952942

953943
if (!projectResponse.success) {
954944
throw new Error(`Failed to create project: ${projectResponse.error}`);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { Command } from "commander";
22
import { configureOrgsCreateCommand } from "./create.js";
3+
import { configureOrgsListCommand } from "./list.js";
34

45
export function configureOrgsCommand(program: Command) {
56
const orgs = program.command("orgs").description("Manage Trigger.dev organizations");
67

8+
configureOrgsListCommand(orgs);
79
configureOrgsCreateCommand(orgs);
810
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { intro, outro } from "@clack/prompts";
2+
import type { Command } from "commander";
3+
import {
4+
CommonCommandOptions,
5+
commonOptions,
6+
handleTelemetry,
7+
wrapCommandAction,
8+
} from "../../cli/common.js";
9+
import { printStandloneInitialBanner } from "../../utilities/initialBanner.js";
10+
import { logger } from "../../utilities/logger.js";
11+
import { getPatApiClient } from "../projects/common.js";
12+
13+
export function configureOrgsListCommand(program: Command) {
14+
return commonOptions(
15+
program
16+
.command("list")
17+
.description("List the Trigger.dev organizations you belong to")
18+
.action(async (options) => {
19+
await handleTelemetry(async () => {
20+
await printStandloneInitialBanner(true, options.profile);
21+
await orgsListCommand(options);
22+
});
23+
})
24+
);
25+
}
26+
27+
async function orgsListCommand(options: unknown) {
28+
return await wrapCommandAction(
29+
"orgsListCommand",
30+
CommonCommandOptions,
31+
options,
32+
async (opts) => await listOrgs(opts)
33+
);
34+
}
35+
36+
async function listOrgs(options: CommonCommandOptions) {
37+
intro("Listing organizations");
38+
39+
const apiClient = await getPatApiClient(options);
40+
const response = await apiClient.getOrgs();
41+
42+
if (!response.success) {
43+
throw new Error(`Failed to list organizations: ${response.error}`);
44+
}
45+
46+
if (response.data.length === 0) {
47+
outro("No organizations found. Create one with `trigger.dev orgs create`.");
48+
return;
49+
}
50+
51+
logger.table(
52+
response.data.map((org) => ({
53+
name: org.title,
54+
slug: org.slug,
55+
id: org.id,
56+
created: org.createdAt.toLocaleString(),
57+
}))
58+
);
59+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { CliApiClient } from "../../apiClient.js";
3+
import { createProjectWithFreePlanFallback } from "./common.js";
4+
5+
const project = { name: "My project", externalRef: "proj_123" };
6+
const planRequired = {
7+
success: false,
8+
statusCode: 402,
9+
error: "You must select a plan for this organization before creating projects.",
10+
};
11+
12+
function fakeClient(
13+
createResponses: unknown[],
14+
activateResponse: unknown = { success: true, data: { plan: "free" } }
15+
) {
16+
const createProject = vi.fn();
17+
for (const response of createResponses) createProject.mockResolvedValueOnce(response);
18+
const activateFreePlan = vi.fn().mockResolvedValue(activateResponse);
19+
return {
20+
client: { createProject, activateFreePlan } as unknown as CliApiClient,
21+
createProject,
22+
activateFreePlan,
23+
};
24+
}
25+
26+
describe("createProjectWithFreePlanFallback", () => {
27+
it("does not activate a plan when the project is created", async () => {
28+
const { client, activateFreePlan } = fakeClient([{ success: true, data: project }]);
29+
30+
const response = await createProjectWithFreePlanFallback(client, "acme", "My project");
31+
32+
expect(response.success).toBe(true);
33+
expect(activateFreePlan).not.toHaveBeenCalled();
34+
});
35+
36+
it("activates the Free plan and retries when a plan is required", async () => {
37+
const { client, createProject } = fakeClient([planRequired, { success: true, data: project }]);
38+
39+
const response = await createProjectWithFreePlanFallback(client, "acme", "My project");
40+
41+
expect(response.success).toBe(true);
42+
expect(createProject).toHaveBeenCalledTimes(2);
43+
});
44+
45+
it("retries when another command already activated the organization", async () => {
46+
const { client } = fakeClient([planRequired, { success: true, data: project }], {
47+
success: false,
48+
statusCode: 409,
49+
error: "Organization is already activated",
50+
});
51+
52+
const response = await createProjectWithFreePlanFallback(client, "acme", "My project");
53+
54+
expect(response.success).toBe(true);
55+
});
56+
57+
it("returns the plan error when activation is unavailable", async () => {
58+
const { client, createProject } = fakeClient([planRequired], {
59+
success: false,
60+
statusCode: 404,
61+
error: "Not found",
62+
});
63+
64+
const response = await createProjectWithFreePlanFallback(client, "acme", "My project");
65+
66+
expect(response).toEqual(planRequired);
67+
expect(createProject).toHaveBeenCalledTimes(1);
68+
});
69+
});

‎packages/cli-v3/src/commands/projects/common.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { log } from "@clack/prompts";
12
import { CliApiClient } from "../../apiClient.js";
23
import type { CommonCommandOptions } from "../../cli/common.js";
4+
import { logger } from "../../utilities/logger.js";
35
import { login } from "../login.js";
46

57
export async function getPatApiClient(options: CommonCommandOptions) {
@@ -18,3 +20,34 @@ export async function getPatApiClient(options: CommonCommandOptions) {
1820

1921
return new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken);
2022
}
23+
24+
const PLAN_REQUIRED_STATUS = 402;
25+
const ALREADY_ACTIVATED_STATUS = 409;
26+
27+
/**
28+
* Creates a project, activating the Free plan first if the organization has no plan yet.
29+
* If activation isn't possible (self-hosted, no billing permission), the original
30+
* "select a plan" error is returned so the user knows what to do.
31+
*/
32+
export async function createProjectWithFreePlanFallback(
33+
apiClient: CliApiClient,
34+
orgParam: string,
35+
name: string
36+
) {
37+
const response = await apiClient.createProject(orgParam, { name });
38+
39+
if (response.success || response.statusCode !== PLAN_REQUIRED_STATUS) {
40+
return response;
41+
}
42+
43+
const planResponse = await apiClient.activateFreePlan(orgParam);
44+
45+
if (planResponse.success) {
46+
log.success("Activated the Free plan");
47+
} else if (planResponse.statusCode !== ALREADY_ACTIVATED_STATUS) {
48+
logger.debug("Failed to activate the Free plan", { error: planResponse.error });
49+
return response;
50+
}
51+
52+
return await apiClient.createProject(orgParam, { name });
53+
}

‎packages/cli-v3/src/commands/projects/create.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
wrapCommandAction,
1111
} from "../../cli/common.js";
1212
import { printStandloneInitialBanner } from "../../utilities/initialBanner.js";
13-
import { getPatApiClient } from "./common.js";
13+
import { createProjectWithFreePlanFallback, getPatApiClient } from "./common.js";
1414

1515
const ProjectsCreateCommandOptions = CommonCommandOptions.extend({
1616
org: z.string().optional(),
@@ -52,7 +52,7 @@ async function createProject(options: ProjectsCreateCommandOptions) {
5252
const org = options.org ?? (await promptForOrg(apiClient));
5353
const name = options.name ?? (await promptForName());
5454

55-
const response = await apiClient.createProject(org, { name });
55+
const response = await createProjectWithFreePlanFallback(apiClient, org, name);
5656

5757
if (!response.success) {
5858
throw new Error(`Failed to create project: ${response.error}`);

0 commit comments

Comments
 (0)