diff --git a/.changeset/lucky-runs-projects.md b/.changeset/lucky-runs-projects.md
new file mode 100644
index 00000000000..208c31b86e5
--- /dev/null
+++ b/.changeset/lucky-runs-projects.md
@@ -0,0 +1,5 @@
+---
+"trigger.dev": patch
+---
+
+New CLI commands: `projects create/get/rename/delete`, `runs list/get/replay/cancel`, and `env set`
diff --git a/docs/cli-env-commands.mdx b/docs/cli-env-commands.mdx
new file mode 100644
index 00000000000..ca56b31c690
--- /dev/null
+++ b/docs/cli-env-commands.mdx
@@ -0,0 +1,63 @@
+---
+title: "CLI env set command"
+sidebarTitle: "env set"
+description: "Use the env set command to create or update an environment variable."
+---
+
+import CommonOptions from "/snippets/cli-options-common.mdx";
+import ConfigFileOptions from "/snippets/cli-options-config-file.mdx";
+import ProjectRefOptions from "/snippets/cli-options-project-ref.mdx";
+import BranchOptions from "/snippets/cli-options-branch.mdx";
+
+Sets an environment variable on your project, creating it if it doesn't exist and overwriting the
+value if it does.
+
+
+
+```bash npm
+npx trigger.dev@latest env set MY_VAR my-value
+```
+
+```bash pnpm
+pnpm dlx trigger.dev@latest env set MY_VAR my-value
+```
+
+```bash yarn
+yarn dlx trigger.dev@latest env set MY_VAR my-value
+```
+
+
+
+## Arguments
+
+```
+npx trigger.dev@latest env set
+```
+
+
+ The name of the environment variable.
+
+
+
+ The value to set.
+
+
+## Options
+
+
+ Store the value as a secret, so it can't be read back.
+
+
+
+ The environment to set the variable in: `prod`, `staging` or `preview`. Defaults to `prod`.
+
+
+
+
+
+
+### Common options
+
+These options are available on most commands.
+
+
diff --git a/docs/cli-projects-commands.mdx b/docs/cli-projects-commands.mdx
new file mode 100644
index 00000000000..a2acd85c548
--- /dev/null
+++ b/docs/cli-projects-commands.mdx
@@ -0,0 +1,74 @@
+---
+title: "CLI projects commands"
+sidebarTitle: "projects"
+description: "Use these commands to create, inspect, rename and delete your Trigger.dev projects."
+---
+
+import CommonOptions from "/snippets/cli-options-common.mdx";
+
+These commands act on the organizations and projects your account can access, so run
+[`login`](/cli-login-commands) first.
+
+## projects create
+
+Creates a project. You are prompted for the organization and name when the options are omitted.
+
+
+
+```bash npm
+npx trigger.dev@latest projects create
+```
+
+```bash pnpm
+pnpm dlx trigger.dev@latest projects create
+```
+
+```bash yarn
+yarn dlx trigger.dev@latest projects create
+```
+
+
+
+
+ The organization slug or ID to create the project in.
+
+
+
+ The name of the new project.
+
+
+## projects get
+
+Prints the name, ref, slug, organization, default runtime and region of a project.
+
+```bash
+npx trigger.dev@latest projects get proj_abc123
+```
+
+## projects rename
+
+Renames a project. The project ref does not change.
+
+```bash
+npx trigger.dev@latest projects rename proj_abc123 "My new name"
+```
+
+## projects delete
+
+Deletes a project and everything in it. You are asked to confirm first.
+
+```bash
+npx trigger.dev@latest projects delete proj_abc123
+```
+
+
+ Delete without the confirmation prompt. Required in non-interactive environments such as CI.
+
+
+## Options
+
+### Common options
+
+These options are available on most commands.
+
+
diff --git a/docs/cli-runs-commands.mdx b/docs/cli-runs-commands.mdx
new file mode 100644
index 00000000000..b4c8d791527
--- /dev/null
+++ b/docs/cli-runs-commands.mdx
@@ -0,0 +1,90 @@
+---
+title: "CLI runs commands"
+sidebarTitle: "runs"
+description: "Use these commands to list, inspect, replay and cancel runs from your terminal."
+---
+
+import CommonOptions from "/snippets/cli-options-common.mdx";
+import ConfigFileOptions from "/snippets/cli-options-config-file.mdx";
+import ProjectRefOptions from "/snippets/cli-options-project-ref.mdx";
+import BranchOptions from "/snippets/cli-options-branch.mdx";
+
+## runs list
+
+Lists runs, newest first, in a table of ID, task, status, version, created time and duration.
+
+
+
+```bash npm
+npx trigger.dev@latest runs list
+```
+
+```bash pnpm
+pnpm dlx trigger.dev@latest runs list
+```
+
+```bash yarn
+yarn dlx trigger.dev@latest runs list
+```
+
+
+
+
+ The number of runs to list, up to 100. Defaults to 20.
+
+
+
+ Only show runs with this status, e.g. `FAILED`.
+
+
+
+ Only show runs for this task identifier.
+
+
+
+ Only show runs with this tag.
+
+
+
+ The pagination cursor printed at the end of the previous page.
+
+
+## runs get
+
+Prints the status, version, tags, timings, duration, cost and error of a single run.
+
+```bash
+npx trigger.dev@latest runs get run_abc123
+```
+
+## runs replay
+
+Triggers a new run with the same payload as an existing one, using the latest version of the task.
+
+```bash
+npx trigger.dev@latest runs replay run_abc123
+```
+
+## runs cancel
+
+Cancels a run that has not finished yet.
+
+```bash
+npx trigger.dev@latest runs cancel run_abc123
+```
+
+## Options
+
+
+ The environment to use: `dev`, `prod`, `staging` or `preview`. Defaults to `prod`.
+
+
+
+
+
+
+### Common options
+
+These options are available on most commands.
+
+
diff --git a/docs/docs.json b/docs/docs.json
index 17eed721e09..13a867be930 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -245,12 +245,15 @@
"pages": [
"cli-deploy-commands",
"cli-dev-commands",
+ "cli-env-commands",
"cli-init-commands",
"cli-list-profiles-commands",
"cli-login-commands",
"cli-logout-commands",
"cli-preview-archive",
+ "cli-projects-commands",
"cli-promote-commands",
+ "cli-runs-commands",
"cli-switch",
"cli-update-commands",
"cli-whoami-commands"
diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts
index f30395c3659..32d7c443001 100644
--- a/packages/cli-v3/src/apiClient.ts
+++ b/packages/cli-v3/src/apiClient.ts
@@ -110,6 +110,15 @@ const MarkProjectInitializedResponseBody = z.object({
initializedAt: z.string().nullable(),
});
+const RenameProjectResponseBody = z.object({
+ id: z.string(),
+ name: z.string(),
+});
+
+const DeleteProjectResponseBody = z.object({
+ id: z.string(),
+});
+
export class CliApiClient {
private engineURL: string;
private source: "cli" | "mcp";
@@ -258,6 +267,29 @@ export class CliApiClient {
});
}
+ async renameProject(projectRef: string, body: { name: string }) {
+ if (!this.accessToken) {
+ throw new Error("renameProject: No access token");
+ }
+
+ return wrapZodFetch(RenameProjectResponseBody, `${this.apiURL}/api/v1/projects/${projectRef}`, {
+ method: "PATCH",
+ headers: this.getHeaders(),
+ body: JSON.stringify(body),
+ });
+ }
+
+ async deleteProject(projectRef: string) {
+ if (!this.accessToken) {
+ throw new Error("deleteProject: No access token");
+ }
+
+ return wrapZodFetch(DeleteProjectResponseBody, `${this.apiURL}/api/v1/projects/${projectRef}`, {
+ method: "DELETE",
+ headers: this.getHeaders(),
+ });
+ }
+
async getWorkerByTag(projectRef: string, envName: string, tagName: string = "current") {
if (!this.accessToken) {
throw new Error("getWorkerByTag: No access token");
diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts
index 5dec4214ba2..77039b38b0d 100644
--- a/packages/cli-v3/src/cli/index.ts
+++ b/packages/cli-v3/src/cli/index.ts
@@ -10,6 +10,7 @@ import { configureLogoutCommand } from "../commands/logout.js";
import { configurePreviewCommand } from "../commands/preview.js";
import { configureProjectsCommand } from "../commands/projects/index.js";
import { configurePromoteCommand } from "../commands/promote.js";
+import { configureRunsCommand } from "../commands/runs/index.js";
import { configureSwitchProfilesCommand } from "../commands/switch.js";
import { configureUpdateCommand } from "../commands/update.js";
import { configureWhoamiCommand } from "../commands/whoami.js";
@@ -43,6 +44,7 @@ configureSwitchProfilesCommand(program);
configureUpdateCommand(program);
configurePreviewCommand(program);
configureProjectsCommand(program);
+configureRunsCommand(program);
configureAnalyzeCommand(program);
configureMcpCommand(program);
configureReportCommand(program);
diff --git a/packages/cli-v3/src/commands/env.ts b/packages/cli-v3/src/commands/env.ts
index 96c9b936ed3..d49535028d5 100644
--- a/packages/cli-v3/src/commands/env.ts
+++ b/packages/cli-v3/src/commands/env.ts
@@ -36,6 +36,16 @@ const EnvGetOptions = CommonCommandOptions.extend({
branch: z.string().optional(),
});
+const EnvSetOptions = CommonCommandOptions.extend({
+ config: z.string().optional(),
+ projectRef: z.string().optional(),
+ name: z.string(),
+ value: z.string(),
+ secret: z.boolean().default(false),
+ env: z.enum(["prod", "staging", "preview", "production"]).default("prod"),
+ branch: z.string().optional(),
+});
+
const EnvPullOptions = CommonCommandOptions.extend({
config: z.string().optional(),
projectRef: z.string().optional(),
@@ -125,6 +135,29 @@ export function configureEnvCommand(program: Command) {
});
});
+ commonOptions(
+ envCommand
+ .command("set ")
+ .description("Set the value of an environment variable, creating or updating it")
+ .option("-c, --config ", "The name of the config file")
+ .option(
+ "-p, --project-ref ",
+ "The project ref. Required if there is no config file"
+ )
+ .option(
+ "-e, --env ",
+ "The environment to set the variable in (prod, staging, preview)",
+ "prod"
+ )
+ .option("-b, --branch ", "The preview branch when using --env preview")
+ .option("--secret", "Store the value as a secret, so it can't be read back")
+ ).action(async (name, value, options) => {
+ await handleTelemetry(async () => {
+ await printInitialBanner(false, options.profile);
+ await envSetCommand({ ...options, name, value });
+ });
+ });
+
return envCommand;
}
@@ -150,6 +183,17 @@ async function envGetCommand(options: unknown) {
);
}
+async function envSetCommand(options: unknown) {
+ return await wrapCommandAction(
+ "envSet",
+ EnvSetOptions,
+ options,
+ async (opts: z.infer) => {
+ return await _envSetCommand(opts);
+ }
+ );
+}
+
async function envPullCommand(options: unknown) {
return await wrapCommandAction(
"envPull",
@@ -166,6 +210,7 @@ async function resolveProjectEnv(
| z.infer
| z.infer
| z.infer
+ | z.infer
) {
const authorization = await login({
embedded: true,
@@ -353,6 +398,36 @@ async function _envGetCommand(options: z.infer) {
outro(`Project: ${projectRef} | Environment: ${envInfo}`);
}
+async function _envSetCommand(options: z.infer) {
+ intro(`Setting environment variable: ${options.name}`);
+
+ const $spinner = spinner();
+
+ const { projectClient, projectRef, env, branch } = await resolveProjectEnv(options);
+
+ $spinner.start(`Setting ${options.name}`);
+
+ // The import endpoint with `override: true` is the only upsert we have — the
+ // create/update endpoints require knowing whether the variable already exists.
+ const result = await projectClient.client.importEnvVars(projectRef, env, {
+ variables: { [options.name]: options.value },
+ override: true,
+ isSecret: options.secret,
+ });
+
+ if (!result.success) {
+ $spinner.stop(`Failed to set ${options.name}`);
+ throw new Error(`Failed to set environment variable: ${result.error}`);
+ }
+
+ $spinner.stop(`Set ${options.name}`);
+
+ log.success(chalk.green(`${options.name} is set${options.secret ? " as a secret" : ""}`));
+
+ const envInfo = branch ? `${env} (${branch})` : env;
+ outro(`Project: ${projectRef} | Environment: ${envInfo}`);
+}
+
async function _envPullCommand(options: z.infer) {
intro("Pull Environment Variables");
const $spinner = spinner();
diff --git a/packages/cli-v3/src/commands/projects/common.ts b/packages/cli-v3/src/commands/projects/common.ts
new file mode 100644
index 00000000000..f3ad5755628
--- /dev/null
+++ b/packages/cli-v3/src/commands/projects/common.ts
@@ -0,0 +1,20 @@
+import { CliApiClient } from "../../apiClient.js";
+import type { CommonCommandOptions } from "../../cli/common.js";
+import { login } from "../login.js";
+
+export async function getPatApiClient(options: CommonCommandOptions) {
+ const authorization = await login({
+ embedded: true,
+ defaultApiUrl: options.apiUrl,
+ profile: options.profile,
+ silent: true,
+ });
+
+ if (!authorization.ok) {
+ throw new Error(
+ `You must login first. Use the \`login\` CLI command.\n\n${authorization.error}`
+ );
+ }
+
+ return new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken);
+}
diff --git a/packages/cli-v3/src/commands/projects/create.ts b/packages/cli-v3/src/commands/projects/create.ts
new file mode 100644
index 00000000000..59a9c4a5184
--- /dev/null
+++ b/packages/cli-v3/src/commands/projects/create.ts
@@ -0,0 +1,104 @@
+import { intro, isCancel, outro, select, text } from "@clack/prompts";
+import type { Command } from "commander";
+import { z } from "zod";
+import type { CliApiClient } from "../../apiClient.js";
+import {
+ CommonCommandOptions,
+ commonOptions,
+ handleTelemetry,
+ OutroCommandError,
+ wrapCommandAction,
+} from "../../cli/common.js";
+import { printStandloneInitialBanner } from "../../utilities/initialBanner.js";
+import { getPatApiClient } from "./common.js";
+
+const ProjectsCreateCommandOptions = CommonCommandOptions.extend({
+ org: z.string().optional(),
+ name: z.string().optional(),
+});
+
+type ProjectsCreateCommandOptions = z.infer;
+
+export function configureProjectsCreateCommand(program: Command) {
+ return commonOptions(
+ program
+ .command("create")
+ .description("Create a new Trigger.dev project")
+ .option("-o, --org ", "The organization slug or ID to create the project in")
+ .option("-n, --name ", "The name of the new project")
+ .action(async (options) => {
+ await handleTelemetry(async () => {
+ await printStandloneInitialBanner(true, options.profile);
+ await projectsCreateCommand(options);
+ });
+ })
+ );
+}
+
+async function projectsCreateCommand(options: unknown) {
+ return await wrapCommandAction(
+ "projectsCreateCommand",
+ ProjectsCreateCommandOptions,
+ options,
+ async (opts) => await createProject(opts)
+ );
+}
+
+async function createProject(options: ProjectsCreateCommandOptions) {
+ intro("Creating a new project");
+
+ const apiClient = await getPatApiClient(options);
+
+ const org = options.org ?? (await promptForOrg(apiClient));
+ const name = options.name ?? (await promptForName());
+
+ const response = await apiClient.createProject(org, { name });
+
+ if (!response.success) {
+ throw new Error(`Failed to create project: ${response.error}`);
+ }
+
+ outro(`Created project ${response.data.name} (${response.data.externalRef})`);
+}
+
+async function promptForOrg(apiClient: CliApiClient) {
+ const orgs = await apiClient.getOrgs();
+
+ if (!orgs.success) {
+ throw new Error(`Failed to list organizations: ${orgs.error}`);
+ }
+
+ if (orgs.data.length === 0) {
+ throw new Error(
+ "You don't belong to any organizations yet. Create one in the dashboard first."
+ );
+ }
+
+ if (orgs.data.length === 1) {
+ return orgs.data[0]!.slug;
+ }
+
+ const selected = await select({
+ message: "Select an organization",
+ options: orgs.data.map((org) => ({ value: org.slug, label: org.title })),
+ });
+
+ if (isCancel(selected)) {
+ throw new OutroCommandError();
+ }
+
+ return selected;
+}
+
+async function promptForName() {
+ const name = await text({
+ message: "What should the project be called?",
+ validate: (value) => (value.trim().length === 0 ? "Please enter a name" : undefined),
+ });
+
+ if (isCancel(name)) {
+ throw new OutroCommandError();
+ }
+
+ return name;
+}
diff --git a/packages/cli-v3/src/commands/projects/delete.ts b/packages/cli-v3/src/commands/projects/delete.ts
new file mode 100644
index 00000000000..e6d1d47c5bf
--- /dev/null
+++ b/packages/cli-v3/src/commands/projects/delete.ts
@@ -0,0 +1,81 @@
+import { confirm, intro, isCancel, outro } from "@clack/prompts";
+import type { Command } from "commander";
+import { z } from "zod";
+import {
+ CommonCommandOptions,
+ commonOptions,
+ handleTelemetry,
+ OutroCommandError,
+ wrapCommandAction,
+} from "../../cli/common.js";
+import { printStandloneInitialBanner } from "../../utilities/initialBanner.js";
+import { getPatApiClient } from "./common.js";
+
+const ProjectsDeleteCommandOptions = CommonCommandOptions.extend({
+ projectRef: z.string(),
+ yes: z.boolean().default(false),
+});
+
+type ProjectsDeleteCommandOptions = z.infer;
+
+export function configureProjectsDeleteCommand(program: Command) {
+ return commonOptions(
+ program
+ .command("delete")
+ .description("Delete a Trigger.dev project")
+ .argument("", "The project ref, starting with proj_")
+ .option("-y, --yes", "Skip the confirmation prompt")
+ .action(async (projectRef, options) => {
+ await handleTelemetry(async () => {
+ await printStandloneInitialBanner(true, options.profile);
+ await projectsDeleteCommand({ ...options, projectRef });
+ });
+ })
+ );
+}
+
+async function projectsDeleteCommand(options: unknown) {
+ return await wrapCommandAction(
+ "projectsDeleteCommand",
+ ProjectsDeleteCommandOptions,
+ options,
+ async (opts) => await deleteProject(opts)
+ );
+}
+
+async function deleteProject(options: ProjectsDeleteCommandOptions) {
+ intro(`Deleting project ${options.projectRef}`);
+
+ const apiClient = await getPatApiClient(options);
+
+ if (!options.yes) {
+ if (!process.stdin.isTTY) {
+ throw new Error(
+ "Interactive prompts cannot be used in non-TTY environments. Pass --yes to delete without confirming."
+ );
+ }
+
+ const project = await apiClient.getProject(options.projectRef);
+
+ if (!project.success) {
+ throw new Error(`Failed to get project: ${project.error}`);
+ }
+
+ const shouldDelete = await confirm({
+ message: `Delete ${project.data.name} (${project.data.externalRef}) and all of its runs? This cannot be undone.`,
+ initialValue: false,
+ });
+
+ if (isCancel(shouldDelete) || !shouldDelete) {
+ throw new OutroCommandError("Cancelled");
+ }
+ }
+
+ const response = await apiClient.deleteProject(options.projectRef);
+
+ if (!response.success) {
+ throw new Error(`Failed to delete project: ${response.error}`);
+ }
+
+ outro(`Deleted project ${options.projectRef}`);
+}
diff --git a/packages/cli-v3/src/commands/projects/get.ts b/packages/cli-v3/src/commands/projects/get.ts
new file mode 100644
index 00000000000..bbf28525226
--- /dev/null
+++ b/packages/cli-v3/src/commands/projects/get.ts
@@ -0,0 +1,67 @@
+import { intro, note, outro } from "@clack/prompts";
+import type { Command } from "commander";
+import { z } from "zod";
+import {
+ CommonCommandOptions,
+ commonOptions,
+ handleTelemetry,
+ wrapCommandAction,
+} from "../../cli/common.js";
+import { printStandloneInitialBanner } from "../../utilities/initialBanner.js";
+import { getPatApiClient } from "./common.js";
+
+const ProjectsGetCommandOptions = CommonCommandOptions.extend({
+ projectRef: z.string(),
+});
+
+type ProjectsGetCommandOptions = z.infer;
+
+export function configureProjectsGetCommand(program: Command) {
+ return commonOptions(
+ program
+ .command("get")
+ .description("Show the details of a Trigger.dev project")
+ .argument("", "The project ref, starting with proj_")
+ .action(async (projectRef, options) => {
+ await handleTelemetry(async () => {
+ await printStandloneInitialBanner(true, options.profile);
+ await projectsGetCommand({ ...options, projectRef });
+ });
+ })
+ );
+}
+
+async function projectsGetCommand(options: unknown) {
+ return await wrapCommandAction(
+ "projectsGetCommand",
+ ProjectsGetCommandOptions,
+ options,
+ async (opts) => await getProject(opts)
+ );
+}
+
+async function getProject(options: ProjectsGetCommandOptions) {
+ intro(`Getting project ${options.projectRef}`);
+
+ const apiClient = await getPatApiClient(options);
+ const response = await apiClient.getProject(options.projectRef);
+
+ if (!response.success) {
+ throw new Error(`Failed to get project: ${response.error}`);
+ }
+
+ const project = response.data;
+
+ note(
+ `Name: ${project.name}
+Ref: ${project.externalRef}
+Slug: ${project.slug}
+Org: ${project.organization.title}
+Runtime: ${project.defaultRuntime ?? "-"}
+Region: ${project.defaultRegion ?? "-"}
+Created: ${project.createdAt.toLocaleString()}`,
+ "Project details"
+ );
+
+ outro(`Project: ${project.externalRef}`);
+}
diff --git a/packages/cli-v3/src/commands/projects/index.ts b/packages/cli-v3/src/commands/projects/index.ts
index 6e677a07b4f..e57da375671 100644
--- a/packages/cli-v3/src/commands/projects/index.ts
+++ b/packages/cli-v3/src/commands/projects/index.ts
@@ -1,10 +1,18 @@
import type { Command } from "commander";
+import { configureProjectsCreateCommand } from "./create.js";
+import { configureProjectsDeleteCommand } from "./delete.js";
+import { configureProjectsGetCommand } from "./get.js";
import { configureProjectsListCommand } from "./list.js";
+import { configureProjectsRenameCommand } from "./rename.js";
export function configureProjectsCommand(program: Command) {
const projects = program.command("projects").description("Manage Trigger.dev projects");
configureProjectsListCommand(projects);
+ configureProjectsCreateCommand(projects);
+ configureProjectsGetCommand(projects);
+ configureProjectsRenameCommand(projects);
+ configureProjectsDeleteCommand(projects);
return projects;
}
diff --git a/packages/cli-v3/src/commands/projects/rename.ts b/packages/cli-v3/src/commands/projects/rename.ts
new file mode 100644
index 00000000000..4a696113d37
--- /dev/null
+++ b/packages/cli-v3/src/commands/projects/rename.ts
@@ -0,0 +1,56 @@
+import { intro, outro } from "@clack/prompts";
+import type { Command } from "commander";
+import { z } from "zod";
+import {
+ CommonCommandOptions,
+ commonOptions,
+ handleTelemetry,
+ wrapCommandAction,
+} from "../../cli/common.js";
+import { printStandloneInitialBanner } from "../../utilities/initialBanner.js";
+import { getPatApiClient } from "./common.js";
+
+const ProjectsRenameCommandOptions = CommonCommandOptions.extend({
+ projectRef: z.string(),
+ name: z.string().trim().min(1).max(255),
+});
+
+type ProjectsRenameCommandOptions = z.infer;
+
+export function configureProjectsRenameCommand(program: Command) {
+ return commonOptions(
+ program
+ .command("rename")
+ .description("Rename a Trigger.dev project")
+ .argument("", "The project ref, starting with proj_")
+ .argument("", "The new project name")
+ .action(async (projectRef, name, options) => {
+ await handleTelemetry(async () => {
+ await printStandloneInitialBanner(true, options.profile);
+ await projectsRenameCommand({ ...options, projectRef, name });
+ });
+ })
+ );
+}
+
+async function projectsRenameCommand(options: unknown) {
+ return await wrapCommandAction(
+ "projectsRenameCommand",
+ ProjectsRenameCommandOptions,
+ options,
+ async (opts) => await renameProject(opts)
+ );
+}
+
+async function renameProject(options: ProjectsRenameCommandOptions) {
+ intro(`Renaming project ${options.projectRef}`);
+
+ const apiClient = await getPatApiClient(options);
+ const response = await apiClient.renameProject(options.projectRef, { name: options.name });
+
+ if (!response.success) {
+ throw new Error(`Failed to rename project: ${response.error}`);
+ }
+
+ outro(`Renamed project ${options.projectRef} to ${response.data.name}`);
+}
diff --git a/packages/cli-v3/src/commands/runs/cancel.ts b/packages/cli-v3/src/commands/runs/cancel.ts
new file mode 100644
index 00000000000..5db72dc8f37
--- /dev/null
+++ b/packages/cli-v3/src/commands/runs/cancel.ts
@@ -0,0 +1,51 @@
+import { intro, outro } from "@clack/prompts";
+import type { Command } from "commander";
+import { z } from "zod";
+import { commonOptions, handleTelemetry, wrapCommandAction } from "../../cli/common.js";
+import { printInitialBanner } from "../../utilities/initialBanner.js";
+import { formatEnvInfo, resolveRunsClient, RunsCommonOptions, runsOptions } from "./common.js";
+
+const RunsCancelCommandOptions = RunsCommonOptions.extend({
+ runId: z.string(),
+});
+
+type RunsCancelCommandOptions = z.infer;
+
+export function configureRunsCancelCommand(program: Command) {
+ return commonOptions(
+ runsOptions(
+ program
+ .command("cancel")
+ .description("Cancel a run that hasn't finished yet")
+ .argument("", "The run ID, starting with run_")
+ ).action(async (runId, options) => {
+ await handleTelemetry(async () => {
+ await printInitialBanner(false, options.profile);
+ await runsCancelCommand({ ...options, runId });
+ });
+ })
+ );
+}
+
+async function runsCancelCommand(options: unknown) {
+ return await wrapCommandAction(
+ "runsCancelCommand",
+ RunsCancelCommandOptions,
+ options,
+ async (opts) => await cancelRun(opts)
+ );
+}
+
+async function cancelRun(options: RunsCancelCommandOptions) {
+ intro(`Cancelling run ${options.runId}`);
+
+ const { apiClient, projectRef, env, branch } = await resolveRunsClient(options);
+
+ await apiClient.cancelRun(options.runId);
+
+ const run = await apiClient.retrieveRun(options.runId);
+
+ outro(
+ `Cancelled ${run.id} (${run.status}) | Project: ${projectRef} | Environment: ${formatEnvInfo(env, branch)}`
+ );
+}
diff --git a/packages/cli-v3/src/commands/runs/common.ts b/packages/cli-v3/src/commands/runs/common.ts
new file mode 100644
index 00000000000..a9e2db6fd11
--- /dev/null
+++ b/packages/cli-v3/src/commands/runs/common.ts
@@ -0,0 +1,76 @@
+import type { Command } from "commander";
+import { z } from "zod";
+import { CommonCommandOptions } from "../../cli/common.js";
+import { loadConfig } from "../../config.js";
+import { logger } from "../../utilities/logger.js";
+import { getProjectEnvApiClient } from "../../utilities/session.js";
+import { login } from "../login.js";
+
+export const RunsCommonOptions = CommonCommandOptions.extend({
+ config: z.string().optional(),
+ projectRef: z.string().optional(),
+ env: z.enum(["dev", "prod", "staging", "preview", "production"]).default("prod"),
+ branch: z.string().optional(),
+});
+
+export type RunsCommonOptions = z.infer;
+
+export function runsOptions(command: Command) {
+ return command
+ .option("-c, --config ", "The name of the config file")
+ .option(
+ "-p, --project-ref ",
+ "The project ref. Required if there is no config file"
+ )
+ .option("-e, --env ", "The environment to use (dev, prod, staging, preview)", "prod")
+ .option("-b, --branch ", "The preview branch when using --env preview");
+}
+
+/** Resolves the project + environment and returns a core API client scoped to it. */
+export async function resolveRunsClient(options: RunsCommonOptions) {
+ const authorization = await login({
+ embedded: true,
+ defaultApiUrl: options.apiUrl,
+ profile: options.profile,
+ silent: true,
+ });
+
+ if (!authorization.ok) {
+ throw new Error(
+ `You must login first. Use the \`login\` CLI command.\n\n${authorization.error}`
+ );
+ }
+
+ const resolvedConfig = await loadConfig({
+ overrides: { project: options.projectRef },
+ configFile: options.config,
+ });
+
+ logger.debug("Resolved config", resolvedConfig);
+
+ // Coerce production to prod
+ const env = options.env === "production" ? "prod" : options.env;
+
+ if (env === "preview" && !options.branch) {
+ throw new Error("Missing branch for the preview environment.");
+ }
+
+ const apiClient = await getProjectEnvApiClient({
+ accessToken: authorization.auth.accessToken,
+ apiUrl: authorization.auth.apiUrl,
+ projectRef: resolvedConfig.project,
+ env,
+ branch: options.branch,
+ profile: options.profile,
+ });
+
+ if (!apiClient) {
+ throw new Error("Failed to get project client");
+ }
+
+ return { apiClient, projectRef: resolvedConfig.project, env, branch: options.branch };
+}
+
+export function formatEnvInfo(env: string, branch: string | undefined) {
+ return branch ? `${env} (${branch})` : env;
+}
diff --git a/packages/cli-v3/src/commands/runs/get.ts b/packages/cli-v3/src/commands/runs/get.ts
new file mode 100644
index 00000000000..3334639deb1
--- /dev/null
+++ b/packages/cli-v3/src/commands/runs/get.ts
@@ -0,0 +1,63 @@
+import { intro, note, outro } from "@clack/prompts";
+import type { Command } from "commander";
+import { z } from "zod";
+import { commonOptions, handleTelemetry, wrapCommandAction } from "../../cli/common.js";
+import { printInitialBanner } from "../../utilities/initialBanner.js";
+import { formatEnvInfo, resolveRunsClient, RunsCommonOptions, runsOptions } from "./common.js";
+
+const RunsGetCommandOptions = RunsCommonOptions.extend({
+ runId: z.string(),
+});
+
+type RunsGetCommandOptions = z.infer;
+
+export function configureRunsGetCommand(program: Command) {
+ return commonOptions(
+ runsOptions(
+ program
+ .command("get")
+ .description("Show the details of a run")
+ .argument("", "The run ID, starting with run_")
+ ).action(async (runId, options) => {
+ await handleTelemetry(async () => {
+ await printInitialBanner(false, options.profile);
+ await runsGetCommand({ ...options, runId });
+ });
+ })
+ );
+}
+
+async function runsGetCommand(options: unknown) {
+ return await wrapCommandAction(
+ "runsGetCommand",
+ RunsGetCommandOptions,
+ options,
+ async (opts) => await getRun(opts)
+ );
+}
+
+async function getRun(options: RunsGetCommandOptions) {
+ intro(`Getting run ${options.runId}`);
+
+ const { apiClient, projectRef, env, branch } = await resolveRunsClient(options);
+
+ const run = await apiClient.retrieveRun(options.runId);
+
+ note(
+ `ID: ${run.id}
+Task: ${run.taskIdentifier}
+Status: ${run.status}
+Version: ${run.version ?? "-"}
+Test: ${run.isTest ? "yes" : "no"}
+Tags: ${run.tags.length > 0 ? run.tags.join(", ") : "-"}
+Created: ${run.createdAt.toLocaleString()}
+Started: ${run.startedAt?.toLocaleString() ?? "-"}
+Finished: ${run.finishedAt?.toLocaleString() ?? "-"}
+Duration: ${run.durationMs > 0 ? `${(run.durationMs / 1000).toFixed(2)}s` : "-"}
+Cost: $${((run.costInCents + run.baseCostInCents) / 100).toFixed(4)}
+Error: ${run.error ? `${run.error.name ? `${run.error.name}: ` : ""}${run.error.message}` : "-"}`,
+ "Run details"
+ );
+
+ outro(`Project: ${projectRef} | Environment: ${formatEnvInfo(env, branch)}`);
+}
diff --git a/packages/cli-v3/src/commands/runs/index.ts b/packages/cli-v3/src/commands/runs/index.ts
new file mode 100644
index 00000000000..a2885b4ac93
--- /dev/null
+++ b/packages/cli-v3/src/commands/runs/index.ts
@@ -0,0 +1,16 @@
+import type { Command } from "commander";
+import { configureRunsCancelCommand } from "./cancel.js";
+import { configureRunsGetCommand } from "./get.js";
+import { configureRunsListCommand } from "./list.js";
+import { configureRunsReplayCommand } from "./replay.js";
+
+export function configureRunsCommand(program: Command) {
+ const runs = program.command("runs").description("Manage runs for your Trigger.dev project");
+
+ configureRunsListCommand(runs);
+ configureRunsGetCommand(runs);
+ configureRunsReplayCommand(runs);
+ configureRunsCancelCommand(runs);
+
+ return runs;
+}
diff --git a/packages/cli-v3/src/commands/runs/list.ts b/packages/cli-v3/src/commands/runs/list.ts
new file mode 100644
index 00000000000..907b1565cc2
--- /dev/null
+++ b/packages/cli-v3/src/commands/runs/list.ts
@@ -0,0 +1,88 @@
+import { intro, log, outro } from "@clack/prompts";
+import { RunStatus } from "@trigger.dev/core/v3";
+import type { Command } from "commander";
+import { z } from "zod";
+import { commonOptions, handleTelemetry, wrapCommandAction } from "../../cli/common.js";
+import { printInitialBanner } from "../../utilities/initialBanner.js";
+import { logger } from "../../utilities/logger.js";
+import { formatEnvInfo, resolveRunsClient, RunsCommonOptions, runsOptions } from "./common.js";
+
+const RunsListCommandOptions = RunsCommonOptions.extend({
+ limit: z.coerce.number().int().min(1).max(100).default(20),
+ status: RunStatus.optional(),
+ task: z.string().optional(),
+ tag: z.string().optional(),
+ cursor: z.string().optional(),
+});
+
+type RunsListCommandOptions = z.infer;
+
+export function configureRunsListCommand(program: Command) {
+ return commonOptions(
+ runsOptions(
+ program
+ .command("list")
+ .description("List runs for your project")
+ .option("--limit ", "The number of runs to list, up to 100", "20")
+ .option(
+ "--status ",
+ `Only show runs with this status (${RunStatus.options.join(", ")})`
+ )
+ .option("--task ", "Only show runs for this task")
+ .option("--tag ", "Only show runs with this tag")
+ .option("--cursor ", "The pagination cursor from a previous page")
+ ).action(async (options) => {
+ await handleTelemetry(async () => {
+ await printInitialBanner(false, options.profile);
+ await runsListCommand(options);
+ });
+ })
+ );
+}
+
+async function runsListCommand(options: unknown) {
+ return await wrapCommandAction(
+ "runsListCommand",
+ RunsListCommandOptions,
+ options,
+ async (opts) => await listRuns(opts)
+ );
+}
+
+async function listRuns(options: RunsListCommandOptions) {
+ intro("Listing runs");
+
+ const { apiClient, projectRef, env, branch } = await resolveRunsClient(options);
+
+ const page = await apiClient.listRuns({
+ limit: options.limit,
+ after: options.cursor,
+ status: options.status,
+ taskIdentifier: options.task,
+ tag: options.tag,
+ });
+
+ if (page.data.length === 0) {
+ outro(`No runs found | Project: ${projectRef} | Environment: ${formatEnvInfo(env, branch)}`);
+ return;
+ }
+
+ logger.table(
+ page.data.map((run) => ({
+ id: run.id,
+ task: run.taskIdentifier,
+ status: run.status,
+ version: run.version ?? "-",
+ created: run.createdAt.toLocaleString(),
+ duration: run.durationMs > 0 ? `${(run.durationMs / 1000).toFixed(2)}s` : "-",
+ }))
+ );
+
+ if (page.pagination.next) {
+ log.info(`Next page: --cursor ${page.pagination.next}`);
+ }
+
+ outro(
+ `Found ${page.data.length} run${page.data.length === 1 ? "" : "s"} | Project: ${projectRef} | Environment: ${formatEnvInfo(env, branch)}`
+ );
+}
diff --git a/packages/cli-v3/src/commands/runs/replay.ts b/packages/cli-v3/src/commands/runs/replay.ts
new file mode 100644
index 00000000000..99192c19c7e
--- /dev/null
+++ b/packages/cli-v3/src/commands/runs/replay.ts
@@ -0,0 +1,49 @@
+import { intro, outro } from "@clack/prompts";
+import type { Command } from "commander";
+import { z } from "zod";
+import { commonOptions, handleTelemetry, wrapCommandAction } from "../../cli/common.js";
+import { printInitialBanner } from "../../utilities/initialBanner.js";
+import { formatEnvInfo, resolveRunsClient, RunsCommonOptions, runsOptions } from "./common.js";
+
+const RunsReplayCommandOptions = RunsCommonOptions.extend({
+ runId: z.string(),
+});
+
+type RunsReplayCommandOptions = z.infer;
+
+export function configureRunsReplayCommand(program: Command) {
+ return commonOptions(
+ runsOptions(
+ program
+ .command("replay")
+ .description("Replay a run with the same payload, using the latest version")
+ .argument("", "The run ID, starting with run_")
+ ).action(async (runId, options) => {
+ await handleTelemetry(async () => {
+ await printInitialBanner(false, options.profile);
+ await runsReplayCommand({ ...options, runId });
+ });
+ })
+ );
+}
+
+async function runsReplayCommand(options: unknown) {
+ return await wrapCommandAction(
+ "runsReplayCommand",
+ RunsReplayCommandOptions,
+ options,
+ async (opts) => await replayRun(opts)
+ );
+}
+
+async function replayRun(options: RunsReplayCommandOptions) {
+ intro(`Replaying run ${options.runId}`);
+
+ const { apiClient, projectRef, env, branch } = await resolveRunsClient(options);
+
+ const replayed = await apiClient.replayRun(options.runId);
+
+ outro(
+ `Replayed as ${replayed.id} | Project: ${projectRef} | Environment: ${formatEnvInfo(env, branch)}`
+ );
+}
diff --git a/packages/cli-v3/src/utilities/session.ts b/packages/cli-v3/src/utilities/session.ts
index 2500cf1f368..5df6c25eccf 100644
--- a/packages/cli-v3/src/utilities/session.ts
+++ b/packages/cli-v3/src/utilities/session.ts
@@ -1,4 +1,4 @@
-import type { GitMeta } from "@trigger.dev/core/v3";
+import { ApiClient, type GitMeta } from "@trigger.dev/core/v3";
import { CliApiClient } from "../apiClient.js";
import { readAuthConfigProfile } from "./configFiles.js";
import { logger } from "./logger.js";
@@ -117,6 +117,21 @@ export async function getProjectClient(options: GetEnvOptions) {
};
}
+/** A core `ApiClient` scoped to the project environment's secret key, for the environment-keyed APIs (runs, etc). */
+export async function getProjectEnvApiClient(options: GetEnvOptions) {
+ const projectClient = await getProjectClient(options);
+
+ if (!projectClient?.client.accessToken) {
+ return;
+ }
+
+ return new ApiClient(
+ projectClient.client.apiURL,
+ projectClient.client.accessToken,
+ options.branch
+ );
+}
+
export type UpsertBranchOptions = {
accessToken: string;
apiUrl: string;