UNPKG

trigger.dev

Version:

A Command-Line Interface for Trigger.dev (v3) projects

413 lines 21.1 kB
import { intro, outro } from "@clack/prompts"; import { prepareDeploymentError } from "@trigger.dev/core/v3"; import { Option as CommandOption } from "commander"; import { writeFile } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import { readPackageJSON, writePackageJSON } from "pkg-types"; import { z } from "zod"; import { buildWorker } from "../build/buildWorker.js"; import { CommonCommandOptions, commonOptions, handleTelemetry, SkipLoggingError, wrapCommandAction, } from "../cli/common.js"; import { loadConfig } from "../config.js"; import { buildImage, generateContainerfile } from "../deploy/buildImage.js"; import { checkLogsForErrors, checkLogsForWarnings, printErrors, printWarnings, saveLogs, } from "../deploy/logs.js"; import { buildManifestToJSON } from "../utilities/buildManifest.js"; import { chalkError, cliLink, isLinksSupported, prettyError } from "../utilities/cliOutput.js"; import { loadDotEnvVars } from "../utilities/dotEnv.js"; import { writeJSONFile } from "../utilities/fileSystem.js"; import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; import { logger } from "../utilities/logger.js"; import { getProjectClient } from "../utilities/session.js"; import { getTmpDir } from "../utilities/tempDirectories.js"; import { spinner } from "../utilities/windows.js"; import { login } from "./login.js"; import { updateTriggerPackages } from "./update.js"; import { resolveAlwaysExternal } from "../build/externals.js"; import { x } from "tinyexec"; const DeployCommandOptions = CommonCommandOptions.extend({ dryRun: z.boolean().default(false), skipSyncEnvVars: z.boolean().default(false), env: z.enum(["prod", "staging"]), loadImage: z.boolean().default(false), buildPlatform: z.enum(["linux/amd64", "linux/arm64"]).default("linux/amd64"), namespace: z.string().optional(), selfHosted: z.boolean().default(false), registry: z.string().optional(), push: z.boolean().default(false), config: z.string().optional(), projectRef: z.string().optional(), apiUrl: z.string().optional(), saveLogs: z.boolean().default(false), skipUpdateCheck: z.boolean().default(false), noCache: z.boolean().default(false), envFile: z.string().optional(), network: z.enum(["default", "none", "host"]).optional(), }); export function configureDeployCommand(program) { return commonOptions(program .command("deploy") .description("Deploy your Trigger.dev v3 project to the cloud.") .argument("[path]", "The path to the project", ".") .option("-e, --env <env>", "Deploy to a specific environment (currently only prod and staging are supported)", "prod") .option("--skip-update-check", "Skip checking for @trigger.dev package updates") .option("-c, --config <config file>", "The name of the config file, found at [path]") .option("-p, --project-ref <project ref>", "The project ref. Required if there is no config file. This will override the project specified in the config file.") .option("--dry-run", "Do a dry run of the deployment. This will not actually deploy the project, but will show you what would be deployed.") .option("--skip-sync-env-vars", "Skip syncing environment variables when using the syncEnvVars extension.") .option("--env-file <env file>", "Path to the .env file to load into the CLI process. Defaults to .env in the project directory.")) .addOption(new CommandOption("--self-hosted", "Build and load the image using your local Docker. Use the --registry option to specify the registry to push the image to when using --self-hosted, or just use --push to push to the default registry.").hideHelp()) .addOption(new CommandOption("--no-cache", "Do not use the cache when building the image. This will slow down the build process but can be useful if you are experiencing issues with the cache.").hideHelp()) .addOption(new CommandOption("--push", "When using the --self-hosted flag, push the image to the default registry. (defaults to false when not using --registry)").hideHelp()) .addOption(new CommandOption("--registry <registry>", "The registry to push the image to when using --self-hosted").hideHelp()) .addOption(new CommandOption("--tag <tag>", "(Coming soon) Specify the tag to use when pushing the image to the registry").hideHelp()) .addOption(new CommandOption("--namespace <namespace>", "Specify the namespace to use when pushing the image to the registry").hideHelp()) .addOption(new CommandOption("--load-image", "Load the built image into your local docker").hideHelp()) .addOption(new CommandOption("--build-platform <platform>", "The platform to build the deployment image for") .default("linux/amd64") .hideHelp()) .addOption(new CommandOption("--save-logs", "If provided, will save logs even for successful builds").hideHelp()) .option("--network <mode>", "The networking mode for RUN instructions when using --self-hosted") .action(async (path, options) => { await handleTelemetry(async () => { await printStandloneInitialBanner(true); await deployCommand(path, options); }); }); } export async function deployCommand(dir, options) { return await wrapCommandAction("deployCommand", DeployCommandOptions, options, async (opts) => { return await _deployCommand(dir, opts); }); } async function _deployCommand(dir, options) { intro("Deploying project"); if (!options.skipUpdateCheck) { await updateTriggerPackages(dir, { ...options }, true, true); } const projectPath = resolve(process.cwd(), dir); const authorization = await login({ embedded: true, defaultApiUrl: options.apiUrl, profile: options.profile, }); if (!authorization.ok) { if (authorization.error === "fetch failed") { throw new Error(`Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`); } else { throw new Error(`You must login first. Use the \`login\` CLI command.\n\n${authorization.error}`); } } const resolvedConfig = await loadConfig({ cwd: projectPath, overrides: { project: options.projectRef }, configFile: options.config, }); logger.debug("Resolved config", resolvedConfig); const projectClient = await getProjectClient({ accessToken: authorization.auth.accessToken, apiUrl: authorization.auth.apiUrl, projectRef: resolvedConfig.project, env: options.env, profile: options.profile, }); if (!projectClient) { throw new Error("Failed to get project client"); } const serverEnvVars = await projectClient.client.getEnvironmentVariables(resolvedConfig.project); loadDotEnvVars(resolvedConfig.workingDir, options.envFile); const destination = getTmpDir(resolvedConfig.workingDir, "build", options.dryRun); const $buildSpinner = spinner(); const forcedExternals = await resolveAlwaysExternal(projectClient.client); const buildManifest = await buildWorker({ target: "deploy", environment: options.env, destination: destination.path, resolvedConfig, rewritePaths: true, envVars: serverEnvVars.success ? serverEnvVars.data.variables : {}, forcedExternals, listener: { onBundleStart() { $buildSpinner.start("Building project"); }, onBundleComplete(result) { $buildSpinner.stop("Successfully built project"); logger.debug("Bundle result", result); }, }, }); logger.debug("Successfully built project to", destination.path); if (options.dryRun) { logger.info(`Dry run complete. View the built project at ${destination.path}`); return; } const deploymentResponse = await projectClient.client.initializeDeployment({ contentHash: buildManifest.contentHash, userId: authorization.userId, selfHosted: options.selfHosted, registryHost: options.registry, namespace: options.namespace, }); if (!deploymentResponse.success) { throw new Error(`Failed to start deployment: ${deploymentResponse.error}`); } const deployment = deploymentResponse.data; // If the deployment doesn't have any externalBuildData, then we can't use the remote image builder // TODO: handle this and allow the user to the build and push the image themselves if (!deployment.externalBuildData && !options.selfHosted) { throw new Error(`Failed to start deployment, as your instance of trigger.dev does not support hosting. To deploy this project, you must use the --self-hosted flag to build and push the image yourself.`); } if (options.selfHosted) { const result = await x("docker", ["buildx", "version"]); if (result.exitCode !== 0) { logger.debug(`"docker buildx version" failed (${result.exitCode}):`, result); throw new Error("Failed to find docker buildx. Please install it: https://github.com/docker/buildx#installing."); } } if (buildManifest.deploy.sync && buildManifest.deploy.sync.env && Object.keys(buildManifest.deploy.sync.env).length > 0) { const numberOfEnvVars = Object.keys(buildManifest.deploy.sync.env).length; const vars = numberOfEnvVars === 1 ? "var" : "vars"; if (!options.skipSyncEnvVars) { const $spinner = spinner(); $spinner.start(`Syncing ${numberOfEnvVars} env ${vars} with the server`); const success = await syncEnvVarsWithServer(projectClient.client, resolvedConfig.project, options.env, buildManifest.deploy.sync.env); if (!success) { await failDeploy(projectClient.client, deployment, { name: "SyncEnvVarsError", message: `Failed to sync ${numberOfEnvVars} env ${vars} with the server`, }, "", $spinner); } else { $spinner.stop(`Successfully synced ${numberOfEnvVars} env ${vars} with the server`); } } else { logger.log("Skipping syncing env vars. The environment variables in your project have changed, but the --skip-sync-env-vars flag was provided."); } } const version = deployment.version; const deploymentLink = cliLink("View deployment", `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/deployments/${deployment.shortCode}`); const testLink = cliLink("Test tasks", `${authorization.dashboardUrl}/projects/v3/${resolvedConfig.project}/test?environment=${options.env === "prod" ? "prod" : "stg"}`); const $spinner = spinner(); if (isLinksSupported) { $spinner.start(`Deploying version ${version} ${deploymentLink}`); } else { $spinner.start(`Deploying version ${version}`); } const selfHostedRegistryHost = deployment.registryHost ?? options.registry; const registryHost = selfHostedRegistryHost ?? "registry.trigger.dev"; const buildResult = await buildImage({ selfHosted: options.selfHosted, buildPlatform: options.buildPlatform, noCache: options.noCache, push: options.push, registryHost, registry: options.registry, deploymentId: deployment.id, deploymentVersion: deployment.version, imageTag: deployment.imageTag, loadImage: options.loadImage, contentHash: deployment.contentHash, externalBuildId: deployment.externalBuildData?.buildId, externalBuildToken: deployment.externalBuildData?.buildToken, externalBuildProjectId: deployment.externalBuildData?.projectId, projectId: projectClient.id, projectRef: resolvedConfig.project, apiUrl: projectClient.client.apiURL, apiKey: projectClient.client.accessToken, authAccessToken: authorization.auth.accessToken, compilationPath: destination.path, buildEnvVars: buildManifest.build.env, network: options.network, }); logger.debug("Build result", buildResult); const warnings = checkLogsForWarnings(buildResult.logs); if (!warnings.ok) { await failDeploy(projectClient.client, deployment, { name: "BuildError", message: warnings.summary }, buildResult.logs, $spinner, warnings.warnings, warnings.errors); throw new SkipLoggingError("Failed to build image"); } if (!buildResult.ok) { await failDeploy(projectClient.client, deployment, { name: "BuildError", message: buildResult.error }, buildResult.logs, $spinner, warnings.warnings); throw new SkipLoggingError("Failed to build image"); } const getDeploymentResponse = await projectClient.client.getDeployment(deployment.id); if (!getDeploymentResponse.success) { await failDeploy(projectClient.client, deployment, { name: "DeploymentError", message: getDeploymentResponse.error }, buildResult.logs, $spinner); throw new SkipLoggingError("Failed to get deployment with worker"); } const deploymentWithWorker = getDeploymentResponse.data; if (!deploymentWithWorker.worker) { await failDeploy(projectClient.client, deployment, { name: "DeploymentError", message: "Failed to get deployment with worker" }, buildResult.logs, $spinner); throw new SkipLoggingError("Failed to get deployment with worker"); } const imageReference = options.selfHosted ? `${selfHostedRegistryHost ? `${selfHostedRegistryHost}/` : ""}${buildResult.image}${buildResult.digest ? `@${buildResult.digest}` : ""}` : `${registryHost}/${buildResult.image}${buildResult.digest ? `@${buildResult.digest}` : ""}`; const finalizeResponse = await projectClient.client.finalizeDeployment(deployment.id, { imageReference, selfHosted: options.selfHosted, }); if (!finalizeResponse.success) { await failDeploy(projectClient.client, deployment, { name: "FinalizeError", message: finalizeResponse.error }, buildResult.logs, $spinner); throw new SkipLoggingError("Failed to finalize deployment"); } $spinner.stop(`Successfully deployed version ${version}`); const taskCount = deploymentWithWorker.worker?.tasks.length ?? 0; outro(`Version ${version} deployed with ${taskCount} detected task${taskCount === 1 ? "" : "s"} ${isLinksSupported ? `| ${deploymentLink} | ${testLink}` : ""}`); } function rewriteBuildManifestPaths(buildManifest, destinationDir) { return { ...buildManifest, files: buildManifest.files.map((file) => ({ ...file, entry: cleanEntryPath(file.entry), out: rewriteOutputPath(destinationDir, file.out), })), outputPath: rewriteOutputPath(destinationDir, buildManifest.outputPath), configPath: rewriteOutputPath(destinationDir, buildManifest.configPath), runControllerEntryPoint: buildManifest.runControllerEntryPoint ? rewriteOutputPath(destinationDir, buildManifest.runControllerEntryPoint) : undefined, runWorkerEntryPoint: rewriteOutputPath(destinationDir, buildManifest.runWorkerEntryPoint), indexControllerEntryPoint: buildManifest.indexControllerEntryPoint ? rewriteOutputPath(destinationDir, buildManifest.indexControllerEntryPoint) : undefined, indexWorkerEntryPoint: rewriteOutputPath(destinationDir, buildManifest.indexWorkerEntryPoint), loaderEntryPoint: buildManifest.loaderEntryPoint ? rewriteOutputPath(destinationDir, buildManifest.loaderEntryPoint) : undefined, }; } async function writeProjectFiles(buildManifest, resolvedConfig, outputPath) { // Step 1. Read the package.json file const packageJson = await readProjectPackageJson(resolvedConfig.packageJsonPath); if (!packageJson) { throw new Error("Could not read the package.json file"); } const dependencies = buildManifest.externals?.reduce((acc, external) => { acc[external.name] = external.version; return acc; }, {}) ?? {}; // Step 3: Write the resolved dependencies to the package.json file await writePackageJSON(join(outputPath, "package.json"), { ...packageJson, name: packageJson.name ?? "trigger-project", dependencies: { ...dependencies, }, trustedDependencies: Object.keys(dependencies), devDependencies: {}, peerDependencies: {}, scripts: {}, }); await writeJSONFile(join(outputPath, "build.json"), buildManifestToJSON(buildManifest)); await writeContainerfile(outputPath, buildManifest); } async function readProjectPackageJson(packageJsonPath) { const packageJson = await readPackageJSON(packageJsonPath); return packageJson; } // Remove any query parameters from the entry path // For example, src/trigger/ai.ts?sentryProxyModule=true -> src/trigger/ai.ts function cleanEntryPath(entry) { return entry.split("?")[0]; } function rewriteOutputPath(destinationDir, filePath) { return `/app/${relative(destinationDir, filePath)}`; } async function writeContainerfile(outputPath, buildManifest) { if (!buildManifest.runControllerEntryPoint || !buildManifest.indexControllerEntryPoint) { throw new Error("Something went wrong with the build. Aborting deployment. [code 7789]"); } const containerfile = await generateContainerfile({ runtime: buildManifest.runtime, entrypoint: buildManifest.runControllerEntryPoint, build: buildManifest.build, image: buildManifest.image, indexScript: buildManifest.indexControllerEntryPoint, }); await writeFile(join(outputPath, "Containerfile"), containerfile); } export async function syncEnvVarsWithServer(apiClient, projectRef, environmentSlug, envVars) { const uploadResult = await apiClient.importEnvVars(projectRef, environmentSlug, { variables: envVars, override: true, }); return uploadResult.success; } async function failDeploy(client, deployment, error, logs, $spinner, warnings, errors) { $spinner.stop(`Failed to deploy project`); const doOutputLogs = async (prefix = "Error") => { if (logs.trim() !== "") { const logPath = await saveLogs(deployment.shortCode, logs); printWarnings(warnings); printErrors(errors); checkLogsForErrors(logs); outro(`${chalkError(`${prefix}:`)} ${error.message}. Full build logs have been saved to ${logPath}`); } else { outro(`${chalkError(`${prefix}:`)} ${error.message}.`); } }; const exitCommand = (message) => { throw new SkipLoggingError(message); }; const deploymentResponse = await client.getDeployment(deployment.id); if (!deploymentResponse.success) { logger.debug(`Failed to get deployment with worker: ${deploymentResponse.error}`); } else { const serverDeployment = deploymentResponse.data; switch (serverDeployment.status) { case "PENDING": case "DEPLOYING": case "BUILDING": { await doOutputLogs(); await client.failDeployment(deployment.id, { error, }); exitCommand("Failed to deploy project"); break; } case "CANCELED": { await doOutputLogs("Canceled"); exitCommand("Failed to deploy project"); break; } case "FAILED": { const errorData = serverDeployment.errorData ? prepareDeploymentError(serverDeployment.errorData) : undefined; if (errorData) { prettyError(errorData.name, errorData.stack, errorData.stderr); if (logs.trim() !== "") { const logPath = await saveLogs(deployment.shortCode, logs); outro(`Aborting deployment. Full build logs have been saved to ${logPath}`); } else { outro(`Aborting deployment`); } } else { await doOutputLogs("Failed"); } exitCommand("Failed to deploy project"); break; } case "DEPLOYED": { await doOutputLogs("Deployed with errors"); exitCommand("Deployed with errors"); break; } case "TIMED_OUT": { await doOutputLogs("TimedOut"); exitCommand("Timed out"); break; } } } } //# sourceMappingURL=deploy.js.map