diff --git a/.changeset/mighty-camels-joke.md b/.changeset/mighty-camels-joke.md new file mode 100644 index 00000000000..de97b3d9c10 --- /dev/null +++ b/.changeset/mighty-camels-joke.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Improve error messages during dev/deploy and handle deploy image build issues diff --git a/.vscode/launch.json b/.vscode/launch.json index 01f730f6196..0aab3dea0eb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -41,7 +41,7 @@ "type": "node-terminal", "request": "launch", "name": "Debug V3 Deploy CLI", - "command": "pnpm exec trigger.dev deploy", + "command": "pnpm exec trigger.dev deploy --skip-deploy", "cwd": "${workspaceFolder}/references/v3-catalog", "sourceMaps": true }, diff --git a/packages/cli-v3/src/cli/common.ts b/packages/cli-v3/src/cli/common.ts index eac342ee84b..96202dfb15a 100644 --- a/packages/cli-v3/src/cli/common.ts +++ b/packages/cli-v3/src/cli/common.ts @@ -5,6 +5,7 @@ import { getTracer, provider } from "../telemetry/tracing"; import { fromZodError } from "zod-validation-error"; import { logger } from "../utilities/logger"; import { outro } from "@clack/prompts"; +import { chalkError } from "../utilities/cliOutput"; export const CommonCommandOptions = z.object({ apiUrl: z.string().optional(), @@ -84,7 +85,8 @@ export async function wrapCommandAction( // do nothing } else { recordSpanException(span, e); - logger.error(e instanceof Error ? e.message : String(e)); + + logger.log(`${chalkError("X Error:")} ${e instanceof Error ? e.message : String(e)}`); } span.end(); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 908ec737e03..dc5c247d6ad 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -43,9 +43,15 @@ import { logger } from "../utilities/logger.js"; import { createTaskFileImports, gatherTaskFiles } from "../utilities/taskFiles"; import { login } from "./login"; +import { Glob } from "glob"; import type { SetOptional } from "type-fest"; import { bundleDependenciesPlugin, workerSetupImportConfigPlugin } from "../utilities/build"; -import { Glob } from "glob"; +import { chalkError, chalkPurple, chalkWarning } from "../utilities/cliOutput"; +import { + logESMRequireError, + parseBuildErrorStack, + parseNpmInstallError, +} from "../utilities/deployErrors"; const DeployCommandOptions = CommonCommandOptions.extend({ skipTypecheck: z.boolean().default(false), @@ -272,28 +278,44 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { ); } - return buildAndPushImage({ - registryHost, - auth: authorization.auth.accessToken, - imageTag: deploymentResponse.data.imageTag, - buildId: deploymentResponse.data.externalBuildData.buildId, - buildToken: deploymentResponse.data.externalBuildData.buildToken, - buildProjectId: deploymentResponse.data.externalBuildData.projectId, - cwd: compilation.path, - projectId: resolvedConfig.config.project, - deploymentId: deploymentResponse.data.id, - deploymentVersion: deploymentResponse.data.version, - contentHash: deploymentResponse.data.contentHash, - projectRef: resolvedConfig.config.project, - loadImage: options.loadImage, - buildPlatform: options.buildPlatform, - }); + return buildAndPushImage( + { + registryHost, + auth: authorization.auth.accessToken, + imageTag: deploymentResponse.data.imageTag, + buildId: deploymentResponse.data.externalBuildData.buildId, + buildToken: deploymentResponse.data.externalBuildData.buildToken, + buildProjectId: deploymentResponse.data.externalBuildData.projectId, + cwd: compilation.path, + projectId: resolvedConfig.config.project, + deploymentId: deploymentResponse.data.id, + deploymentVersion: deploymentResponse.data.version, + contentHash: deploymentResponse.data.contentHash, + projectRef: resolvedConfig.config.project, + loadImage: options.loadImage, + buildPlatform: options.buildPlatform, + }, + deploymentSpinner + ); }; const image = await buildImage(); if (!image.ok) { - deploymentSpinner.stop(`Failed to build project image: ${image.error}`); + deploymentSpinner.stop(`Failed to build project.`); + + // If there are logs, let's write it out to a temporary file and include the path in the error message + if (image.logs.trim() !== "") { + const logPath = join(await createTempDir(), `build-${deploymentResponse.data.shortCode}.log`); + + await writeFile(logPath, image.logs); + + logger.log( + `${chalkError("X Error:")} ${image.error}. Full build logs have been saved to ${logPath})` + ); + } else { + logger.log(`${chalkError("X Error:")} ${image.error}.`); + } throw new SkipLoggingError(`Failed to build project image: ${image.error}`); } @@ -379,10 +401,19 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } case "FAILED": { if (finishedDeployment.errorData) { - deploymentSpinner.stop( - `Deployment encountered an error: ${finishedDeployment.errorData.name}. ${deploymentLink}` - ); - logger.error(finishedDeployment.errorData.stack); + const parsedError = finishedDeployment.errorData.stack + ? parseBuildErrorStack(finishedDeployment.errorData) + : finishedDeployment.errorData.message; + + if (typeof parsedError === "string") { + deploymentSpinner.stop(`Deployment encountered an error. ${deploymentLink}`); + + logger.log(`${chalkError("X Error:")} ${parsedError}`); + } else { + deploymentSpinner.stop(`Deployment encountered an error. ${deploymentLink}`); + + logESMRequireError(parsedError, resolvedConfig); + } throw new SkipLoggingError( `Deployment encountered an error: ${finishedDeployment.errorData.name}` @@ -551,15 +582,18 @@ type BuildAndPushImageResults = | { ok: true; image: string; + logs: string; digest?: string; } | { ok: false; error: string; + logs: string; }; async function buildAndPushImage( - options: BuildAndPushImageOptions + options: BuildAndPushImageOptions, + updater: ReturnType ): Promise { return tracer.startActiveSpan("buildAndPushImage", async (span) => { span.setAttributes({ @@ -626,7 +660,7 @@ async function buildAndPushImage( const errors: string[] = []; try { - await new Promise((res, rej) => { + const processCode = await new Promise((res, rej) => { // For some reason everything is output on stderr, not stdout childProcess.stderr?.on("data", (data: Buffer) => { const text = data.toString(); @@ -636,9 +670,19 @@ async function buildAndPushImage( }); childProcess.on("error", (e) => rej(e)); - childProcess.on("close", () => res()); + childProcess.on("close", (code) => res(code)); }); + const logs = extractLogs(errors); + + if (processCode !== 0) { + return { + ok: false as const, + error: `Error building image`, + logs, + }; + } + const digest = extractImageDigest(errors); span.setAttributes({ @@ -650,6 +694,7 @@ async function buildAndPushImage( return { ok: true as const, image: options.imageTag, + logs, digest, }; } catch (e) { @@ -659,6 +704,7 @@ async function buildAndPushImage( return { ok: false as const, error: e instanceof Error ? e.message : JSON.stringify(e), + logs: extractLogs(errors), }; } }); @@ -751,6 +797,7 @@ async function buildAndPushSelfHostedImage( return { ok: false as const, error: e instanceof Error ? e.message : JSON.stringify(e), + logs: extractLogs(errors), }; } @@ -793,6 +840,7 @@ async function buildAndPushSelfHostedImage( return { ok: false as const, error: e instanceof Error ? e.message : JSON.stringify(e), + logs: extractLogs(errors), }; } } @@ -803,6 +851,7 @@ async function buildAndPushSelfHostedImage( ok: true as const, image: options.imageTag, digest, + logs: extractLogs(errors), }; }); } @@ -820,6 +869,13 @@ function extractImageDigest(outputs: string[]) { } } +function extractLogs(outputs: string[]) { + // Remove empty lines + const cleanedOutputs = outputs.map((line) => line.trim()).filter((line) => line !== ""); + + return cleanedOutputs.map((line) => line.trim()).join("\n"); +} + async function compileProject( config: ResolvedConfig, options: DeployCommandOptions, @@ -1057,7 +1113,7 @@ async function compileProject( ); if (!resolvingDependenciesResult) { - throw new Error("Failed to resolve dependencies"); + throw new SkipLoggingError("Failed to resolve dependencies"); } // Write the Containerfile to /tmp/dir/Containerfile @@ -1188,15 +1244,39 @@ async function resolveDependencies( return true; } catch (installError) { - logger.debug(`Failed to resolve dependencies: ${JSON.stringify(installError)}`); - recordSpanException(span, installError); - span.end(); - resolvingDepsSpinner.stop( - "Failed to resolve dependencies. Rerun with --log-level=debug for more information" - ); + const parsedError = parseNpmInstallError(installError); + + if (typeof parsedError === "string") { + resolvingDepsSpinner.stop(`Failed to resolve dependencies: ${parsedError}`); + } else { + switch (parsedError.type) { + case "package-not-found-error": { + resolvingDepsSpinner.stop(`Failed to resolve dependencies`); + + logger.log( + `\n${chalkError("X Error:")} The package ${chalkPurple( + parsedError.packageName + )} could not be found in the npm registry.` + ); + + break; + } + case "no-matching-version-error": { + resolvingDepsSpinner.stop(`Failed to resolve dependencies`); + + logger.log( + `\n${chalkError("X Error:")} The package ${chalkPurple( + parsedError.packageName + )} could not resolve because the version doesn't exist` + ); + + break; + } + } + } return false; } @@ -1312,8 +1392,12 @@ async function gatherRequiredDependencies( dependencies[packageParts.name] = externalDependencyVersion; continue; } else { - logger.warn( - `Could not find version for package ${packageName}, add a version specifier to the package name (e.g. ${packageParts.name}@latest) or add it to your project's package.json` + logger.log( + `${chalkWarning("X Warning:")} Could not find version for package ${chalkPurple( + packageName + )}, add a version specifier to the package name (e.g. ${ + packageParts.name + }@latest) or add it to your project's package.json` ); } } diff --git a/packages/cli-v3/src/commands/dev.tsx b/packages/cli-v3/src/commands/dev.tsx index 53cfabaa445..d5b1babd896 100644 --- a/packages/cli-v3/src/commands/dev.tsx +++ b/packages/cli-v3/src/commands/dev.tsx @@ -41,6 +41,11 @@ import { createTaskFileImports, gatherTaskFiles } from "../utilities/taskFiles"; import { UncaughtExceptionError } from "../workers/common/errors"; import { BackgroundWorker, BackgroundWorkerCoordinator } from "../workers/dev/backgroundWorker.js"; import { runtimeCheck } from "../utilities/runtimeCheck"; +import { + logESMRequireError, + parseBuildErrorStack, + parseNpmInstallError, +} from "../utilities/deployErrors"; let apiClient: CliApiClient | undefined; @@ -544,20 +549,55 @@ function useDev({ ); } catch (e) { if (e instanceof UncaughtExceptionError) { + const parsedBuildError = parseBuildErrorStack(e.originalError); + + if (typeof parsedBuildError !== "string") { + logESMRequireError( + parsedBuildError, + configPath + ? { status: "file", path: configPath, config } + : { status: "in-memory", config } + ); + return; + } else { + } + if (e.originalError.stack) { - logger.error("Background worker failed to start", e.originalError.stack); + logger.log( + `${chalkError("X Error:")} Worker failed to start`, + e.originalError.stack + ); } return; } - if (e instanceof Error) { - logger.error(`Background worker failed to start`, e.stack); - - return; + const parsedError = parseNpmInstallError(e); + + if (typeof parsedError === "string") { + logger.log(`${chalkError("X Error:")} ${parsedError}`); + } else { + switch (parsedError.type) { + case "package-not-found-error": { + logger.log( + `\n${chalkError("X Error:")} The package ${chalkPurple( + parsedError.packageName + )} could not be found in the npm registry.` + ); + + break; + } + case "no-matching-version-error": { + logger.log( + `\n${chalkError("X Error:")} The package ${chalkPurple( + parsedError.packageName + )} could not resolve because the version doesn't exist` + ); + + break; + } + } } - - logger.error(`Background worker failed to start: ${e}`); } }); }, diff --git a/packages/cli-v3/src/utilities/deployErrors.ts b/packages/cli-v3/src/utilities/deployErrors.ts new file mode 100644 index 00000000000..03b1f908eb4 --- /dev/null +++ b/packages/cli-v3/src/utilities/deployErrors.ts @@ -0,0 +1,146 @@ +import chalk from "chalk"; +import { relative } from "node:path"; +import { chalkError, chalkPurple, chalkGrey, chalkGreen } from "./cliOutput"; +import { logger } from "./logger"; +import { ReadConfigResult } from "./configFiles"; + +export type ESMRequireError = { + type: "esm-require-error"; + moduleName: string; +}; + +export type BuildError = ESMRequireError | string; + +function errorIsErrorLike(error: unknown): error is Error { + return ( + error instanceof Error || (typeof error === "object" && error !== null && "message" in error) + ); +} + +export function parseBuildErrorStack(error: unknown): BuildError { + if (typeof error === "string") { + return error; + } + + if (errorIsErrorLike(error)) { + if (typeof error.stack === "string") { + const isErrRequireEsm = error.stack.includes("ERR_REQUIRE_ESM"); + + let moduleName = null; + + if (isErrRequireEsm) { + // Regular expression to match the module path + const moduleRegex = /node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/[^\/]+\s/; + const match = moduleRegex.exec(error.stack); + if (match) { + moduleName = match[1] as string; // Capture the module name + + return { + type: "esm-require-error", + moduleName, + }; + } + } + } else { + return error.message; + } + } + + return "Unknown error"; +} + +export function logESMRequireError(parsedError: ESMRequireError, resolvedConfig: ReadConfigResult) { + logger.log( + `\n${chalkError("X Error:")} The ${chalkPurple( + parsedError.moduleName + )} module is being required even though it's ESM only, and builds only support CommonJS. There are two ${chalk.underline( + "possible" + )} ways to fix this:` + ); + logger.log( + `\n${chalkGrey("○")} Dynamically import the module in your code: ${chalkGrey( + `const myModule = await import("${parsedError.moduleName}");` + )}` + ); + + if (resolvedConfig.status === "file") { + const relativePath = relative(resolvedConfig.config.projectDir, resolvedConfig.path).replace( + /\\/g, + "/" + ); + + logger.log( + `${chalkGrey("○")} ${chalk.underline("Or")} add ${chalkPurple( + parsedError.moduleName + )} to the ${chalkGreen("dependenciesToBundle")} array in your config file ${chalkGrey( + `(${relativePath})` + )}. This will bundle the module with your code.\n` + ); + } else { + logger.log( + `${chalkGrey("○")} ${chalk.underline("Or")} add ${chalkPurple( + parsedError.moduleName + )} to the ${chalkGreen("dependenciesToBundle")} array in your config file ${chalkGrey( + "(you'll need to create one)" + )}. This will bundle the module with your code.\n` + ); + } +} + +export type PackageNotFoundError = { + type: "package-not-found-error"; + packageName: string; +}; + +export type NoMatchingVersionError = { + type: "no-matching-version-error"; + packageName: string; +}; + +export type NpmInstallError = PackageNotFoundError | NoMatchingVersionError | string; + +export function parseNpmInstallError(error: unknown): NpmInstallError { + if (typeof error === "string") { + return error; + } + + if (error instanceof Error) { + if (typeof error.stack === "string") { + const isPackageNotFoundError = + error.stack.includes("ERR! 404 Not Found") && + error.stack.includes("is not in this registry"); + let packageName = null; + + if (isPackageNotFoundError) { + // Regular expression to match the package name + const packageNameRegex = /'([^']+)' is not in this registry/; + const match = packageNameRegex.exec(error.stack); + if (match) { + packageName = match[1] as string; // Capture the package name + } + } + + if (packageName) { + return { + type: "package-not-found-error", + packageName, + }; + } + + const noMatchingVersionRegex = /No matching version found for ([^\s]+)\s/; + const noMatchingVersionMatch = noMatchingVersionRegex.exec(error.stack); + if (noMatchingVersionMatch) { + return { + type: "no-matching-version-error", + packageName: (noMatchingVersionMatch[1] as string).replace(/.$/, ""), + }; + } + + return error.message; + } else { + return error.message; + } + } + + return "Unknown error"; +} diff --git a/packages/cli-v3/src/utilities/installPackages.ts b/packages/cli-v3/src/utilities/installPackages.ts index b7d716c322e..aee7da50663 100644 --- a/packages/cli-v3/src/utilities/installPackages.ts +++ b/packages/cli-v3/src/utilities/installPackages.ts @@ -15,23 +15,14 @@ export async function installPackages( await setPackageJsonDeps(join(cwd, "package.json"), packages); - const childProcess = execa( + await execa( "npm", ["install", "--install-strategy", "nested", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd, - stderr: "inherit", + stderr: "pipe", } ); - - await new Promise((res, rej) => { - childProcess.on("error", (e) => rej(e)); - childProcess.on("close", () => res()); - }); - - await childProcess; - - return; } async function getPackageVersion(path: string) {