diff --git a/.changeset/beige-pens-dance.md b/.changeset/beige-pens-dance.md new file mode 100644 index 000000000..49276987d --- /dev/null +++ b/.changeset/beige-pens-dance.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +v3 CLI update command and package manager detection fix diff --git a/docs/_snippets/v3/step-cli-dev.mdx b/docs/_snippets/v3/step-cli-dev.mdx index 7f13b80a5..43e71de5c 100644 --- a/docs/_snippets/v3/step-cli-dev.mdx +++ b/docs/_snippets/v3/step-cli-dev.mdx @@ -2,6 +2,8 @@ The CLI `dev` command runs a server for your tasks. It will watches for changes in your `/trigger` directory and communicates with the Trigger.dev platform to register your tasks, perform runs, and send data back and forth. +It can also update your `@trigger.dev/*` packages to prevent version mismatches and failed deploys. You will always be prompted first. + ```bash npm diff --git a/docs/v3/cli-deploy.mdx b/docs/v3/cli-deploy.mdx index 6265dd27a..d9933da59 100644 --- a/docs/v3/cli-deploy.mdx +++ b/docs/v3/cli-deploy.mdx @@ -21,13 +21,16 @@ yarn dlx trigger.dev@beta deploy +Will fail in CI if any version mismatches are detected. Ensure everything runs locally first using the [dev](/v3/cli-dev) command and don't bypass the version checks! + It performs a few steps to deploy: -1. Typechecks the code. -2. Compiles and bundles the code. -3. Checks that [environment variables](/v3/deploy-environment-variables) are set. -4. Deploys the code to the cloud. -5. Registers the tasks as a new version in the environment (prod by default). +1. Optionally updates packages when running locally. +2. Typechecks the code. +3. Compiles and bundles the code. +4. Checks that [environment variables](/v3/deploy-environment-variables) are set. +5. Deploys the code to the cloud. +6. Registers the tasks as a new version in the environment (prod by default). ## Options diff --git a/docs/v3/cli-dev.mdx b/docs/v3/cli-dev.mdx index 27d5856d3..26f4e373a 100644 --- a/docs/v3/cli-dev.mdx +++ b/docs/v3/cli-dev.mdx @@ -21,6 +21,8 @@ yarn dlx trigger.dev@beta dev +It will first perform an update check to prevent version mismatches, failed deploys, and other errors. You will always be prompted first. + You will see in the terminal that the server is running and listening for requests. When you run a task, you will see it in the terminal along with a link to view it in the dashboard. It is worth noting that each task runs in a separate Node process. This means that if you have a long-running task, it will not block other tasks from running. diff --git a/docs/v3/github-actions.mdx b/docs/v3/github-actions.mdx index 43c25f364..233bf5599 100644 --- a/docs/v3/github-actions.mdx +++ b/docs/v3/github-actions.mdx @@ -5,6 +5,8 @@ description: "You can easily deploy your tasks with GitHub actions." This simple GitHub action file will deploy you Trigger.dev tasks when new code is pushed to the `main` branch and the `trigger` directory has changes in it. +The deploy step will fail if any version mismatches are detected. Please see the [version pinning](/v3/github-actions#version-pinning) section for more details. + ```yaml .github/workflows/release-trigger.yml name: Deploy to Trigger.dev @@ -42,3 +44,19 @@ If you already have a GitHub action file, you can just add the final step "🚀 You need to add the `TRIGGER_ACCESS_TOKEN` secret to your repository. You can create a new access token by going to your profile page and then clicking on the "Personal Access Tokens" tab. To set it in GitHub go to your repository, click on "Settings", "Secrets and variables" and then "Actions". Add a new secret with the name `TRIGGER_ACCESS_TOKEN` and use the value of your access token. + +## Version pinning + +The CLI and `@trigger.dev/*` package versions need to be in sync, otherwise there will be errors and unpredictable behavior. Hence, the `deploy` command will automatically fail during CI on any version mismatches. + +To ensure a smooth CI experience you can pin the CLI version in the deploy step, like so: + +```yaml .github/workflows/release-trigger.yml +- name: 🚀 Deploy Trigger.dev + env: + TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} + run: | + npx trigger.dev@3.0.0-beta.16 deploy +``` + +You should use the version you run locally during dev and manual deploy. The current version is displayed in the banner, but you can also check it by appending `--version` to any command. diff --git a/docs/v3/upgrading-from-v2.mdx b/docs/v3/upgrading-from-v2.mdx index eec1b0f81..012c8c481 100644 --- a/docs/v3/upgrading-from-v2.mdx +++ b/docs/v3/upgrading-from-v2.mdx @@ -171,25 +171,7 @@ async function yourBackendFunction() { ## Upgrading your project - - - - -You can run this command to upgrade all the packages to the beta: - -```bash -npx @trigger.dev/cli@beta update --to beta -``` - - - - - -Follow the [v3 quick start](/v3/quick-start) to get started with v3. - - - - +Just follow the [v3 quick start](/v3/quick-start) to get started with v3. Our new CLI will take care of the rest. ## Using v2 together with v3 diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index a4926a378..97ede8857 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -106,7 +106,6 @@ "mock-fs": "^5.2.0", "nanoid": "^4.0.2", "node-fetch": "^3.3.0", - "npm-check-updates": "^16.12.2", "object-hash": "^3.0.0", "p-debounce": "^4.0.0", "p-throttle": "^6.1.0", diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts index 0600504c0..52f80346e 100644 --- a/packages/cli-v3/src/cli/index.ts +++ b/packages/cli-v3/src/cli/index.ts @@ -8,6 +8,7 @@ import { configureWhoamiCommand } from "../commands/whoami.js"; import { COMMAND_NAME } from "../consts.js"; import { getVersion } from "../utilities/getVersion.js"; import { configureListProfilesCommand } from "../commands/list-profiles.js"; +import { configureUpdateCommand } from "../commands/update.js"; export const program = new Command(); @@ -23,3 +24,4 @@ configureDeployCommand(program); configureWhoamiCommand(program); configureLogoutCommand(program); configureListProfilesCommand(program); +configureUpdateCommand(program); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 78d8b2f2b..94f974917 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -57,6 +57,7 @@ import { safeJsonParse } from "../utilities/safeJsonParse"; import { JavascriptProject } from "../utilities/javascriptProject"; import { cliRootPath } from "../utilities/resolveInternalFilePath"; import { escapeImportPath, spinner } from "../utilities/windows"; +import { updateTriggerPackages } from "./update"; import { docs, getInTouch } from "../utilities/links"; const DeployCommandOptions = CommonCommandOptions.extend({ @@ -74,6 +75,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({ outputMetafile: z.string().optional(), apiUrl: z.string().optional(), saveLogs: z.boolean().default(false), + skipUpdateCheck: z.boolean().default(false), }); type DeployCommandOptions = z.infer; @@ -90,6 +92,7 @@ export function configureDeployCommand(program: Command) { "prod" ) .option("--skip-typecheck", "Whether to skip the pre-build typecheck") + .option("--skip-update-check", "Skip checking for @trigger.dev package updates") .option( "--ignore-env-var-check", "Detected missing environment variables won't block deployment" @@ -167,6 +170,10 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { intro("Deploying project"); + if (!options.skipUpdateCheck) { + await updateTriggerPackages(dir, { ...options }, true, true); + } + const authorization = await login({ embedded: true, defaultApiUrl: options.apiUrl, diff --git a/packages/cli-v3/src/commands/dev.tsx b/packages/cli-v3/src/commands/dev.tsx index 30ae4f9f2..6ae86d72d 100644 --- a/packages/cli-v3/src/commands/dev.tsx +++ b/packages/cli-v3/src/commands/dev.tsx @@ -53,6 +53,7 @@ import { import { findUp, pathExists } from "find-up"; import { cliRootPath } from "../utilities/resolveInternalFilePath"; import { escapeImportPath } from "../utilities/windows"; +import { updateTriggerPackages } from "./update"; let apiClient: CliApiClient | undefined; @@ -61,6 +62,7 @@ const DevCommandOptions = CommonCommandOptions.extend({ debugOtel: z.boolean().default(false), config: z.string().optional(), projectRef: z.string().optional(), + skipUpdateCheck: z.boolean().default(false), }); type DevCommandOptions = z.infer; @@ -78,6 +80,7 @@ export function configureDevCommand(program: Command) { ) .option("--debugger", "Enable the debugger") .option("--debug-otel", "Enable OpenTelemetry debugging") + .option("--skip-update-check", "Skip checking for @trigger.dev package updates") ).action(async (path, options) => { wrapCommandAction("dev", DevCommandOptions, options, async (opts) => { await devCommand(path, opts); @@ -132,7 +135,13 @@ async function startDev( } await printStandloneInitialBanner(true); - printDevBanner(); + + if (!options.skipUpdateCheck) { + console.log(); // spacing + await updateTriggerPackages(dir, { ...options }, false, true); + } + + printDevBanner(!options.skipUpdateCheck); logger.debug("Starting dev session", { dir, options, authorization }); diff --git a/packages/cli-v3/src/commands/update.ts b/packages/cli-v3/src/commands/update.ts index 97ceba66d..7f40a1a31 100644 --- a/packages/cli-v3/src/commands/update.ts +++ b/packages/cli-v3/src/commands/update.ts @@ -1,148 +1,300 @@ -import { confirm } from "@clack/prompts"; -import { RunOptions, run } from "npm-check-updates"; -import path from "path"; +import { confirm, intro, isCancel, log, outro } from "@clack/prompts"; import { z } from "zod"; -import { chalkError, chalkSuccess } from "../utilities/cliOutput.js"; -import { readJSONFileSync, writeJSONFile } from "../utilities/fileSystem.js"; -import { installDependencies } from "../utilities/installDependencies.js"; +import { readJSONFile, removeFile, writeJSONFile } from "../utilities/fileSystem.js"; import { spinner } from "../utilities/windows.js"; +import { CommonCommandOptions, OutroCommandError, wrapCommandAction } from "../cli/common.js"; +import { Command } from "commander"; +import { logger } from "../utilities/logger.js"; +import { PackageJson } from "type-fest"; +import { printStandloneInitialBanner, updateCheck } from "../utilities/initialBanner.js"; +import { join, resolve } from "path"; +import { JavascriptProject } from "../utilities/javascriptProject.js"; +import { PackageManager } from "../utilities/getUserPackageManager.js"; +import { getVersion } from "../utilities/getVersion.js"; +import { chalkError, prettyWarning } from "../utilities/cliOutput.js"; -export const UpdateCommandOptionsSchema = z.object({ - to: z.string().optional(), +export const UpdateCommandOptions = CommonCommandOptions.pick({ + logLevel: true, + skipTelemetry: true, }); -export type UpdateCommandOptions = z.infer; +export type UpdateCommandOptions = z.infer; -type NcuRunOptionTarget = "latest" | `@${string}`; - -export async function updateCommand(projectPath: string, anyOptions: any) { - const loadingSpinner = spinner(); - loadingSpinner.start("Checking settings"); - - const parseRes = UpdateCommandOptionsSchema.safeParse(anyOptions); - if (!parseRes.success) { - loadingSpinner.stop(chalkError(parseRes.error.message)); - return; - } - const options = parseRes.data; - - const triggerDevPackage = "@trigger.dev"; - const packageJSONPath = path.join(projectPath, "package.json"); - const packageData = readJSONFileSync(packageJSONPath); - if (!packageData) { - loadingSpinner.stop(chalkError("Couldn't load package.json")); - return; - } - - loadingSpinner.message("Checking for updates"); - - const packageMaps: { [k: string]: { type: string; version: string } } = {}; - const packageDependencies = packageData.dependencies || {}; - const packageDevDependencies = packageData.devDependencies || {}; - Object.keys(packageDependencies).forEach((i) => { - packageMaps[i] = { type: "dependencies", version: packageDependencies[i] }; - }); - Object.keys(packageDevDependencies).forEach((i) => { - packageMaps[i] = { - type: "devDependencies", - version: packageDevDependencies[i], - }; - }); - - const targetVersion = getTargetVersion(options.to); - - // Use npm-check-updates to get updated dependency versions - const ncuOptions: RunOptions = { - packageData, - upgrade: true, - jsonUpgraded: true, - target: targetVersion, - }; - - // Can either give a json like package.json or just with deps and their new versions - const updatedDependencies: { [k: string]: any } | void = await run(ncuOptions); - - if (!updatedDependencies) { - loadingSpinner.stop(chalkError("Couldn't update dependencies")); - return; - } - - const ifUpdatedDependenciesIsPackageJSON = - updatedDependencies.hasOwnProperty("dependencies") || - updatedDependencies.hasOwnProperty("devDependencies"); - - const dependencies = updatedDependencies.dependencies || {}; - const devDependencies = updatedDependencies.devDependencies || {}; - - const allDependencies = ifUpdatedDependenciesIsPackageJSON - ? Object.keys({ ...dependencies, ...devDependencies }) - : Object.keys(updatedDependencies); - - const triggerPackages = allDependencies.filter((pkg) => pkg.startsWith(triggerDevPackage)); - - // If there are no @trigger.dev packages - if (triggerPackages.length === 0) { - loadingSpinner.stop(chalkSuccess(`All @trigger.dev/* packages are already up to date.`)); - return; - } - - // Filter the packages with null and what don't match what - // they are installed with so that they can be updated - const packagesToUpdate = triggerPackages.filter((pkg: string) => updatedDependencies[pkg]); - - // If no packages require any updation - if (packagesToUpdate.length === 0) { - loadingSpinner.stop(chalkSuccess(`All @trigger.dev/* packages are already up to date.`)); - return; - } - - let applyUpdates = targetVersion !== "latest"; - - if (targetVersion === "latest") { - applyUpdates = await hasUserConfirmed(packagesToUpdate, packageMaps, updatedDependencies); - } - - if (applyUpdates) { - const newPackageJSON = packageData; - packagesToUpdate.forEach((packageName) => { - const tmp = packageMaps[packageName]; - if (tmp) { - newPackageJSON[tmp.type][packageName] = updatedDependencies[packageName]; - } +export function configureUpdateCommand(program: Command) { + return program + .command("update") + .description("Updates all @trigger.dev/* packages to match the CLI version") + .argument("[path]", "The path to the directory that contains the package.json file", ".") + .option( + "-l, --log-level ", + "The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.", + "log" + ) + .option("--skip-telemetry", "Opt-out of sending telemetry") + .action(async (path, options) => { + wrapCommandAction("dev", UpdateCommandOptions, options, async (opts) => { + await printStandloneInitialBanner(true); + await updateCommand(path, opts); + }); }); - await writeJSONFile(packageJSONPath, newPackageJSON); - await installDependencies(projectPath); - } } -// expects a version number, or latest. -// if version number is specified, prepend it with '@' for ncu. -function getTargetVersion(toVersion?: string): NcuRunOptionTarget { - if (!toVersion) { - return "latest"; - } - return toVersion === "latest" ? "latest" : `@${toVersion}`; +const triggerPackageFilter = /^@trigger\.dev/; + +export async function updateCommand(dir: string, options: UpdateCommandOptions) { + await updateTriggerPackages(dir, options); } -async function hasUserConfirmed( - packagesToUpdate: string[], - packageMaps: { [x: string]: { type: string; version: string } }, - updatedDependencies: { [x: string]: any } -): Promise { - // Inform the user of the dependencies that can be updated - console.log("\nNewer versions found for the following packages:"); - console.table( - packagesToUpdate.map((i) => ({ - name: i, - old: packageMaps[i]?.version, - new: updatedDependencies[i], - })) +export async function updateTriggerPackages( + dir: string, + options: UpdateCommandOptions, + embedded?: boolean, + requireUpdate?: boolean +) { + if (!embedded) { + intro("Updating packages"); + } + + const projectPath = resolve(process.cwd(), dir); + + const { packageJson, readonlyPackageJson, packageJsonPath } = await getPackageJson(projectPath); + + if (!packageJson) { + log.error("Failed to load package.json. Try to re-run with `-l debug` to see what's going on."); + return; + } + + const cliVersion = getVersion(); + const newCliVersion = await updateCheck(); + + if (newCliVersion) { + prettyWarning( + "You're not running the latest CLI version, please consider updating ASAP", + `Current: ${cliVersion}\nLatest: ${newCliVersion}`, + "Run latest: npx trigger.dev@beta" + ); + } + + const triggerDependencies = getTriggerDependencies(packageJson); + + function getVersionMismatches(deps: Dependency[], targetVersion: string): Dependency[] { + const mismatches: Dependency[] = []; + + for (const dep of deps) { + if (dep.version === targetVersion) { + continue; + } + + mismatches.push(dep); + } + + return mismatches; + } + + const versionMismatches = getVersionMismatches(triggerDependencies, cliVersion); + + if (versionMismatches.length === 0) { + if (!embedded) { + outro(`Nothing to do${newCliVersion ? " ..but you should really update your CLI!" : ""}`); + } + return; + } + + prettyWarning( + "Mismatch between your CLI version and installed packages", + "We recommend pinned versions for guaranteed compatibility" ); - // Ask the user if they want to update the dependencies - const shouldContinue = await confirm({ - message: "Do you want to update these packages in package.json and re-install dependencies?", - }); + if (!process.stdout.isTTY) { + // Running in CI with version mismatch detected + outro("Deploy failed"); - return shouldContinue as boolean; + console.log( + `ERROR: Version mismatch detected while running in CI. This won't end well. Aborting. + +Please run the dev command locally and check that your CLI version matches the one printed below. Additionally, all \`@trigger.dev/*\` packages also need to match this version. + +If your local CLI version doesn't match the one below, you may want to add the \`trigger.dev\` package to your dependencies. You will also have to update your workflow deploy command to \`npx trigger.dev deploy\` to ensure your pinned CLI version is used. + +CLI version: ${cliVersion} + +Current package versions that don't match the CLI: +${versionMismatches.map((dep) => `- ${dep.name}@${dep.version}`).join("\n")}\n` + ); + process.exit(1); + } + + log.message(""); // spacing + + // Always require user confirmation + const userWantsToUpdate = await updateConfirmation(versionMismatches, cliVersion); + + if (isCancel(userWantsToUpdate)) { + throw new OutroCommandError(); + } + + if (!userWantsToUpdate) { + if (requireUpdate) { + outro("You shall not pass!"); + + logger.log( + `${chalkError( + "X Error:" + )} Update required: Version mismatches are a common source of bugs and errors. Please update or use \`--skip-update-check\` at your own risk.\n` + ); + process.exit(1); + } + + if (!embedded) { + outro("You've been warned!"); + } + + return; + } + + const installSpinner = spinner(); + installSpinner.start("Writing new package.json file"); + + // Backup package.json + const packageJsonBackupPath = `${packageJsonPath}.bak`; + await writeJSONFile(packageJsonBackupPath, readonlyPackageJson, true); + + const exitHandler = async (sig: any) => { + log.warn( + `You may have to manually roll back any package.json changes. Backup written to ${packageJsonBackupPath}` + ); + }; + + // Add exit handler to warn about manual rollback of package.json + // Automatically rolling back can end up overwriting with an empty file instead + process.prependOnceListener("exit", exitHandler); + + // Update package.json + mutatePackageJsonWithUpdatedPackages(packageJson, versionMismatches, cliVersion); + await writeJSONFile(packageJsonPath, packageJson, true); + + async function revertPackageJsonChanges() { + await writeJSONFile(packageJsonPath, readonlyPackageJson, true); + await removeFile(packageJsonBackupPath); + } + + installSpinner.message("Installing new package versions"); + + const jsProject = new JavascriptProject(projectPath); + + let packageManager: PackageManager | undefined; + + try { + packageManager = await jsProject.getPackageManager(); + + installSpinner.message(`Installing new package versions with ${packageManager}`); + + await jsProject.install(); + } catch (error) { + installSpinner.stop( + `Failed to install new package versions${packageManager ? ` with ${packageManager}` : ""}` + ); + + // Remove exit handler in case of failure + process.removeListener("exit", exitHandler); + + await revertPackageJsonChanges(); + throw error; + } + + installSpinner.stop("Installed new package versions"); + + // Remove exit handler once packages have been updated, also delete backup file + process.removeListener("exit", exitHandler); + await removeFile(packageJsonBackupPath); + + if (!embedded) { + outro( + `Packages updated${newCliVersion ? " ..but you should really update your CLI too!" : ""}` + ); + } +} + +type Dependency = { + type: "dependencies" | "devDependencies"; + name: string; + version: string; +}; + +function getTriggerDependencies(packageJson: PackageJson): Dependency[] { + const deps: Dependency[] = []; + + for (const type of ["dependencies", "devDependencies"] as const) { + for (const [name, version] of Object.entries(packageJson[type] ?? {})) { + if (!version) { + continue; + } + + if (version.startsWith("workspace")) { + continue; + } + + if (!triggerPackageFilter.test(name)) { + continue; + } + + const ignoredPackages = ["@trigger.dev/companyicons"]; + + if (ignoredPackages.includes(name)) { + continue; + } + + deps.push({ type, name, version }); + } + } + + return deps; +} + +function mutatePackageJsonWithUpdatedPackages( + packageJson: PackageJson, + depsToUpdate: Dependency[], + targetVersion: string +) { + for (const { type, name, version } of depsToUpdate) { + if (!packageJson[type]) { + throw new Error( + `No ${type} entry found in package.json. Please try to upgrade manually instead.` + ); + } + + packageJson[type]![name] = targetVersion; + } +} + +function printUpdateTable(depsToUpdate: Dependency[], targetVersion: string): void { + log.message("Suggested updates"); + + const tableData = depsToUpdate.map((dep) => ({ + package: dep.name, + old: dep.version, + new: targetVersion, + })); + + logger.table(tableData); +} + +async function updateConfirmation(depsToUpdate: Dependency[], targetVersion: string) { + printUpdateTable(depsToUpdate, targetVersion); + + let confirmMessage = "Would you like to apply those updates?"; + + return await confirm({ + message: confirmMessage, + }); +} + +export async function getPackageJson(absoluteProjectPath: string) { + const packageJsonPath = join(absoluteProjectPath, "package.json"); + + const readonlyPackageJson = Object.freeze((await readJSONFile(packageJsonPath)) as PackageJson); + + const packageJson = structuredClone(readonlyPackageJson); + + return { packageJson, readonlyPackageJson, packageJsonPath }; } diff --git a/packages/cli-v3/src/utilities/assertExhaustive.ts b/packages/cli-v3/src/utilities/assertExhaustive.ts new file mode 100644 index 000000000..f934448bb --- /dev/null +++ b/packages/cli-v3/src/utilities/assertExhaustive.ts @@ -0,0 +1,3 @@ +export function assertExhaustive(x: never): never { + throw new Error("Unexpected object: " + x); +} diff --git a/packages/cli-v3/src/utilities/cliOutput.ts b/packages/cli-v3/src/utilities/cliOutput.ts index 5c8302c5d..cecee7542 100644 --- a/packages/cli-v3/src/utilities/cliOutput.ts +++ b/packages/cli-v3/src/utilities/cliOutput.ts @@ -1,3 +1,4 @@ +import { log } from "@clack/prompts"; import chalk from "chalk"; export const green = "#4FFF54"; @@ -63,3 +64,26 @@ export function prettyPrintDate(date: Date = new Date()) { return formattedDate; } + +export function prettyWarning(header: string, body?: string, footer?: string) { + const prefix = "Warning: "; + const indent = Array(prefix.length).fill(" ").join(""); + const spacing = "\n\n"; + + const prettyPrefix = chalkWarning(prefix); + + const withIndents = (text?: string) => + text + ?.split("\n") + .map((line) => `${indent}${line}`) + .join("\n"); + + const prettyBody = withIndents(body); + const prettyFooter = withIndents(footer); + + log.warn( + `${prettyPrefix}${header}${prettyBody ? `${spacing}${prettyBody}` : ""}${ + prettyFooter ? `${spacing}${prettyFooter}` : "" + }` + ); +} diff --git a/packages/cli-v3/src/utilities/fileSystem.ts b/packages/cli-v3/src/utilities/fileSystem.ts index f7dfde2b1..690540b18 100644 --- a/packages/cli-v3/src/utilities/fileSystem.ts +++ b/packages/cli-v3/src/utilities/fileSystem.ts @@ -67,8 +67,8 @@ export async function safeFeadJSONFile(path: string) { } } -export async function writeJSONFile(path: string, json: any) { - await writeFile(path, JSON.stringify(json), "utf8"); +export async function writeJSONFile(path: string, json: any, pretty = false) { + await writeFile(path, JSON.stringify(json, undefined, pretty ? 2 : undefined), "utf8"); } export function readJSONFileSync(path: string) { diff --git a/packages/cli-v3/src/utilities/getUserPackageManager.ts b/packages/cli-v3/src/utilities/getUserPackageManager.ts index acdb0e5d6..e60f20359 100644 --- a/packages/cli-v3/src/utilities/getUserPackageManager.ts +++ b/packages/cli-v3/src/utilities/getUserPackageManager.ts @@ -1,8 +1,16 @@ import { findUp } from "find-up"; +import { basename } from "path"; +import { logger } from "./logger"; export type PackageManager = "npm" | "pnpm" | "yarn"; export async function getUserPackageManager(path: string): Promise { + const packageManager = await detectPackageManager(path); + logger.debug("Detected package manager", { packageManager }); + return packageManager; +} + +async function detectPackageManager(path: string): Promise { try { return await detectPackageManagerFromArtifacts(path); } catch (error) { @@ -29,19 +37,30 @@ function detectPackageManagerFromCurrentCommand(): PackageManager { } async function detectPackageManagerFromArtifacts(path: string): Promise { - const packageFiles = [ - { name: "yarn.lock", pm: "yarn" } as const, - { name: "pnpm-lock.yaml", pm: "pnpm" } as const, - { name: "package-lock.json", pm: "npm" } as const, - { name: "npm-shrinkwrap.json", pm: "npm" } as const, - ]; + const artifacts = { + yarn: "yarn.lock", + pnpm: "pnpm-lock.yaml", + npm: "package-lock.json", + npmShrinkwrap: "npm-shrinkwrap.json", + }; - for (const { name, pm } of packageFiles) { - const foundPath = await findUp(name, { cwd: path }); - if (typeof foundPath === "string") { - return pm; - } + const foundPath = await findUp(Object.values(artifacts), { cwd: path }); + + if (!foundPath) { + throw new Error("Could not detect package manager from artifacts"); } - throw new Error("Could not detect package manager from artifacts"); + logger.debug("Found path from package manager artifacts", { foundPath }); + + switch (basename(foundPath)) { + case artifacts.yarn: + return "yarn"; + case artifacts.pnpm: + return "pnpm"; + case artifacts.npm: + case artifacts.npmShrinkwrap: + return "npm"; + default: + throw new Error(`Unhandled package manager detection path: ${foundPath}`); + } } diff --git a/packages/cli-v3/src/utilities/initialBanner.ts b/packages/cli-v3/src/utilities/initialBanner.ts index 91edc0931..821beedd9 100644 --- a/packages/cli-v3/src/utilities/initialBanner.ts +++ b/packages/cli-v3/src/utilities/initialBanner.ts @@ -8,8 +8,8 @@ import { logger } from "./logger.js"; import { spinner } from "./windows"; export async function printInitialBanner(performUpdateCheck = true) { - const packageVersion = getVersion(); - const text = `\n${logo()} ${chalkGrey(`(${packageVersion})`)}\n`; + const cliVersion = getVersion(); + const text = `\n${logo()} ${chalkGrey(`(${cliVersion})`)}\n`; logger.info(text); @@ -22,7 +22,7 @@ export async function printInitialBanner(performUpdateCheck = true) { // Log a slightly more noticeable message if this is a major bump if (maybeNewVersion !== undefined) { loadingSpinner.stop(`Update available ${chalk.green(maybeNewVersion)}`); - const currentMajor = parseInt(packageVersion.split(".")[0]!); + const currentMajor = parseInt(cliVersion.split(".")[0]!); const newMajor = parseInt(maybeNewVersion.split(".")[0]!); if (newMajor > currentMajor) { logger.warn( @@ -38,9 +38,9 @@ After installation, run Trigger.dev with \`npx trigger.dev\`.` } export async function printStandloneInitialBanner(performUpdateCheck = true) { - const packageVersion = getVersion(); + const cliVersion = getVersion(); - logger.log(`\n${logo()} ${chalkGrey("(v3 Developer Preview)")}`); + logger.log(`\n${logo()} ${chalkGrey(`(${cliVersion})`)}\n`); if (performUpdateCheck) { const maybeNewVersion = await updateCheck(); @@ -54,7 +54,11 @@ export async function printStandloneInitialBanner(performUpdateCheck = true) { logger.log(`${chalkGrey("-".repeat(54))}`); } -export function printDevBanner() { +export function printDevBanner(printTopBorder = true) { + if (printTopBorder) { + logger.log(chalkGrey("-".repeat(54))); + } + logger.log( `${chalkGrey("Key:")} ${chalkWorker("Version")} ${chalkGrey("|")} ${chalkTask( "Task" @@ -68,7 +72,7 @@ async function doUpdateCheck(): Promise { try { // default cache for update check is 1 day update = await checkForUpdate(pkg, { - distTag: pkg.version.startsWith("0.0.0") ? "beta" : "latest", + distTag: pkg.version.startsWith("3.0.0-beta") ? "beta" : "latest", }); } catch (err) { // ignore error diff --git a/packages/cli-v3/src/utilities/installDependencies.ts b/packages/cli-v3/src/utilities/installDependencies.ts deleted file mode 100644 index 8bfa0124e..000000000 --- a/packages/cli-v3/src/utilities/installDependencies.ts +++ /dev/null @@ -1,74 +0,0 @@ -import chalk from "chalk"; -import { execa } from "execa"; -import { getUserPackageManager, type PackageManager } from "./getUserPackageManager.js"; -import { logger } from "./logger.js"; -import { spinner } from "./windows.js"; - -export async function installDependencies(projectDir: string) { - logger.info("Installing dependencies..."); - - const pkgManager = await getUserPackageManager(projectDir); - - const installSpinner = await runInstallCommand(pkgManager, projectDir); - - // If the spinner was used to show the progress, use succeed method on it - // If not, use the succeed on a new spinner - (installSpinner || spinner()).stop(chalk.green("Successfully installed dependencies!\n")); -} - -async function runInstallCommand( - pkgManager: PackageManager, - projectDir: string -): Promise | null> { - switch (pkgManager) { - // When using npm, inherit the stderr stream so that the progress bar is shown - case "npm": - await execa(pkgManager, ["install"], { - cwd: projectDir, - stderr: "inherit", - }); - - return null; - // When using yarn or pnpm, use the stdout stream and ora spinner to show the progress - case "pnpm": { - const loadingSpinner = spinner(); - loadingSpinner.start("Running pnpm install..."); - const pnpmSubprocess = execa(pkgManager, ["install"], { - cwd: projectDir, - stdout: "pipe", - }); - - await new Promise((res, rej) => { - pnpmSubprocess.stdout?.on("data", (data: Buffer) => { - const text = data.toString(); - - if (text.includes("Progress")) { - loadingSpinner.message(text.includes("|") ? text.split(" | ")[1] ?? "" : text); - } - }); - pnpmSubprocess.on("error", (e) => rej(e)); - pnpmSubprocess.on("close", () => res()); - }); - - return loadingSpinner; - } - case "yarn": { - const loadingSpinner = spinner(); - loadingSpinner.start("Running yarn..."); - const yarnSubprocess = execa(pkgManager, [], { - cwd: projectDir, - stdout: "pipe", - }); - - await new Promise((res, rej) => { - yarnSubprocess.stdout?.on("data", (data: Buffer) => { - loadingSpinner.message(data.toString()); - }); - yarnSubprocess.on("error", (e) => rej(e)); - yarnSubprocess.on("close", () => res()); - }); - - return loadingSpinner; - } - } -} diff --git a/packages/cli-v3/src/utilities/installPackages.ts b/packages/cli-v3/src/utilities/installPackages.ts index aee7da506..157d1110c 100644 --- a/packages/cli-v3/src/utilities/installPackages.ts +++ b/packages/cli-v3/src/utilities/installPackages.ts @@ -25,17 +25,6 @@ export async function installPackages( ); } -async function getPackageVersion(path: string) { - try { - const packageJsonPath = join(path, "package.json"); - const packageJson = await readJSONFile(packageJsonPath); - - return packageJson.version; - } catch (error) { - return undefined; - } -} - // Expects path to be in the format: // - source-map-support/register.js // - @opentelemetry/api diff --git a/packages/cli-v3/src/utilities/javascriptProject.ts b/packages/cli-v3/src/utilities/javascriptProject.ts index 2feba1c90..36229963e 100644 --- a/packages/cli-v3/src/utilities/javascriptProject.ts +++ b/packages/cli-v3/src/utilities/javascriptProject.ts @@ -3,6 +3,8 @@ import { join } from "node:path"; import { readJSONFileSync } from "./fileSystem"; import { logger } from "./logger"; import { PackageManager, getUserPackageManager } from "./getUserPackageManager"; +import { PackageJson } from "type-fest"; +import { assertExhaustive } from "./assertExhaustive"; export type ResolveOptions = { allowDev: boolean }; @@ -49,14 +51,14 @@ const BuiltInModules = new Set([ ]); export class JavascriptProject { - private _packageJson?: any; + private _packageJson?: PackageJson; private _packageManager?: PackageManager; constructor(private projectPath: string) {} private get packageJson() { if (!this._packageJson) { - this._packageJson = readJSONFileSync(join(this.projectPath, "package.json")); + this._packageJson = readJSONFileSync(join(this.projectPath, "package.json")) as PackageJson; } return this._packageJson; @@ -64,21 +66,29 @@ export class JavascriptProject { public get scripts(): Record { return { - postinstall: this.packageJson.scripts?.postinstall, + postinstall: this.packageJson.scripts?.postinstall ?? "", }; } + async install(): Promise { + const command = await this.#getCommand(); + + try { + await command.installDependencies({ + cwd: this.projectPath, + }); + } catch (error) { + logger.debug(`Failed to install dependencies using ${command.name}`, { + error, + }); + } + } + async resolve(packageName: string, options?: ResolveOptions): Promise { if (BuiltInModules.has(packageName)) { return undefined; } - if (!this._packageManager) { - this._packageManager = await getUserPackageManager(this.projectPath); - } - - const packageManager = this._packageManager; - const opts = { allowDev: false, ...options }; const packageJsonVersion = this.packageJson.dependencies?.[packageName]; @@ -95,12 +105,7 @@ export class JavascriptProject { } } - const command = - packageManager === "npm" - ? new NPMCommands() - : packageManager === "pnpm" - ? new PNPMCommands() - : new YarnCommands(); + const command = await this.#getCommand(); try { const version = await command.resolveDependencyVersion(packageName, { @@ -117,6 +122,29 @@ export class JavascriptProject { }); } } + + async #getCommand(): Promise { + const packageManager = await this.getPackageManager(); + + switch (packageManager) { + case "npm": + return new NPMCommands(); + case "pnpm": + return new PNPMCommands(); + case "yarn": + return new YarnCommands(); + default: + assertExhaustive(packageManager); + } + } + + async getPackageManager(): Promise { + if (!this._packageManager) { + this._packageManager = await getUserPackageManager(this.projectPath); + } + + return this._packageManager; + } } type PnpmList = { @@ -140,6 +168,10 @@ type PackageManagerOptions = { }; interface PackageManagerCommands { + name: string; + + installDependencies(options: PackageManagerOptions): Promise; + resolveDependencyVersion( packageName: string, options: PackageManagerOptions @@ -151,15 +183,21 @@ class PNPMCommands implements PackageManagerCommands { return "pnpm"; } - async resolveDependencyVersion( - packageName: string, - options: PackageManagerOptions - ): Promise { - const cmd = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; - const { stdout } = await $({ cwd: options.cwd })`${cmd} list ${packageName} -r --json`; + private get cmd() { + return process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + } + + async installDependencies(options: PackageManagerOptions) { + const { stdout, stderr } = await $({ cwd: options.cwd })`${this.cmd} install`; + + logger.debug(`Installing dependencies using ${this.name}`, { stdout, stderr }); + } + + async resolveDependencyVersion(packageName: string, options: PackageManagerOptions) { + const { stdout } = await $({ cwd: options.cwd })`${this.cmd} list ${packageName} -r --json`; const result = JSON.parse(stdout) as PnpmList; - logger.debug(`Resolving ${packageName} version using pnpm`, { result }); + logger.debug(`Resolving ${packageName} version using ${this.name}`, { result }); // Return the first dependency version that matches the package name for (const dep of result) { @@ -189,15 +227,21 @@ class NPMCommands implements PackageManagerCommands { return "npm"; } - async resolveDependencyVersion( - packageName: string, - options: PackageManagerOptions - ): Promise { - const cmd = process.platform === "win32" ? "npm.cmd" : "npm"; - const { stdout } = await $({ cwd: options.cwd })`${cmd} list ${packageName} --json`; + private get cmd() { + return process.platform === "win32" ? "npm.cmd" : "npm"; + } + + async installDependencies(options: PackageManagerOptions) { + const { stdout, stderr } = await $({ cwd: options.cwd })`${this.cmd} install`; + + logger.debug(`Installing dependencies using ${this.name}`, { stdout, stderr }); + } + + async resolveDependencyVersion(packageName: string, options: PackageManagerOptions) { + const { stdout } = await $({ cwd: options.cwd })`${this.cmd} list ${packageName} --json`; const output = JSON.parse(stdout) as NpmListOutput; - logger.debug(`Resolving ${packageName} version using npm`, { output }); + logger.debug(`Resolving ${packageName} version using ${this.name}`, { output }); return this.#recursivelySearchDependencies(output.dependencies, packageName); } @@ -227,17 +271,22 @@ class YarnCommands implements PackageManagerCommands { return "yarn"; } - async resolveDependencyVersion( - packageName: string, - options: PackageManagerOptions - ): Promise { - const cmd = process.platform === "win32" ? "yarn.cmd" : "yarn"; + private get cmd() { + return process.platform === "win32" ? "yarn.cmd" : "yarn"; + } - const { stdout } = await $({ cwd: options.cwd })`${cmd} info ${packageName} --json`; + async installDependencies(options: PackageManagerOptions) { + const { stdout, stderr } = await $({ cwd: options.cwd })`${this.cmd} install`; + + logger.debug(`Installing dependencies using ${this.name}`, { stdout, stderr }); + } + + async resolveDependencyVersion(packageName: string, options: PackageManagerOptions) { + const { stdout } = await $({ cwd: options.cwd })`${this.cmd} info ${packageName} --json`; const lines = stdout.split("\n"); - logger.debug(`Resolving ${packageName} version using yarn`, { lines }); + logger.debug(`Resolving ${packageName} version using ${this.name}`, { lines }); for (const line of lines) { const json = JSON.parse(line); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b05730b2c..bc2f84282 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1561,9 +1561,6 @@ importers: node-fetch: specifier: ^3.3.0 version: 3.3.0 - npm-check-updates: - specifier: ^16.12.2 - version: 16.12.3 object-hash: specifier: ^3.0.0 version: 3.0.0