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;