diff --git a/.changeset/four-apes-sleep.md b/.changeset/four-apes-sleep.md new file mode 100644 index 0000000000..3e88016b00 --- /dev/null +++ b/.changeset/four-apes-sleep.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/remix": patch +"@trigger.dev/cli": patch +--- + +CLI now supports multiple frameworks (starting with Next.js and Remix) diff --git a/docs/_snippets/manual-setup-remix.mdx b/docs/_snippets/manual-setup-remix.mdx index cb89f338f6..d6f7d20e52 100644 --- a/docs/_snippets/manual-setup-remix.mdx +++ b/docs/_snippets/manual-setup-remix.mdx @@ -32,50 +32,30 @@ Create a `.env` file at the root of your project and include your Trigger API ke ```bash TRIGGER_API_KEY=ENTER_YOUR_DEVELOPMENT_API_KEY_HERE -TRIGGER_API_URL=https://cloud.trigger.dev +TRIGGER_API_URL=https://api.trigger.dev # this is only necessary if you are self-hosting ``` Replace `ENTER_YOUR_DEVELOPMENT_API_KEY_HERE` with the actual API key obtained from the previous step. ## Configuring the Trigger Client -To set up the Trigger Client for your project, follow these steps: +Create a file at `/app/trigger.ts`, where `` represents the root directory of your project. -1. **Create Configuration File:** +Next, add the following code to the file which creates and exports a new `TriggerClient`: - In your project directory, create a configuration file named `trigger.ts` or `trigger.js`, depending on whether your project uses TypeScript (`.ts`) or JavaScript (`.js`). +```typescript app/trigger.(ts/js) +// trigger.ts (for TypeScript) or trigger.js (for JavaScript) -2. **Choose Directory:** +import { TriggerClient } from "@trigger.dev/sdk"; - Create the configuration file inside the **app** directory of your project. - -3. **Add Configuration Code:** - - Open the configuration file you created and add the following code: - - ```typescript app/trigger.(ts/js) - // trigger.ts (for TypeScript) or trigger.js (for JavaScript) - - import { TriggerClient } from "@trigger.dev/sdk"; - - export const client = new TriggerClient({ - id: "my-app", - apiKey: process.env.TRIGGER_API_KEY, - apiUrl: process.env.TRIGGER_API_URL, - }); - ``` - - Replace **"my-app"** with an appropriate identifier for your project. The **apiKey** and **apiUrl** are obtained from the environment variables you set earlier. - -4. **Example Directory Structure :** +export const client = new TriggerClient({ + id: "my-app", + apiKey: process.env.TRIGGER_API_KEY, + apiUrl: process.env.TRIGGER_API_URL, +}); +``` - ``` - project-root/ - ├── app/ - ├── routes/ - ├── trigger.ts - ├── other files... - ``` +Replace **"my-app"** with an appropriate identifier for your project. ## Creating the API Route diff --git a/docs/_snippets/quickstart-cli-dev.mdx b/docs/_snippets/quickstart-cli-dev.mdx new file mode 100644 index 0000000000..866a8ee4a9 --- /dev/null +++ b/docs/_snippets/quickstart-cli-dev.mdx @@ -0,0 +1,26 @@ +The CLI `dev` command allows the Trigger.dev service to send messages to your site. This is required for registering Jobs, triggering them and running tasks. To achieve this it creates a tunnel (using [ngrok](https://ngrok.com/)) so Trigger.dev can send messages to your machine. + +You should leave the `dev` command running when you're developing. + +In a **new terminal window or tab** run: + + + +```bash npm +npx @trigger.dev/cli@latest dev +``` + +```bash pnpm +pnpm dlx @trigger.dev/cli@latest dev +``` + +```bash yarn +yarn dlx @trigger.dev/cli@latest dev +``` + + +
+ + You can optionally pass the port if you're not running on the default port by adding + `--port 3001` to the end. + diff --git a/docs/_snippets/quickstart-example-job.mdx b/docs/_snippets/quickstart-example-job.mdx new file mode 100644 index 0000000000..6bf8b662f0 --- /dev/null +++ b/docs/_snippets/quickstart-example-job.mdx @@ -0,0 +1,26 @@ +```typescript +// Your first job +// This Job will be triggered by an event, log a joke to the console, and then wait 5 seconds before logging the punchline +client.defineJob({ + // This is the unique identifier for your Job, it must be unique across all Jobs in your project + id: "example-job", + name: "Example Job: a joke with a delay", + version: "0.0.1", + // This is triggered by an event using eventTrigger. You can also trigger Jobs with webhooks, on schedules, and more: https://trigger.dev/docs/documentation/concepts/triggers/introduction + trigger: eventTrigger({ + name: "example.event", + }), + run: async (payload, io, ctx) => { + // This logs a message to the console + await io.logger.info("🧪 Example Job: a joke with a delay"); + await io.logger.info("How do you comfort a JavaScript bug?"); + // This waits for 5 seconds, the second parameter is the number of seconds to wait, you can add delays of up to a year + await io.wait("Wait 5 seconds for the punchline...", 5); + await io.logger.info("You console it! 🤦"); + await io.logger.info( + "✨ Congratulations, You just ran your first successful Trigger.dev Job! ✨" + ); + // To learn how to write much more complex (and probably funnier) Jobs, check out our docs: https://trigger.dev/docs/documentation/guides/create-a-job + }, +}); +``` diff --git a/docs/_snippets/quickstart-running-your-job.mdx b/docs/_snippets/quickstart-running-your-job.mdx new file mode 100644 index 0000000000..bd18bb531f --- /dev/null +++ b/docs/_snippets/quickstart-running-your-job.mdx @@ -0,0 +1,18 @@ + + +There are two way to trigger this Job. + +1. Use the "Test" functionality in the dashboard. +2. Use the Trigger.dev API (either via our SDK or a web request) + +#### "Testing" from the dashboard + +Click into the Job and then open the "Test" tab. You should see this page: + +![Test Job](/images/test-job.png) + +This Job doesn't have a payload schema (meaning it takes an empty object), so you can simple click the "Run test" button. + +**Congratulations, you should get redirected so you can see your first Run!** + + diff --git a/docs/_snippets/quickstart-setup-steps.mdx b/docs/_snippets/quickstart-setup-steps.mdx new file mode 100644 index 0000000000..fbcf9e9c32 --- /dev/null +++ b/docs/_snippets/quickstart-setup-steps.mdx @@ -0,0 +1,80 @@ + + +You can either: + +- Use the [Trigger.dev Cloud](https://cloud.trigger.dev). +- Or [self-host](/documentation/guides/self-hosting) the service. + + + + + +Once you've created an account, follow the steps in the app to: + +1. Complete your account details. +2. Create your first Organization and Project. + + + + + +1. Go to the "Environments & API Keys" page in your project. + ![Go to the Environments & API Keys page ](/images/environments-link.png) + +2. Copy the `DEV` **SERVER** API key. + ![API Keys](/images/api-keys.png) + + + + + +The easiest way to get started it to use the CLI. It will add Trigger.dev to your existing project, setup a route and give you an example file. + +In a terminal window run: + + + +```bash npm +npx @trigger.dev/cli@latest init +``` + +```bash pnpm +pnpm dlx @trigger.dev/cli@latest init +``` + +```bash yarn +yarn dlx @trigger.dev/cli@latest init +``` + + + +It will ask you a couple of questions + +1. Are you using the [Trigger.dev Cloud](https://cloud.trigger.dev) or [self-hosting](/documentation/guides/self-hosting)? +2. Enter your development API key. Enter the key you copied earlier. + + + + + +Make sure your site is running locally, we will connect to it to register your Jobs. + +You must leave this running for the rest of the steps. + + + +```bash npm +npm run dev +``` + +```bash pnpm +pnpm run dev +``` + +```bash yarn +yarn run dev +``` + + + + diff --git a/docs/_snippets/quickstart-whats-next.mdx b/docs/_snippets/quickstart-whats-next.mdx new file mode 100644 index 0000000000..5d815504e5 --- /dev/null +++ b/docs/_snippets/quickstart-whats-next.mdx @@ -0,0 +1,20 @@ +## What's next? + + + + A Guide for how to create your first real Job + + + Learn more about how Trigger.dev works and how it can help you. + + + One of the quickest ways to learn how Trigger.dev works is to view some example Jobs. + + + Struggling getting setup or have a question? We're here to help. + + diff --git a/docs/documentation/guides/manual/nextjs.mdx b/docs/documentation/guides/manual/nextjs.mdx index 56285d2f8e..57d549a29a 100644 --- a/docs/documentation/guides/manual/nextjs.mdx +++ b/docs/documentation/guides/manual/nextjs.mdx @@ -22,19 +22,15 @@ To begin, install the necessary packages in your Next.js project directory. You ```bash npm - npm i @trigger.dev/sdk @trigger-dev/nextjs - ``` ```bash pnpm pnpm install @trigger.dev/sdk @trigger-dev/nextjs - ``` ```bash yarn yarn add @trigger.dev/sdk @trigger-dev/nextjs - ``` @@ -58,7 +54,7 @@ Create a `.env.local` file at the root of your project and include your Trigger ```bash TRIGGER_API_KEY=ENTER_YOUR_DEVELOPMENT_API_KEY_HERE -TRIGGER_API_URL=https://cloud.trigger.dev +TRIGGER_API_URL=https://api.trigger.dev # this is only necessary if you are self-hosting ``` @@ -66,59 +62,23 @@ Replace `ENTER_YOUR_DEVELOPMENT_API_KEY_HERE` with the actual API key obtained f ## Configuring the Trigger Client -To set up the Trigger Client for your project, follow these steps: - -1. **Create Configuration File:** - - In your project directory, create a configuration file named `trigger.ts` or `trigger.js`, depending on whether your project uses TypeScript (`.ts`) or JavaScript (`.js`). - -2. **Choose Directory:** - - Depending on your project structure, choose the appropriate directory for the configuration file. If your project uses a `src` directory, create the file within it. Otherwise, create it directly in the project root. - -3. **Add Configuration Code:** +Create a file at `/src/trigger.ts` or `/trigger.ts` depending on whether you're using the `src` directory or not. `` represents the root directory of your project. - Open the configuration file you created and add the following code: +Next, add the following code to the file which creates and exports a new `TriggerClient`: - ```typescript - // trigger.ts (for TypeScript) or trigger.js (for JavaScript) +```typescript src/trigger.(ts/js) +// trigger.ts (for TypeScript) or trigger.js (for JavaScript) - import { TriggerClient } from "@trigger.dev/sdk"; +import { TriggerClient } from "@trigger.dev/sdk"; - export const client = new TriggerClient({ - id: "my-app", - apiKey: process.env.TRIGGER_API_KEY, - apiUrl: process.env.TRIGGER_API_URL, - }); - ``` - - Replace **"my-app"** with an appropriate identifier for your project. The **apiKey** and **apiUrl** are obtained from the environment variables you set earlier. - -4. **File Location:** - - Depending on your project structure, save the configuration file in the appropriate location: - - - If your project uses a **src** directory, save the file within the **src** directory. - - If your project does not use a **src** directory, save the file in the project root. - - **Example Directory Structure with src:** - - ``` - project-root/ - ├── src/ - ├── trigger.ts - ├── other files... - ``` - - **Example Directory Structure without src:** - - ``` - project-root/ - ├── trigger.ts - ├── other files... - ``` +export const client = new TriggerClient({ + id: "my-app", + apiKey: process.env.TRIGGER_API_KEY, + apiUrl: process.env.TRIGGER_API_URL, +}); +``` -By following these steps, you'll configure the Trigger Client to work with your project, regardless of whether you have a separate **src** directory and whether you're using TypeScript or JavaScript files. +Replace **"my-app"** with an appropriate identifier for your project. ## Creating the API Route @@ -238,18 +198,31 @@ Your `package.json` file might look something like this: Replace **"my-app"** with the appropriate identifier you used during the step for creating the Trigger Client. -## Next Steps +## Running -Start your Next.js project locally, and then execute the `dev` CLI command to run Trigger.dev locally. You should run this command every time you want to use Trigger.dev locally. +### Run your Next.js app -![Your first Job](/images/cli-dev.gif) +Run your Next.js app locally, like you normally would. For example: - - Make sure your Next.js site is running locally before continuing. You must also leave this `dev` - terminal command running while you develop. - + -In a **new terminal window or tab** run: +```bash npm +npm run dev +``` + +```bash pnpm +pnpm run dev +``` + +```bash yarn +yarn run dev +``` + + + +### Run the CLI 'dev' command + +In a **_separate terminal window or tab_** run: @@ -271,9 +244,10 @@ yarn dlx @trigger.dev/cli@latest dev You can optionally pass the port if you're not running on 3000 by adding `--port 3001` to the end + You can optionally pass the hostname if you're not running on localhost by adding - `--hostname `. Example, in case your Next.js is running on 0.0.0.0: `--hostname 0.0.0.0`. + `--hostname `. Example, in case your Remix is running on 0.0.0.0: `--hostname 0.0.0.0`. diff --git a/docs/documentation/quickstarts/nextjs.mdx b/docs/documentation/quickstarts/nextjs.mdx index 2cefe44626..3faade9622 100644 --- a/docs/documentation/quickstarts/nextjs.mdx +++ b/docs/documentation/quickstarts/nextjs.mdx @@ -17,106 +17,12 @@ Trigger.dev works with either the Pages or App Router configuration. -## Create a Trigger.dev account + + -You can either: + -- Use the [Trigger.dev Cloud](https://cloud.trigger.dev). -- Or [self-host](/documentation/guides/self-hosting) the service. - -### Create your first project - -Once you've created an account, follow the steps in the app to: - -1. Complete your account details. -2. Create your first Organization and Project. - -### Getting an API key - -1. Go to the "Environments & API Keys" page in your project. - ![Go to the Environments & API Keys page ](/images/environments-link.png) - -2. Copy the `DEV` **SERVER** API key. - ![API Keys](/images/api-keys.png) - -## Run the CLI `init` command - -The easiest way to get started it to use the CLI. It will add Trigger.dev to your existing Next.js project, setup a route and give you an example file. - -In a terminal window run: - - - -```bash npm -npx @trigger.dev/cli@latest init -``` - -```bash pnpm -pnpm dlx @trigger.dev/cli@latest init -``` - -```bash yarn -yarn dlx @trigger.dev/cli@latest init -``` - - - -It will ask you a few questions - -1. Are you using the [Trigger.dev Cloud](https://cloud.trigger.dev) or [self-hosting](/documentation/guides/self-hosting)? -2. Enter your development API key. Enter the key you copied earlier. -3. Enter a unique ID for your endpoint (you can just use the default by hitting enter) - -## Run your Next.js site - -Make sure your Next.js site is running locally, we will connect to it to register your Jobs. - -You must leave this running for the rest of the steps. - - - -```bash npm -npm run dev -``` - -```bash pnpm -pnpm run dev -``` - -```bash yarn -yarn run dev -``` - - - -## Run the CLI `dev` command - -The CLI `dev` command allows the Trigger.dev service to send messages to your Next.js site. This is required for registering Jobs, triggering them and running tasks. To achieve this it creates a tunnel (using [ngrok](https://ngrok.com/)) so Trigger.dev can send messages to your machine. - -You should leave the `dev` command running when you're developing. - -In a **new terminal window or tab** run: - - - -```bash npm -npx @trigger.dev/cli@latest dev -``` - -```bash pnpm -pnpm dlx @trigger.dev/cli@latest dev -``` - -```bash yarn -yarn dlx @trigger.dev/cli@latest dev -``` - - -
- - You can optionally pass the port if you're not running on 3000 by adding - `--port 3001` to the end - + @@ -152,7 +58,7 @@ yarn add concurrently --dev "dev": "concurrently --kill-others npm:dev:*", "dev:next": "next dev", "dev:trigger": "npx @trigger.dev/cli dev", - ... + //... } ... ``` @@ -160,73 +66,24 @@ yarn add concurrently --dev -## Your first job +
+ + -The CLI init command created a simple Job for you. There will be a new file either `api/trigger/route.ts` or `pages/api/trigger.ts`. +The CLI init command created a simple Job for you. There will be a new file either `src/jobs/examples.(ts/js)` or `jobs/examples.(ts/js)`. In there is this Job: -```typescript -//Job definition – uses the client -client.defineJob({ - // 1. Metadata - id: "example-job", - name: "Example Job", - version: "0.0.1", - // 2. Trigger - trigger: eventTrigger({ - name: "example.event", - }), - // 3. Run function - run: async (payload, io, ctx) => { - // do something - await io.logger.info("Hello world!", { payload }); - - return { - message: "Hello world!", - }; - }, -}); -``` + If you navigate to your Trigger.dev project you will see this Job in the "Jobs" section: ![Your first Job](/images/first-job.png) -## Triggering the Job - -There are two way to trigger this Job. - -1. Use the "Test" functionality in the dashboard. -2. Use the Trigger.dev API (either via our SDK or a web request) - -### "Testing" from the dashboard - -Click into the Job and then open the "Test" tab. You should see this page: - -![Test Job](/images/test-job.png) - -This Job doesn't have a payload schema (meaning it takes an empty object), so you can simple click the "Run test" button. + -Congratulations, you should get redirected so you can see your first Run! + -## What's next? +
- - - A Guide for how to create your first real Job - - - Learn more about how Trigger.dev works and how it can help you. - - - One of the quickest ways to learn how Trigger.dev works is to view some example Jobs. - - - Struggling getting setup or have a question? We're here to help. - - + diff --git a/docs/documentation/quickstarts/remix.mdx b/docs/documentation/quickstarts/remix.mdx index a3a2bb174f..2d8cc21604 100644 --- a/docs/documentation/quickstarts/remix.mdx +++ b/docs/documentation/quickstarts/remix.mdx @@ -4,4 +4,83 @@ sidebarTitle: "Remix" description: "Start creating Jobs in 5 minutes in your Remix project." --- - +This quick start guide will get you up and running with Trigger.dev. + + +No problem, create a blank project by running the `create-remix` command in your terminal then continue with this quickstart guide as normal: + +```bash +npx create-remix@latest +``` + +Trigger.dev works with Remix v1 and v2. + + + + + + + + + + + + +You can modify your `package.json` to run both the Remix server and the CLI `dev` command together. + +1. Install the `concurrently` package: + + + +```bash npm +npm install concurrently --save-dev +``` + +```bash pnpm +pnpm install concurrently --save-dev +``` + +```bash yarn +yarn add concurrently --dev +``` + + + +2. Modify your `package.json` file's `dev` script. + +```json package.json +//... +"scripts": { + "dev": "concurrently --kill-others npm:dev:*", + //your normal remix dev command would go here + "dev:remix": "remix dev", + "dev:trigger": "npx @trigger.dev/cli dev", + //... +} +//... +``` + + + + + + + + +The CLI init command created a simple Job for you. There will be a new file either `app/jobs/example.server.(ts/js)`. + +In there is this Job: + + + +If you navigate to your Trigger.dev project you will see this Job in the "Jobs" section: + +![Your first Job](/images/first-job.png) + + + + + + + + diff --git a/packages/cli/README.md b/packages/cli/README.md index 2c828b13a5..e4cb3940e3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,10 +1,10 @@ -## ✨ @trigger.dev/cli - Initialize your Next.js project to start using Trigger.dev +## ✨ @trigger.dev/cli - Initialize your project to start using Trigger.dev -Trigger.dev is an open source platform that makes it easy to create event-driven background tasks directly your Next.js project. +Trigger.dev is an open source platform that makes it easy to create event-driven background tasks directly in your existing project. ## 💻 Usage -To initialize your Next.js project using `@trigger.dev/cli`, run any of the following three commands and answer the prompts: +To initialize your project using `@trigger.dev/cli`, run any of the following three commands and answer the prompts: ### npm diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js deleted file mode 100644 index f17734821d..0000000000 --- a/packages/cli/jest.config.js +++ /dev/null @@ -1,10 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -export default { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ - "/test/**/*.ts?(x)", - "/test/**/?(*.)+(spec|test).ts?(x)", - "/src/**/?(*.)+(spec|test).ts?(x)" - ], -}; \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json index b4ecec32e3..d1080056a9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,15 +35,13 @@ "trigger-cli": "./dist/index.js" }, "devDependencies": { - "@gmrchk/cli-testing-library": "^0.1.2", "@trigger.dev/tsconfig": "workspace:*", "@types/gradient-string": "^1.1.2", "@types/inquirer": "^9.0.3", - "@types/jest": "^29.5.3", + "@types/mock-fs": "^4.13.1", "@types/node": "16", "@types/node-fetch": "^2.6.2", "rimraf": "^3.0.2", - "ts-jest": "^29.1.1", "tsup": "^6.5.0", "type-fest": "^3.6.0", "typescript": "^4.9.5", @@ -68,6 +66,7 @@ "gradient-string": "^2.0.2", "inquirer": "^9.1.4", "localtunnel": "^2.0.2", + "mock-fs": "^5.2.0", "nanoid": "^4.0.2", "ngrok": "5.0.0-beta.2", "node-fetch": "^3.3.0", diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 66477ac146..593c4d1965 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -17,12 +17,12 @@ program.name(COMMAND_NAME).description("The Trigger.dev CLI").version("0.0.1"); program .command("init") - .description("Initialize Trigger.dev in your Next.js project") - .option("-p, --project-path ", "The path to the Next.js project", ".") + .description("Initialize Trigger.dev in your project") + .option("-p, --project-path ", "The path to the project", ".") .option("-k, --api-key ", "The development API key to use for the project.") .option( "-e, --endpoint-id ", - "The unique ID for the endpoint to use for this project. (e.g. my-nextjs-project)" + "The unique ID for the endpoint to use for this project. (e.g. my-project)" ) .option( "-t, --trigger-url ", @@ -41,11 +41,11 @@ program program .command("dev") - .description("Tunnel your local Next.js project to Trigger.dev and start running jobs") + .description("Tunnel your local project to Trigger.dev and start running jobs") .argument("[path]", "The path to the project", ".") - .option("-p, --port ", "The local port your server is on", "3000") - .option("-H, --hostname ", "Hostname on which the application is served", "localhost") - .option("-e, --env-file ", "The name of the env file to load", ".env.local") + .option("-p, --port ", "Override the local port your server is on") + .option("-H, --hostname ", "Override the hostname on which the application is served") + .option("-e, --env-file ", "Override the name of the env file to load") .option( "-i, --client-id ", "The ID of the client to use for this project. Will use the value from the package.json file if not provided." diff --git a/packages/cli/src/commands/createIntegration.ts b/packages/cli/src/commands/createIntegration.ts index f290673f5b..312daab0f9 100644 --- a/packages/cli/src/commands/createIntegration.ts +++ b/packages/cli/src/commands/createIntegration.ts @@ -207,7 +207,7 @@ export default defineConfig([ // Install the dependencies await installDependencies(resolvedPath); - logger.success(`✅ Successfully initialized ${resolvedOptions.packageName} at ${resolvedPath}`); + logger.success(`✔ Successfully initialized ${resolvedOptions.packageName} at ${resolvedPath}`); logger.info("Next steps:"); logger.info(` 1. If you generated code, double check it for errors.`); logger.info( diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 5a88680dc4..1984bb15fc 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -1,31 +1,37 @@ +import chalk from "chalk"; import childProcess from "child_process"; import chokidar from "chokidar"; import fs from "fs/promises"; import ngrok from "ngrok"; -import fetch from "../utils/fetchUseProxy"; +import { run as ncuRun } from "npm-check-updates"; import ora, { Ora } from "ora"; import pathModule from "path"; import util from "util"; import { z } from "zod"; +import { Framework, getFramework } from "../frameworks"; import { telemetryClient } from "../telemetry/telemetry"; +import { getEnvFilename } from "../utils/env"; +import fetch from "../utils/fetchUseProxy"; import { getTriggerApiDetails } from "../utils/getTriggerApiDetails"; +import { getUserPackageManager } from "../utils/getUserPkgManager"; import { logger } from "../utils/logger"; import { resolvePath } from "../utils/parseNameAndPath"; +import { RequireKeys } from "../utils/requiredKeys"; import { TriggerApi } from "../utils/triggerApi"; -import { run as ncuRun } from 'npm-check-updates' -import chalk from "chalk"; +import { standardWatchIgnoreRegex, standardWatchFilePaths } from "../frameworks/watchConfig"; const asyncExecFile = util.promisify(childProcess.execFile); export const DevCommandOptionsSchema = z.object({ - port: z.coerce.number(), - hostname: z.string(), - envFile: z.string(), + port: z.coerce.number().optional(), + hostname: z.string().optional(), + envFile: z.string().optional(), handlerPath: z.string(), clientId: z.string().optional(), }); export type DevCommandOptions = z.infer; +type ResolvedOptions = RequireKeys; const throttleTimeMs = 1000; @@ -47,7 +53,9 @@ export async function devCommand(path: string, anyOptions: any) { const options = result.data; const resolvedPath = resolvePath(path); - await checkForOutdatedPackages(resolvedPath) + + //check for outdated packages, don't await this + checkForOutdatedPackages(resolvedPath); // Read from package.json to get the endpointId const endpointId = await getEndpointIdFromPackageJson(resolvedPath, options); @@ -60,50 +68,44 @@ export async function devCommand(path: string, anyOptions: any) { } logger.success(`✔️ [trigger.dev] Detected TriggerClient id: ${endpointId}`); - // Read from .env.local or .env to get the TRIGGER_API_KEY and TRIGGER_API_URL - const apiDetails = await getTriggerApiDetails(resolvedPath, options.envFile); + //resolve the options using the detected framework (use default if there isn't a matching framework) + const packageManager = await getUserPackageManager(resolvedPath); + const framework = await getFramework(resolvedPath, packageManager); + const resolvedOptions = await resolveOptions(framework, resolvedPath, options); + // Read from .env.local or .env to get the TRIGGER_API_KEY and TRIGGER_API_URL + const apiDetails = await getTriggerApiDetails(resolvedPath, resolvedOptions.envFile); if (!apiDetails) { - telemetryClient.dev.failed("missing_api_key", options); + telemetryClient.dev.failed("missing_api_key", resolvedOptions); return; } - const { apiUrl, envFile, apiKey } = apiDetails; - logger.success(`✔️ [trigger.dev] Found API Key in ${envFile} file`); - logger.info(` [trigger.dev] Looking for Next.js site on port ${options.port}`); - - const localEndpointHandlerUrl = `http://${options.hostname}:${options.port}${options.handlerPath}`; - - try { - await fetch(localEndpointHandlerUrl, { - method: "POST", - headers: { - "x-trigger-api-key": apiKey, - "x-trigger-action": "PING", - "x-trigger-endpoint-id": endpointId, - }, - }); - } catch (err) { + //verify that the endpoint can be reached + const verifiedEndpoint = await verifyEndpoint(resolvedOptions, endpointId, apiKey, framework); + if (!verifiedEndpoint) { logger.error( - `❌ [trigger.dev] No server found on port ${options.port}. Make sure your Next.js app is running and try again.` + `✖ [trigger.dev] Failed to find a valid Trigger.dev endpoint. Make sure your app is running and try again.` ); - telemetryClient.dev.failed("no_server_found", options); + logger.info(` [trigger.dev] You can use -H to specify a hostname, or -p to specify a port.`); + telemetryClient.dev.failed("no_server_found", resolvedOptions); return; } - telemetryClient.dev.serverRunning(path, options); + const { hostname, port, handlerPath } = verifiedEndpoint; + + telemetryClient.dev.serverRunning(path, resolvedOptions); // Setup tunnel - const endpointUrl = await resolveEndpointUrl(apiUrl, options.port, options.hostname); + const endpointUrl = await resolveEndpointUrl(apiUrl, port, hostname); if (!endpointUrl) { - telemetryClient.dev.failed("failed_to_create_tunnel", options); + telemetryClient.dev.failed("failed_to_create_tunnel", resolvedOptions); return; } - const endpointHandlerUrl = `${endpointUrl}${options.handlerPath}`; - telemetryClient.dev.tunnelRunning(path, options); + const endpointHandlerUrl = `${endpointUrl}${handlerPath}`; + telemetryClient.dev.tunnelRunning(path, resolvedOptions); const connectingSpinner = ora(`[trigger.dev] Registering endpoint ${endpointHandlerUrl}...`); @@ -113,13 +115,13 @@ export async function devCommand(path: string, anyOptions: any) { const refresh = async () => { connectingSpinner.start(); - const refreshedEndpointId = await getEndpointIdFromPackageJson(resolvedPath, options); + const refreshedEndpointId = await getEndpointIdFromPackageJson(resolvedPath, resolvedOptions); - // Read from .env.local to get the TRIGGER_API_KEY and TRIGGER_API_URL + // Read from env file to get the TRIGGER_API_KEY and TRIGGER_API_URL const apiDetails = await getTriggerApiDetails(resolvedPath, envFile); if (!apiDetails) { - connectingSpinner.fail(`❌ [trigger.dev] Failed to connect: Missing API Key`); + connectingSpinner.fail(`[trigger.dev] Failed to connect: Missing API Key`); logger.info(`Will attempt again on the next file change…`); attemptCount = 0; return; @@ -131,10 +133,10 @@ export async function devCommand(path: string, anyOptions: any) { const authorizedKey = await apiClient.whoami(apiKey); if (!authorizedKey) { logger.error( - `🛑 The API key you provided is not authorized. Try visiting your dashboard to get a new API key.` + `✖ [trigger.dev] The API key you provided is not authorized. Try visiting your dashboard to get a new API key.` ); - telemetryClient.dev.failed("invalid_api_key", options); + telemetryClient.dev.failed("invalid_api_key", resolvedOptions); return; } @@ -159,18 +161,18 @@ export async function devCommand(path: string, anyOptions: any) { if (!hasConnected) { hasConnected = true; - telemetryClient.dev.connected(path, options); + telemetryClient.dev.connected(path, resolvedOptions); } } else { attemptCount++; if (attemptCount === 10 || !result.retryable) { - connectingSpinner.fail(`🚨 Failed to connect: ${result.error}`); + connectingSpinner.fail(`Failed to connect: ${result.error}`); logger.info(`Will attempt again on the next file change…`); attemptCount = 0; if (!hasConnected) { - telemetryClient.dev.failed("failed_to_connect", options); + telemetryClient.dev.failed("failed_to_connect", resolvedOptions); } return; } @@ -182,25 +184,19 @@ export async function devCommand(path: string, anyOptions: any) { } }; - // Watch for changes to .ts files and refresh endpoints - const watcher = chokidar.watch( - [ - `${resolvedPath}/**/*.ts`, - `${resolvedPath}/**/*.tsx`, - `${resolvedPath}/**/*.js`, - `${resolvedPath}/**/*.jsx`, - `${resolvedPath}/**/*.json`, - `${resolvedPath}/pnpm-lock.yaml`, - ], - { - ignored: /(node_modules|\.next)/, - //don't trigger a watch when it collects the paths - ignoreInitial: true, - } + // Watch for changes to files and refresh endpoints + const watchPaths = (framework?.watchFilePaths ?? standardWatchFilePaths).map( + (path) => `${resolvedPath}/${path}` ); + const ignored = framework?.watchIgnoreRegex ?? standardWatchIgnoreRegex; + const watcher = chokidar.watch(watchPaths, { + ignored, + //don't trigger a watch when it collects the paths + ignoreInitial: true, + }); watcher.on("all", (_event, _path) => { - // console.log(_event, _path); + console.log(_event, _path); throttle(refresh, throttleTimeMs); }); @@ -208,34 +204,128 @@ export async function devCommand(path: string, anyOptions: any) { throttle(refresh, throttleTimeMs); } -export async function checkForOutdatedPackages(path: string) { +async function resolveOptions( + framework: Framework | undefined, + path: string, + unresolvedOptions: DevCommandOptions +): Promise { + if (!framework) { + logger.info("Failed to detect framework, using default values"); + return { + port: unresolvedOptions.port ?? 3000, + hostname: unresolvedOptions.hostname ?? "localhost", + envFile: unresolvedOptions.envFile ?? ".env", + handlerPath: unresolvedOptions.handlerPath, + clientId: unresolvedOptions.clientId, + }; + } - const updates = await ncuRun({ + //get env filename + const envName = await getEnvFilename(path, framework.possibleEnvFilenames()); + + return { + port: unresolvedOptions.port, + hostname: unresolvedOptions.hostname, + envFile: unresolvedOptions.envFile ?? envName ?? ".env", + handlerPath: unresolvedOptions.handlerPath, + clientId: unresolvedOptions.clientId, + }; +} + +async function verifyEndpoint( + resolvedOptions: ResolvedOptions, + endpointId: string, + apiKey: string, + framework?: Framework +) { + //create list of hostnames to try + const hostnames = []; + if (resolvedOptions.hostname) { + hostnames.push(resolvedOptions.hostname); + } + if (framework) { + hostnames.push(...framework.defaultHostnames); + } else { + hostnames.push("localhost"); + } + + //create list of ports to try + const ports = []; + if (resolvedOptions.port) { + ports.push(resolvedOptions.port); + } + if (framework) { + ports.push(...framework.defaultPorts); + } else { + ports.push(3000); + } + + //create list of urls to try + const urls: { hostname: string; port: number }[] = []; + for (const hostname of hostnames) { + for (const port of ports) { + urls.push({ hostname, port }); + } + } + + //try each hostname + for (const url of urls) { + const { hostname, port } = url; + const localEndpointHandlerUrl = `http://${hostname}:${port}${resolvedOptions.handlerPath}`; + + const spinner = ora( + `[trigger.dev] Looking for your trigger endpoint: ${localEndpointHandlerUrl}` + ).start(); + + try { + const response = await fetch(localEndpointHandlerUrl, { + method: "POST", + headers: { + "x-trigger-api-key": apiKey, + "x-trigger-action": "PING", + "x-trigger-endpoint-id": endpointId, + }, + }); + + if (!response.ok || response.status !== 200) { + spinner.fail( + `[trigger.dev] Server responded with ${response.status} (${localEndpointHandlerUrl}).` + ); + continue; + } + + spinner.succeed(`[trigger.dev] Found your trigger endpoint: ${localEndpointHandlerUrl}`); + return { hostname, port, handlerPath: resolvedOptions.handlerPath }; + } catch (err) { + spinner.fail(`[trigger.dev] No server found (${localEndpointHandlerUrl}).`); + } + } + + return; +} + +export async function checkForOutdatedPackages(path: string) { + const updates = (await ncuRun({ packageFile: `${path}/package.json`, - filter: "/trigger.dev\/.+$/", + filter: "/trigger.dev/.+$/", upgrade: false, - }) as { + })) as { [key: string]: string; - } + }; - if (typeof updates === 'undefined' || Object.keys(updates).length === 0) { + if (typeof updates === "undefined" || Object.keys(updates).length === 0) { return; } const packageFile = await fs.readFile(`${path}/package.json`); - const data = JSON.parse(Buffer.from(packageFile).toString('utf8')); + const data = JSON.parse(Buffer.from(packageFile).toString("utf8")); const dependencies = data.dependencies; - console.log( - chalk.bgYellow('Updates available for trigger.dev packages') - ); - console.log( - chalk.bgBlue('Run npx @trigger.dev/cli@latest update') - ); + console.log(chalk.bgYellow("Updates available for trigger.dev packages")); + console.log(chalk.bgBlue("Run npx @trigger.dev/cli@latest update")); for (let dep in updates) { console.log(`${dep} ${dependencies[dep]} → ${updates[dep]}`); } - } export async function getEndpointIdFromPackageJson(path: string, options: DevCommandOptions) { @@ -256,14 +346,14 @@ export async function getEndpointIdFromPackageJson(path: string, options: DevCom async function resolveEndpointUrl(apiUrl: string, port: number, hostname: string) { const apiURL = new URL(apiUrl); - if (apiURL.hostname === "localhost") { + //if the API is localhost and the hostname is localhost + if (apiURL.hostname === "localhost" && hostname === "localhost") { return `http://${hostname}:${port}`; } // Setup tunnel const tunnelSpinner = ora(`🚇 Creating tunnel`).start(); - - const tunnelUrl = await createTunnel(port, tunnelSpinner); + const tunnelUrl = await createTunnel(hostname, port, tunnelSpinner); if (tunnelUrl) { tunnelSpinner.succeed(`🚇 Created tunnel: ${tunnelUrl}`); @@ -272,9 +362,9 @@ async function resolveEndpointUrl(apiUrl: string, port: number, hostname: string return tunnelUrl; } -async function createTunnel(port: number, spinner: Ora) { +async function createTunnel(hostname: string, port: number, spinner: Ora) { try { - return await ngrok.connect(port); + return await ngrok.connect({ addr: `${hostname}:${port}` }); } catch (error: any) { if ( typeof error.message === "string" && diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 5ca61ad30f..226d70c57b 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -3,19 +3,22 @@ import fs from "fs/promises"; import inquirer from "inquirer"; import pathModule from "path"; -import { pathToRegexp } from "path-to-regexp"; import { simpleGit } from "simple-git"; -import { parse } from "tsconfck"; -import { pathToFileURL } from "url"; import { promptApiKey, promptTriggerUrl } from "../cli/index"; import { CLOUD_API_URL, CLOUD_TRIGGER_URL, COMMAND_NAME } from "../consts"; -import { TelemetryClient, telemetryClient } from "../telemetry/telemetry"; +import { Framework, frameworkNames, getFramework } from "../frameworks"; +import { telemetryClient } from "../telemetry/telemetry"; import { addDependencies } from "../utils/addDependencies"; -import { detectNextJsProject } from "../utils/detectNextJsProject"; -import { pathExists, readJSONFile } from "../utils/fileSystem"; +import { + getEnvFilename, + setApiKeyEnvironmentVariable, + setApiUrlEnvironmentVariable, +} from "../utils/env"; +import { readJSONFile } from "../utils/fileSystem"; +import { PackageManager, getUserPackageManager } from "../utils/getUserPkgManager"; import { logger } from "../utils/logger"; import { resolvePath } from "../utils/parseNameAndPath"; -import { renderApiKey } from "../utils/renderApiKey"; +import { readPackageJson } from "../utils/readPackageJson"; import { renderTitle } from "../utils/renderTitle"; import { TriggerApi, WhoamiResponse } from "../utils/triggerApi"; @@ -44,21 +47,19 @@ export const initCommand = async (options: InitCommandOptions) => { logger.info(`✨ Initializing Trigger.dev in project`); } - // Detect if are are in a Next.js project - const isNextJsProject = await detectNextJsProject(resolvedPath); + const packageManager = await getUserPackageManager(resolvedPath); + const framework = await getFramework(resolvedPath, packageManager); - if (!isNextJsProject) { + if (!framework) { logger.error( - "We currently only support automatic setup for Next.js projects (we didn't detect one). View our manual installation guides for all frameworks: https://trigger.dev/docs/documentation/quickstarts/introduction" + `We currently only support automatic setup for ${frameworkNames()} projects (we didn't detect one). View our manual installation guides for all frameworks: https://trigger.dev/docs/documentation/quickstarts/introduction` ); - telemetryClient.init.failed("not_nextjs_project", options); + telemetryClient.init.failed("not_supported_project", options); return; - } else { - logger.success("✅ Detected Next.js project"); } + logger.success(`✔ Detected ${framework.name} project`); const hasGitChanges = await detectGitChanges(resolvedPath); - if (hasGitChanges) { // Warn the user that they have git changes logger.warn( @@ -69,11 +70,7 @@ export const initCommand = async (options: InitCommandOptions) => { const isTypescriptProject = await detectTypescriptProject(resolvedPath); telemetryClient.init.isTypescriptProject(isTypescriptProject, options); - const optionsAfterPrompts = await resolveOptionsWithPrompts( - options, - resolvedPath, - telemetryClient - ); + const optionsAfterPrompts = await resolveOptionsWithPrompts(options, resolvedPath); const apiKey = optionsAfterPrompts.apiKey; if (!apiKey) { @@ -103,61 +100,51 @@ export const initCommand = async (options: InitCommandOptions) => { const endpointSlug = authorizedKey.project.slug; const resolvedOptions: ResolvedOptions = { ...optionsAfterPrompts, endpointSlug }; - await addDependencies(resolvedPath, [ - { name: "@trigger.dev/sdk", tag: "latest" }, - { name: "@trigger.dev/nextjs", tag: "latest" }, - ]); - + //install dependencies + const dependencies = await framework.dependencies(); + await addDependencies(resolvedPath, dependencies); telemetryClient.init.addedDependencies(resolvedOptions); - // Setup environment variables - await setupEnvironmentVariables(resolvedPath, resolvedOptions); - - const usesSrcDir = await detectUseOfSrcDir(resolvedPath); - - if (usesSrcDir) { - logger.info("📁 Detected use of src directory"); + // Setup environment variables (create a file if there isn't one) + let envName = await getEnvFilename(resolvedPath, framework.possibleEnvFilenames()); + if (!envName) { + envName = framework.possibleEnvFilenames()[0]!; + const newEnvPath = pathModule.join(resolvedPath, framework.possibleEnvFilenames()[0]!); + await fs.writeFile(newEnvPath, ""); } + await setApiKeyEnvironmentVariable(resolvedPath, envName, resolvedOptions.apiKey); + await setApiUrlEnvironmentVariable(resolvedPath, envName, resolvedOptions.apiUrl); - const nextJsDir = await detectPagesOrAppDir(resolvedPath, usesSrcDir); - - const routeDir = pathModule.join(resolvedPath, usesSrcDir ? "src" : ""); + const installOptions = { + typescript: isTypescriptProject, + packageManager, + endpointSlug: resolvedOptions.endpointSlug, + }; - if (nextJsDir === "pages") { - telemetryClient.init.createFiles(resolvedOptions, "pages"); - await createTriggerPageRoute( - resolvedPath, - routeDir, - resolvedOptions, - isTypescriptProject, - usesSrcDir - ); - } else { - telemetryClient.init.createFiles(resolvedOptions, "app"); - await createTriggerAppRoute( - resolvedPath, - routeDir, - resolvedOptions, - isTypescriptProject, - usesSrcDir - ); - } + telemetryClient.init.install(resolvedOptions, framework.name, installOptions); + await framework.install(resolvedPath, installOptions); - await detectMiddlewareUsage(resolvedPath, usesSrcDir); + telemetryClient.init.postInstall(resolvedOptions, framework.name, installOptions); + await framework.postInstall(resolvedPath, installOptions); await addConfigurationToPackageJson(resolvedPath, resolvedOptions); - await printNextSteps(resolvedOptions, authorizedKey); + await printNextSteps(resolvedOptions, authorizedKey, packageManager, framework); telemetryClient.init.completed(resolvedOptions); }; -async function printNextSteps(options: ResolvedOptions, authorizedKey: WhoamiResponse) { +async function printNextSteps( + options: ResolvedOptions, + authorizedKey: WhoamiResponse, + packageManager: PackageManager, + framework: Framework +) { const projectUrl = `${options.triggerUrl}/orgs/${authorizedKey.organization.slug}/projects/${authorizedKey.project.slug}`; - logger.success(`✅ Successfully initialized Trigger.dev!`); + logger.success(`✔ Successfully initialized Trigger.dev!`); logger.info("Next steps:"); - logger.info(` 1. Run your Next.js project locally with 'npm run dev'`); + logger.info(` 1. Run your ${framework.name} project locally with '${packageManager} run dev'`); logger.info( ` 2. In a separate terminal, run 'npx @trigger.dev/cli@latest dev' to watch for changes and automatically register Trigger.dev jobs` ); @@ -169,18 +156,21 @@ async function printNextSteps(options: ResolvedOptions, authorizedKey: WhoamiRes } async function addConfigurationToPackageJson(path: string, options: ResolvedOptions) { - const pkgJsonPath = pathModule.join(path, "package.json"); - const pkgBuffer = await fs.readFile(pkgJsonPath); - const pkgJson = JSON.parse(pkgBuffer.toString()); + const pkgJson = await readPackageJson(path); + + if (!pkgJson) { + throw new Error("Could not find package.json"); + } pkgJson["trigger.dev"] = { endpointId: options.endpointSlug, }; // Write the updated package.json file + const pkgJsonPath = pathModule.join(path, "package.json"); await fs.writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); - logger.success(`✅ Wrote trigger.dev config to package.json`); + logger.success(`✔ Wrote trigger.dev config to package.json`); } type OptionsAfterPrompts = Required> & { @@ -189,8 +179,7 @@ type OptionsAfterPrompts = Required> & const resolveOptionsWithPrompts = async ( options: InitCommandOptions, - path: string, - telemetryClient: TelemetryClient + path: string ): Promise => { const resolvedOptions: InitCommandOptions = { ...options }; @@ -279,474 +268,3 @@ async function detectTypescriptProject(path: string): Promise { return false; } } - -async function detectUseOfSrcDir(path: string): Promise { - // Detects if the project is using a src directory - try { - await fs.access(pathModule.join(path, "src")); - return true; - } catch (error) { - return false; - } -} - -// Detect the use of pages or app dir in the Next.js project -// Import the next.config.js file and check for experimental: { appDir: true } -async function detectPagesOrAppDir(path: string, usesSrcDir = false): Promise<"pages" | "app"> { - const nextConfigPath = pathModule.join(path, "next.config.js"); - const importedConfig = await import(pathToFileURL(nextConfigPath).toString()).catch(() => ({})); - - if (importedConfig?.default?.experimental?.appDir) { - return "app"; - } else { - // We need to check if src/app/page.tsx exists - // Or app/page.tsx exists - // If so then we return app - // If not return pages - - const extensionsToCheck = ["jsx", "tsx", "js", "ts"]; - const basePath = pathModule.join(path, usesSrcDir ? "src" : "", "app", `page.`); - - for (const extension of extensionsToCheck) { - const appPagePath = basePath + extension; - const appPageExists = await pathExists(appPagePath); - - if (appPageExists) { - return "app"; - } - } - - return "pages"; - } -} - -async function detectMiddlewareUsage(path: string, usesSrcDir = false) { - const middlewarePath = pathModule.join(path, usesSrcDir ? "src" : "", "middleware.ts"); - - const middlewareExists = await pathExists(middlewarePath); - - if (!middlewareExists) { - return; - } - - const matcher = await getMiddlewareConfigMatcher(middlewarePath); - - if (!matcher || matcher.length === 0) { - logger.warn( - `⚠️ ⚠️ ⚠️ It looks like there might be conflicting Next.js middleware in ${pathModule.relative( - process.cwd(), - middlewarePath - )} which can cause issues with Trigger.dev. Please see https://trigger.dev/docs/documentation/guides/platforms/nextjs#middleware` - ); - - telemetryClient.init.warning("middleware_conflict", { projectPath: path }); - return; - } - - if (matcher.length === 0) { - return; - } - - if (typeof matcher === "string") { - const matcherRegex = pathToRegexp(matcher); - - // Check to see if /api/trigger matches the regex, if it does, then we need to output a warning with a link to the docs to fix it - if (matcherRegex.test("/api/trigger")) { - logger.warn( - `🚨 It looks like there might be conflicting Next.js middleware in ${pathModule.relative( - process.cwd(), - middlewarePath - )} which will cause issues with Trigger.dev. Please see https://trigger.dev/docs/documentation/guides/platforms/nextjs#middleware` - ); - telemetryClient.init.warning("middleware_conflict_api_trigger", { projectPath: path }); - } - } else if (Array.isArray(matcher) && matcher.every((m) => typeof m === "string")) { - const matcherRegexes = matcher.map((m) => pathToRegexp(m)); - - if (matcherRegexes.some((r) => r.test("/api/trigger"))) { - logger.warn( - `🚨 It looks like there might be conflicting Next.js middleware in ${pathModule.relative( - process.cwd(), - middlewarePath - )} which will cause issues with Trigger.dev. Please see https://trigger.dev/docs/documentation/guides/platforms/nextjs#middleware` - ); - telemetryClient.init.warning("middleware_conflict", { projectPath: path }); - } - } -} - -async function getMiddlewareConfigMatcher(path: string): Promise> { - const fileContent = await fs.readFile(path, "utf-8"); - - const regex = /matcher:\s*(\[.*\]|".*")/s; - let match = regex.exec(fileContent); - - if (!match) { - return []; - } - - if (match.length < 2) { - return []; - } - - let matcherString: string = match[1] as string; - - // Handle array scenario - if (matcherString.startsWith("[") && matcherString.endsWith("]")) { - matcherString = matcherString.slice(1, -1); // Remove brackets - const arrayRegex = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/g; - let arrayMatch; - const matches: string[] = []; - while ((arrayMatch = arrayRegex.exec(matcherString)) !== null) { - matches.push((arrayMatch[1] as string).slice(1, -1)); // remove quotes - } - return matches; - } else { - // Handle single string scenario - return [matcherString.slice(1, -1)]; // remove quotes - } -} - -// Find the alias that points to the "src" directory. -// So for example, the paths object could be: -// { -// "@/*": ["./src/*"] -// } -// In this case, we would return "@" -function getPathAlias(tsconfig: any, usesSrcDir: boolean) { - if (!tsconfig.compilerOptions.paths) { - return; - } - - const paths = tsconfig.compilerOptions.paths; - - const alias = Object.keys(paths).find((key) => { - const value = paths[key]; - - if (value.length !== 1) { - return false; - } - - const path = value[0]; - - if (usesSrcDir) { - return path === "./src/*"; - } else { - return path === "./*"; - } - }); - - // Make sure to remove the trailing "/*" - if (alias) { - return alias.slice(0, -2); - } - - return; -} - -async function createTriggerAppRoute( - projectPath: string, - path: string, - options: ResolvedOptions, - isTypescriptProject: boolean, - usesSrcDir = false -) { - const configFileName = isTypescriptProject ? "tsconfig.json" : "jsconfig.json"; - const tsConfigPath = pathModule.join(projectPath, configFileName); - const { tsconfig } = await parse(tsConfigPath); - - const extension = isTypescriptProject ? ".ts" : ".js"; - const triggerFileName = `trigger${extension}`; - const examplesFileName = `examples${extension}`; - const examplesIndexFileName = `index${extension}`; - const routeFileName = `route${extension}`; - - const pathAlias = getPathAlias(tsconfig, usesSrcDir); - const routePathPrefix = pathAlias ? pathAlias + "/" : "../../../"; - - const routeContent = ` -import { createAppRoute } from "@trigger.dev/nextjs"; -import { client } from "${routePathPrefix}trigger"; - - -import "${routePathPrefix}jobs"; - -//this route is used to send and receive data with Trigger.dev -export const { POST, dynamic } = createAppRoute(client); -`; - - const triggerContent = ` -import { TriggerClient } from "@trigger.dev/sdk"; - -export const client = new TriggerClient({ - id: "${options.endpointSlug}", - apiKey: process.env.TRIGGER_API_KEY, - apiUrl: process.env.TRIGGER_API_URL, -}); - `; - - const jobsPathPrefix = pathAlias ? pathAlias + "/" : "../"; - - const jobsContent = ` -import { eventTrigger } from "@trigger.dev/sdk"; -import { client } from "${jobsPathPrefix}trigger"; - -// Your first job -// This Job will be triggered by an event, log a joke to the console, and then wait 5 seconds before logging the punchline -client.defineJob({ - // This is the unique identifier for your Job, it must be unique across all Jobs in your project - id: "example-job", - name: "Example Job: a joke with a delay", - version: "0.0.1", - // This is triggered by an event using eventTrigger. You can also trigger Jobs with webhooks, on schedules, and more: https://trigger.dev/docs/documentation/concepts/triggers/introduction - trigger: eventTrigger({ - name: "example.event", - }), - run: async (payload, io, ctx) => { - // This logs a message to the console - await io.logger.info("🧪 Example Job: a joke with a delay"); - await io.logger.info("How do you comfort a JavaScript bug?"); - // This waits for 5 seconds, the second parameter is the number of seconds to wait, you can add delays of up to a year - await io.wait("Wait 5 seconds for the punchline...", 5); - await io.logger.info("You console it! 🤦"); - await io.logger.info( - "✨ Congratulations, You just ran your first successful Trigger.dev Job! ✨" - ); - // To learn how to write much more complex (and probably funnier) Jobs, check out our docs: https://trigger.dev/docs/documentation/guides/create-a-job - }, -}); - -`; - - const examplesIndexContent = ` -// import all your job files here - -export * from "./examples" -`; - - const directories = pathModule.join(path, "app", "api", "trigger"); - await fs.mkdir(directories, { recursive: true }); - - const fileExists = await pathExists(pathModule.join(directories, routeFileName)); - - if (fileExists) { - logger.info("Skipping creation of app route because it already exists"); - return; - } - - await fs.writeFile(pathModule.join(directories, routeFileName), routeContent); - - logger.success( - `✅ Created app route at ${usesSrcDir ? "src/" : ""}app/api/${removeFileExtension( - triggerFileName - )}/${routeFileName}` - ); - - const triggerFileExists = await pathExists(pathModule.join(path, triggerFileName)); - - if (!triggerFileExists) { - await fs.writeFile(pathModule.join(path, triggerFileName), triggerContent); - - logger.success(`✅ Created trigger client at ${usesSrcDir ? "src/" : ""}${triggerFileName}`); - } - - const exampleDirectories = pathModule.join(path, "jobs"); - await fs.mkdir(exampleDirectories, { recursive: true }); - - const exampleFileExists = await pathExists(pathModule.join(exampleDirectories, examplesFileName)); - - if (!exampleFileExists) { - await fs.writeFile(pathModule.join(exampleDirectories, examplesFileName), jobsContent); - - await fs.writeFile( - pathModule.join(exampleDirectories, examplesIndexFileName), - examplesIndexContent - ); - - logger.success(`✅ Created example job at ${usesSrcDir ? "src/" : ""}jobs/examples.ts`); - } -} - -async function createTriggerPageRoute( - projectPath: string, - path: string, - options: ResolvedOptions, - isTypescriptProject: boolean, - usesSrcDir = false -) { - const configFileName = isTypescriptProject ? "tsconfig.json" : "jsconfig.json"; - const tsConfigPath = pathModule.join(projectPath, configFileName); - const { tsconfig } = await parse(tsConfigPath); - - const pathAlias = getPathAlias(tsconfig, usesSrcDir); - const routePathPrefix = pathAlias ? pathAlias + "/" : "../../"; - - const extension = isTypescriptProject ? ".ts" : ".js"; - const triggerFileName = `trigger${extension}`; - const examplesFileName = `examples${extension}`; - const examplesIndexFileName = `index${extension}`; - - const routeContent = ` -import { createPagesRoute } from "@trigger.dev/nextjs"; -import { client } from "${routePathPrefix}trigger"; - -import "${routePathPrefix}jobs"; - -//this route is used to send and receive data with Trigger.dev -const { handler, config } = createPagesRoute(client); -export { config }; - -export default handler; - `; - - const triggerContent = ` -import { TriggerClient } from "@trigger.dev/sdk"; - -export const client = new TriggerClient({ - id: "${options.endpointSlug}", - apiKey: process.env.TRIGGER_API_KEY, - apiUrl: process.env.TRIGGER_API_URL, -}); - `; - - const jobsPathPrefix = pathAlias ? pathAlias + "/" : "../"; - - const jobsContent = ` -import { eventTrigger } from "@trigger.dev/sdk"; -import { client } from "${jobsPathPrefix}trigger"; - -// Your first job -// This Job will be triggered by an event, log a joke to the console, and then wait 5 seconds before logging the punchline -client.defineJob({ - // This is the unique identifier for your Job, it must be unique across all Jobs in your project - id: "example-job", - name: "Example Job: a joke with a delay", - version: "0.0.1", - // This is triggered by an event using eventTrigger. You can also trigger Jobs with webhooks, on schedules, and more: https://trigger.dev/docs/documentation/concepts/triggers/introduction - trigger: eventTrigger({ - name: "example.event", - }), - run: async (payload, io, ctx) => { - // This logs a message to the console - await io.logger.info("🧪 Example Job: a joke with a delay"); - await io.logger.info("How do you comfort a JavaScript bug?"); - // This waits for 5 seconds, the second parameter is the number of seconds to wait, you can add delays of up to a year - await io.wait("Wait 5 seconds for the punchline...", 5); - await io.logger.info("You console it! 🤦"); - await io.logger.info( - "✨ Congratulations, You just ran your first successful Trigger.dev Job! ✨" - ); - // To learn how to write much more complex (and probably funnier) Jobs, check out our docs: https://trigger.dev/docs/documentation/guides/create-a-job - }, -}); -`; - - const examplesIndexContent = ` -// import all your job files here - -export * from "./examples" - `; - - const directories = pathModule.join(path, "pages", "api"); - await fs.mkdir(directories, { recursive: true }); - - // Don't overwrite the file if it already exists - const exists = await pathExists(pathModule.join(directories, triggerFileName)); - - if (exists) { - logger.info("Skipping creation of pages route because it already exists"); - return; - } - - await fs.writeFile(pathModule.join(directories, triggerFileName), routeContent); - logger.success( - `✅ Created pages route at ${usesSrcDir ? "src/" : ""}pages/api/${triggerFileName}` - ); - - const triggerFileExists = await pathExists(pathModule.join(path, triggerFileName)); - - if (!triggerFileExists) { - await fs.writeFile(pathModule.join(path, triggerFileName), triggerContent); - - logger.success(`✅ Created TriggerClient at ${usesSrcDir ? "src/" : ""}${triggerFileName}`); - } - - const exampleDirectories = pathModule.join(path, "jobs"); - await fs.mkdir(exampleDirectories, { recursive: true }); - - const exampleFileExists = await pathExists(pathModule.join(exampleDirectories, examplesFileName)); - - if (!exampleFileExists) { - await fs.writeFile(pathModule.join(exampleDirectories, examplesFileName), jobsContent); - - await fs.writeFile( - pathModule.join(exampleDirectories, examplesIndexFileName), - examplesIndexContent - ); - - logger.success( - `✅ Created example job at ${usesSrcDir ? "src/" : ""}jobs/examples/${examplesFileName}` - ); - } -} - -async function setupEnvironmentVariables(path: string, options: ResolvedOptions) { - if (options.apiKey) { - await setupEnvironmentVariable( - path, - ".env.local", - "TRIGGER_API_KEY", - options.apiKey, - true, - renderApiKey - ); - } - - if (options.triggerUrl) { - await setupEnvironmentVariable(path, ".env.local", "TRIGGER_API_URL", options.triggerUrl, true); - } -} - -async function setupEnvironmentVariable( - dir: string, - fileName: string, - variableName: string, - value: string, - replaceValue: boolean = true, - renderer: (value: string) => string = (value) => value -) { - const path = pathModule.join(dir, fileName); - const envFileExists = await pathExists(path); - - if (!envFileExists) { - await fs.writeFile(path, ""); - } - - const envFileContent = await fs.readFile(path, "utf-8"); - - if (envFileContent.includes(variableName)) { - if (!replaceValue) { - logger.info( - `☑ Skipping setting ${variableName}=${renderer(value)} because it already exists` - ); - return; - } - // Update the existing value - const updatedEnvFileContent = envFileContent.replace( - new RegExp(`${variableName}=.*\\n`, "g"), - `${variableName}=${value}\n` - ); - - await fs.writeFile(path, updatedEnvFileContent); - - logger.success(`✅ Set ${variableName}=${renderer(value)} in ${fileName}`); - } else { - await fs.appendFile(path, `\n${variableName}=${value}`); - - logger.success(`✅ Added ${variableName}=${renderer(value)} to ${fileName}`); - } -} - -function removeFileExtension(filename: string) { - return filename.replace(/\.[^.]+$/, ""); -} diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 239f1c26b1..ff6ed559ff 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -55,7 +55,7 @@ export async function updateCommand(projectPath: string) { // If there are no @trigger.dev packages if (triggerPackages.length === 0) { - logger.success(`✅ All @trigger.dev/* packages are up to date.`); + logger.success(`✔ All @trigger.dev/* packages are up to date.`); return; } @@ -65,7 +65,7 @@ export async function updateCommand(projectPath: string) { // If no packages require any updation if (packagesToUpdate.length === 0) { - logger.success(`✅ All @trigger.dev/* packages are up to date.`); + logger.success(`✔ All @trigger.dev/* packages are up to date.`); return; } diff --git a/packages/cli/src/frameworks/index.ts b/packages/cli/src/frameworks/index.ts new file mode 100644 index 0000000000..960791d6ec --- /dev/null +++ b/packages/cli/src/frameworks/index.ts @@ -0,0 +1,65 @@ +import { InstallPackage } from "../utils/addDependencies"; +import { PackageManager } from "../utils/getUserPkgManager"; +import { NextJs } from "./nextjs"; +import { Remix } from "./remix"; + +export type ProjectInstallOptions = { + typescript: boolean; + packageManager: PackageManager; + endpointSlug: string; +}; + +export interface Framework { + /** A unique id for the framework */ + id: string; + + /** Display to the user in messages */ + name: string; + + /** Is this folder a project using this framework? */ + isMatch(path: string, packageManager: PackageManager): Promise; + + /** List of packages to install */ + dependencies(): Promise; + + /** Priority list of env filenames, e.g. ".env" */ + possibleEnvFilenames(): string[]; + + /** Install the required files */ + install(path: string, options: ProjectInstallOptions): Promise; + + /** You can check for middleware, add extra instructions, etc */ + postInstall(path: string, options: ProjectInstallOptions): Promise; + + /** Used by the dev command, if a hostname isn't passed in */ + defaultHostnames: string[]; + + /** Used by the dev command, if a port isn't passed in */ + defaultPorts: number[]; + + /** These filenames are watched for changes with the dev command, can use globs. */ + watchFilePaths: string[]; + + /** These folders are ignored when watching for changes with the dev command */ + watchIgnoreRegex: RegExp; +} + +/** The order of these matters. The first one that matches the folder will be used, so stricter ones should be first. */ +const frameworks: Framework[] = [new NextJs(), new Remix()]; + +export const getFramework = async ( + path: string, + packageManager: PackageManager +): Promise => { + for (const framework of frameworks) { + if (await framework.isMatch(path, packageManager)) { + return framework; + } + } + + return; +}; + +export function frameworkNames() { + return frameworks.map((f) => f.name).join(", "); +} diff --git a/packages/cli/src/frameworks/nextjs/index.ts b/packages/cli/src/frameworks/nextjs/index.ts new file mode 100644 index 0000000000..1ec073961d --- /dev/null +++ b/packages/cli/src/frameworks/nextjs/index.ts @@ -0,0 +1,217 @@ +import fs from "fs/promises"; +import pathModule from "path"; +import { Framework } from ".."; +import { templatesPath } from "../../paths"; +import { InstallPackage } from "../../utils/addDependencies"; +import { createFileFromTemplate } from "../../utils/createFileFromTemplate"; +import { pathExists } from "../../utils/fileSystem"; +import { PackageManager } from "../../utils/getUserPkgManager"; +import { logger } from "../../utils/logger"; +import { getPathAlias } from "../../utils/pathAlias"; +import { readPackageJson } from "../../utils/readPackageJson"; +import { detectMiddlewareUsage } from "./middleware"; +import { standardWatchFilePaths } from "../watchConfig"; + +export class NextJs implements Framework { + id = "nextjs"; + name = "Next.js"; + + async isMatch(path: string, packageManager: PackageManager): Promise { + const hasNextConfigFile = await detectNextConfigFile(path); + if (hasNextConfigFile) { + return true; + } + + return await detectNextDependency(path); + } + + async dependencies(): Promise { + return [ + { name: "@trigger.dev/sdk", tag: "latest" }, + { name: "@trigger.dev/nextjs", tag: "latest" }, + { name: "@trigger.dev/react", tag: "latest" }, + ]; + } + + possibleEnvFilenames(): string[] { + return [".env.local", ".env"]; + } + + async install( + path: string, + options: { typescript: boolean; packageManager: PackageManager; endpointSlug: string } + ): Promise { + const usesSrcDir = await detectUseOfSrcDir(path); + if (usesSrcDir) { + logger.info("📁 Detected use of src directory"); + } + + const nextJsDir = await detectPagesOrAppDir(path); + const routeDir = pathModule.join(path, usesSrcDir ? "src" : ""); + const pathAlias = await getPathAlias({ + projectPath: path, + isTypescriptProject: options.typescript, + extraDirectories: usesSrcDir ? ["src"] : undefined, + }); + + if (nextJsDir === "pages") { + await createTriggerPageRoute(routeDir, options.endpointSlug, options.typescript, pathAlias); + } else { + await createTriggerAppRoute(routeDir, options.endpointSlug, options.typescript, pathAlias); + } + } + + async postInstall( + path: string, + options: { typescript: boolean; packageManager: PackageManager; endpointSlug: string } + ): Promise { + await detectMiddlewareUsage(path); + } + + defaultHostnames = ["localhost"]; + defaultPorts = [3000, 3001, 3002]; + watchFilePaths = standardWatchFilePaths; + watchIgnoreRegex = /(node_modules|\.next)/; +} + +async function detectNextConfigFile(path: string): Promise { + return pathExists(pathModule.join(path, "next.config.js")); +} + +export async function detectNextDependency(path: string): Promise { + const packageJsonContent = await readPackageJson(path); + if (!packageJsonContent) { + return false; + } + + return packageJsonContent.dependencies?.next !== undefined; +} + +export async function detectUseOfSrcDir(path: string): Promise { + // Detects if the project is using a src directory + try { + await fs.access(pathModule.join(path, "src")); + return true; + } catch (error) { + return false; + } +} + +export async function detectPagesOrAppDir(path: string): Promise<"pages" | "app"> { + const withoutSrcAppPath = pathModule.join(path, "app"); + if (await pathExists(withoutSrcAppPath)) { + return "app"; + } + + const withSrcAppPath = pathModule.join(path, "src", "app"); + if (await pathExists(withSrcAppPath)) { + return "app"; + } + + return "pages"; +} + +async function createTriggerPageRoute( + path: string, + endpointSlug: string, + isTypescriptProject: boolean, + pathAlias: string | undefined +) { + const templatesDir = pathModule.join(templatesPath(), "nextjs"); + const fileExtension = isTypescriptProject ? ".ts" : ".js"; + + //pages/api/trigger.js or src/pages/api/trigger.js + const apiRoutePath = pathModule.join(path, "pages", "api", `trigger${fileExtension}`); + const apiRouteResult = await createFileFromTemplate({ + templatePath: pathModule.join(templatesDir, "pagesApiRoute.js"), + replacements: { + routePathPrefix: pathAlias ? pathAlias + "/" : "../../", + }, + outputPath: apiRoutePath, + }); + if (!apiRouteResult.success) { + throw new Error("Failed to create API route file"); + } + logger.success(`✔ Created API route at ${apiRoutePath}`); + + await createJobsAndTriggerFile(path, endpointSlug, fileExtension, pathAlias, templatesDir); +} + +async function createTriggerAppRoute( + path: string, + endpointSlug: string, + isTypescriptProject: boolean, + pathAlias: string | undefined +) { + const templatesDir = pathModule.join(templatesPath(), "nextjs"); + const fileExtension = isTypescriptProject ? ".ts" : ".js"; + + //app/api/trigger/route.js or src/app/api/trigger/route.js + const apiRoutePath = pathModule.join(path, "app", "api", "trigger", `route${fileExtension}`); + const apiRouteResult = await createFileFromTemplate({ + templatePath: pathModule.join(templatesDir, "appApiRoute.js"), + replacements: { + routePathPrefix: pathAlias ? pathAlias + "/" : "../../", + }, + outputPath: apiRoutePath, + }); + if (!apiRouteResult.success) { + throw new Error("Failed to create API route file"); + } + logger.success(`✔ Created API route at ${apiRoutePath}`); + + await createJobsAndTriggerFile(path, endpointSlug, fileExtension, pathAlias, templatesDir); +} + +async function createJobsAndTriggerFile( + path: string, + endpointSlug: string, + fileExtension: string, + pathAlias: string | undefined, + templatesDir: string +) { + //trigger.js or src/trigger.js + const triggerFilePath = pathModule.join(path, `trigger${fileExtension}`); + const triggerResult = await createFileFromTemplate({ + templatePath: pathModule.join(templatesDir, "trigger.js"), + replacements: { + endpointSlug, + }, + outputPath: triggerFilePath, + }); + if (!triggerResult.success) { + throw new Error("Failed to create trigger file"); + } + logger.success(`✔ Created Trigger client at ${triggerFilePath}`); + + //example jobs + const exampleDirectory = pathModule.join(path, "jobs"); + + //jobs/examples.js or src/jobs/examples.js + const exampleJobFilePath = pathModule.join(exampleDirectory, `examples${fileExtension}`); + const exampleJobResult = await createFileFromTemplate({ + templatePath: pathModule.join(templatesDir, "exampleJob.js"), + replacements: { + jobsPathPrefix: pathAlias ? pathAlias + "/" : "../", + }, + outputPath: exampleJobFilePath, + }); + if (!exampleJobResult.success) { + throw new Error("Failed to create example job file"); + } + logger.success(`✔ Created example job at ${exampleJobFilePath}`); + + //jobs/index.js or src/jobs/index.js + const jobsIndexFilePath = pathModule.join(exampleDirectory, `index${fileExtension}`); + const jobsIndexResult = await createFileFromTemplate({ + templatePath: pathModule.join(templatesDir, "jobsIndex.js"), + replacements: { + jobsPathPrefix: pathAlias ? pathAlias + "/" : "../", + }, + outputPath: jobsIndexFilePath, + }); + if (!jobsIndexResult.success) { + throw new Error("Failed to create jobs index file"); + } + logger.success(`✔ Created jobs index at ${jobsIndexFilePath}`); +} diff --git a/packages/cli/src/frameworks/nextjs/middleware.ts b/packages/cli/src/frameworks/nextjs/middleware.ts new file mode 100644 index 0000000000..9bf1fa20d8 --- /dev/null +++ b/packages/cli/src/frameworks/nextjs/middleware.ts @@ -0,0 +1,93 @@ +import fs from "fs/promises"; +import { pathExists } from "../../utils/fileSystem"; +import pathModule from "path"; +import { logger } from "../../utils/logger"; +import { telemetryClient } from "../../telemetry/telemetry"; +import { pathToRegexp } from "path-to-regexp"; + +export async function detectMiddlewareUsage(path: string, usesSrcDir = false) { + const middlewarePath = pathModule.join(path, usesSrcDir ? "src" : "", "middleware.ts"); + + const middlewareExists = await pathExists(middlewarePath); + + if (!middlewareExists) { + return; + } + + const matcher = await getMiddlewareConfigMatcher(middlewarePath); + + if (!matcher || matcher.length === 0) { + logger.warn( + `⚠️ ⚠️ ⚠️ It looks like there might be conflicting Next.js middleware in ${pathModule.relative( + process.cwd(), + middlewarePath + )} which can cause issues with Trigger.dev. Please see https://trigger.dev/docs/documentation/guides/platforms/nextjs#middleware` + ); + + telemetryClient.init.warning("middleware_conflict", { projectPath: path }); + return; + } + + if (matcher.length === 0) { + return; + } + + if (typeof matcher === "string") { + const matcherRegex = pathToRegexp(matcher); + + // Check to see if /api/trigger matches the regex, if it does, then we need to output a warning with a link to the docs to fix it + if (matcherRegex.test("/api/trigger")) { + logger.warn( + `🚨 It looks like there might be conflicting Next.js middleware in ${pathModule.relative( + process.cwd(), + middlewarePath + )} which will cause issues with Trigger.dev. Please see https://trigger.dev/docs/documentation/guides/platforms/nextjs#middleware` + ); + telemetryClient.init.warning("middleware_conflict_api_trigger", { projectPath: path }); + } + } else if (Array.isArray(matcher) && matcher.every((m) => typeof m === "string")) { + const matcherRegexes = matcher.map((m) => pathToRegexp(m)); + + if (matcherRegexes.some((r) => r.test("/api/trigger"))) { + logger.warn( + `🚨 It looks like there might be conflicting Next.js middleware in ${pathModule.relative( + process.cwd(), + middlewarePath + )} which will cause issues with Trigger.dev. Please see https://trigger.dev/docs/documentation/guides/platforms/nextjs#middleware` + ); + telemetryClient.init.warning("middleware_conflict", { projectPath: path }); + } + } +} + +async function getMiddlewareConfigMatcher(path: string): Promise> { + const fileContent = await fs.readFile(path, "utf-8"); + + const regex = /matcher:\s*(\[.*\]|".*")/s; + let match = regex.exec(fileContent); + + if (!match) { + return []; + } + + if (match.length < 2) { + return []; + } + + let matcherString: string = match[1] as string; + + // Handle array scenario + if (matcherString.startsWith("[") && matcherString.endsWith("]")) { + matcherString = matcherString.slice(1, -1); // Remove brackets + const arrayRegex = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/g; + let arrayMatch; + const matches: string[] = []; + while ((arrayMatch = arrayRegex.exec(matcherString)) !== null) { + matches.push((arrayMatch[1] as string).slice(1, -1)); // remove quotes + } + return matches; + } else { + // Handle single string scenario + return [matcherString.slice(1, -1)]; // remove quotes + } +} diff --git a/packages/cli/src/frameworks/nextjs/nextjs.test.ts b/packages/cli/src/frameworks/nextjs/nextjs.test.ts new file mode 100644 index 0000000000..63f192eb7d --- /dev/null +++ b/packages/cli/src/frameworks/nextjs/nextjs.test.ts @@ -0,0 +1,240 @@ +import mock from "mock-fs"; +import { NextJs, detectPagesOrAppDir, detectUseOfSrcDir } from "."; +import { getFramework } from ".."; +import { pathExists } from "../../utils/fileSystem"; + +afterEach(() => { + mock.restore(); +}); + +describe("Next project detection", () => { + test("has dependency", async () => { + mock({ + "package.json": JSON.stringify({ dependencies: { next: "1.0.0" } }), + }); + + const framework = await getFramework("", "npm"); + expect(framework?.id).toEqual("nextjs"); + }); + + test("no dependency, has next.config.js", async () => { + mock({ + "package.json": JSON.stringify({ dependencies: { foo: "1.0.0" } }), + "next.config.js": "module.exports = {}", + }); + + const framework = await getFramework("", "npm"); + expect(framework?.id).toEqual("nextjs"); + }); + + test("no dependency, no next.config.js", async () => { + mock({ + "package.json": JSON.stringify({ dependencies: { foo: "1.0.0" } }), + }); + + const framework = await getFramework("", "npm"); + expect(framework?.id).not.toEqual("nextjs"); + }); +}); + +describe("src directory", () => { + test("has src directory", async () => { + mock({ + src: { + "some-file.txt": "file content here", + }, + }); + + const hasSrcDirectory = await detectUseOfSrcDir(""); + expect(hasSrcDirectory).toEqual(true); + }); + + test("no src directory", async () => { + mock({ + app: { + "some-file.txt": "file content here", + }, + }); + + const hasSrcDirectory = await detectUseOfSrcDir(""); + expect(hasSrcDirectory).toEqual(false); + }); +}); + +describe("detect pages or app directory", () => { + test("detect 'app' from src/app directory", async () => { + mock({ + "src/app": {}, + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("app"); + }); + + test("detect 'app' from src/app directory", async () => { + mock({ + "src/app": {}, + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("app"); + }); + + test("detect 'pages' from src/pages directory", async () => { + mock({ + "src/pages": { + "some-file.txt": "file content here", + }, + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("pages"); + }); + + test("detect 'pages' from pages directory", async () => { + mock({ + pages: { + "some-file.txt": "file content here", + }, + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("pages"); + }); +}); + +describe("pages install", () => { + test("src/pages + javascript", async () => { + mock({ + "src/pages": {}, + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("pages"); + + const nextJs = new NextJs(); + await nextJs.install("", { typescript: false, packageManager: "npm", endpointSlug: "foo" }); + expect(await pathExists("src/trigger.js")).toEqual(true); + expect(await pathExists("src/pages/api/trigger.js")).toEqual(true); + expect(await pathExists("src/jobs/index.js")).toEqual(true); + expect(await pathExists("src/jobs/examples.js")).toEqual(true); + }); + + test("pages + javascript", async () => { + mock({ + pages: {}, + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("pages"); + + const nextJs = new NextJs(); + await nextJs.install("", { typescript: false, packageManager: "npm", endpointSlug: "foo" }); + expect(await pathExists("trigger.js")).toEqual(true); + expect(await pathExists("pages/api/trigger.js")).toEqual(true); + expect(await pathExists("jobs/index.js")).toEqual(true); + expect(await pathExists("jobs/examples.js")).toEqual(true); + }); + + test("src/pages + typescript", async () => { + mock({ + "src/pages": {}, + "tsconfig.json": JSON.stringify({}), + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("pages"); + + const nextJs = new NextJs(); + await nextJs.install("", { typescript: true, packageManager: "npm", endpointSlug: "foo" }); + expect(await pathExists("src/trigger.ts")).toEqual(true); + expect(await pathExists("src/pages/api/trigger.ts")).toEqual(true); + expect(await pathExists("src/jobs/index.ts")).toEqual(true); + expect(await pathExists("src/jobs/examples.ts")).toEqual(true); + }); + + test("pages + typescript", async () => { + mock({ + pages: {}, + "tsconfig.json": JSON.stringify({}), + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("pages"); + + const nextJs = new NextJs(); + await nextJs.install("", { typescript: true, packageManager: "npm", endpointSlug: "foo" }); + expect(await pathExists("trigger.ts")).toEqual(true); + expect(await pathExists("pages/api/trigger.ts")).toEqual(true); + expect(await pathExists("jobs/index.ts")).toEqual(true); + expect(await pathExists("jobs/examples.ts")).toEqual(true); + }); +}); + +describe("app install", () => { + test("src/app + javascript", async () => { + mock({ + "src/app": {}, + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("app"); + + const nextJs = new NextJs(); + await nextJs.install("", { typescript: false, packageManager: "npm", endpointSlug: "foo" }); + expect(await pathExists("src/trigger.js")).toEqual(true); + expect(await pathExists("src/app/api/trigger/route.js")).toEqual(true); + expect(await pathExists("src/jobs/index.js")).toEqual(true); + expect(await pathExists("src/jobs/examples.js")).toEqual(true); + }); + + test("app + javascript", async () => { + mock({ + app: {}, + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("app"); + + const nextJs = new NextJs(); + await nextJs.install("", { typescript: false, packageManager: "npm", endpointSlug: "foo" }); + expect(await pathExists("trigger.js")).toEqual(true); + expect(await pathExists("app/api/trigger/route.js")).toEqual(true); + expect(await pathExists("jobs/index.js")).toEqual(true); + expect(await pathExists("jobs/examples.js")).toEqual(true); + }); + + test("src/app + typescript", async () => { + mock({ + "src/app": {}, + "tsconfig.json": JSON.stringify({}), + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("app"); + + const nextJs = new NextJs(); + await nextJs.install("", { typescript: true, packageManager: "npm", endpointSlug: "foo" }); + expect(await pathExists("src/trigger.ts")).toEqual(true); + expect(await pathExists("src/app/api/trigger/route.ts")).toEqual(true); + expect(await pathExists("src/jobs/index.ts")).toEqual(true); + expect(await pathExists("src/jobs/examples.ts")).toEqual(true); + }); + + test("app + typescript", async () => { + mock({ + app: {}, + "tsconfig.json": JSON.stringify({}), + }); + + const projectType = await detectPagesOrAppDir(""); + expect(projectType).toEqual("app"); + + const nextJs = new NextJs(); + await nextJs.install("", { typescript: true, packageManager: "npm", endpointSlug: "foo" }); + expect(await pathExists("trigger.ts")).toEqual(true); + expect(await pathExists("app/api/trigger/route.ts")).toEqual(true); + expect(await pathExists("jobs/index.ts")).toEqual(true); + expect(await pathExists("jobs/examples.ts")).toEqual(true); + }); +}); diff --git a/packages/cli/src/frameworks/remix/index.ts b/packages/cli/src/frameworks/remix/index.ts new file mode 100644 index 0000000000..0b9a04e327 --- /dev/null +++ b/packages/cli/src/frameworks/remix/index.ts @@ -0,0 +1,110 @@ +import { Framework, ProjectInstallOptions } from ".."; +import { InstallPackage } from "../../utils/addDependencies"; +import { pathExists } from "../../utils/fileSystem"; +import { PackageManager } from "../../utils/getUserPkgManager"; +import pathModule from "path"; +import { getPathAlias } from "../../utils/pathAlias"; +import { createFileFromTemplate } from "../../utils/createFileFromTemplate"; +import { templatesPath } from "../../paths"; +import { logger } from "../../utils/logger"; +import { readPackageJson } from "../../utils/readPackageJson"; +import { standardWatchFilePaths } from "../watchConfig"; + +export class Remix implements Framework { + id = "remix"; + name = "Remix"; + + async isMatch(path: string, packageManager: PackageManager): Promise { + //check for remix.config.js + const hasConfigFile = await pathExists(pathModule.join(path, "remix.config.js")); + if (hasConfigFile) { + return true; + } + + //check for any packages starting with @remix-run + const packageJsonContent = await readPackageJson(path); + if (!packageJsonContent) { + return false; + } + + const keys = Object.keys(packageJsonContent.dependencies || {}); + const dependencyWithRemix = keys.find((key) => key.startsWith("@remix-run")); + if (dependencyWithRemix) { + return true; + } + + return false; + } + + async dependencies(): Promise { + return [ + { name: "@trigger.dev/sdk", tag: "latest" }, + { name: "@trigger.dev/remix", tag: "latest" }, + { name: "@trigger.dev/react", tag: "latest" }, + ]; + } + + possibleEnvFilenames(): string[] { + return [".env"]; + } + + async install(path: string, { typescript, endpointSlug }: ProjectInstallOptions): Promise { + const pathAlias = await getPathAlias({ + projectPath: path, + isTypescriptProject: typescript, + extraDirectories: ["app"], + }); + const templatesDir = pathModule.join(templatesPath(), "remix"); + const appFolder = pathModule.join(path, "app"); + const fileExtension = typescript ? ".ts" : ".js"; + + //create app/api.trigger.js + const apiRoutePath = pathModule.join(appFolder, "routes", `api.trigger${fileExtension}`); + const apiRouteResult = await createFileFromTemplate({ + templatePath: pathModule.join(templatesDir, "apiRoute.js"), + replacements: { + routePathPrefix: pathAlias ? pathAlias + "/" : "../../", + }, + outputPath: apiRoutePath, + }); + if (!apiRouteResult.success) { + throw new Error("Failed to create API route file"); + } + logger.success(`✔ Created API route at ${apiRoutePath}`); + + //app/trigger.server.js + const triggerFilePath = pathModule.join(appFolder, `trigger.server${fileExtension}`); + const triggerResult = await createFileFromTemplate({ + templatePath: pathModule.join(templatesDir, "trigger.js"), + replacements: { + endpointSlug, + }, + outputPath: triggerFilePath, + }); + if (!triggerResult.success) { + throw new Error("Failed to create trigger file"); + } + logger.success(`✔ Created Trigger client at ${triggerFilePath}`); + + //app/jobs/example.server.js + const exampleJobFilePath = pathModule.join(appFolder, "jobs", `example.server${fileExtension}`); + const exampleJobResult = await createFileFromTemplate({ + templatePath: pathModule.join(templatesDir, "exampleJob.js"), + replacements: { + jobsPathPrefix: pathAlias ? pathAlias + "/" : "../", + }, + outputPath: exampleJobFilePath, + }); + if (!exampleJobResult.success) { + throw new Error("Failed to create example job file"); + } + logger.success(`✔ Created example job at ${exampleJobFilePath}`); + } + + async postInstall(path: string, options: ProjectInstallOptions): Promise {} + + defaultHostnames = ["localhost"]; + defaultPorts = [3000, 8788, 3333]; + watchFilePaths = standardWatchFilePaths; + watchIgnoreRegex = /(node_modules|build)/; +} diff --git a/packages/cli/src/frameworks/remix/remix.test.ts b/packages/cli/src/frameworks/remix/remix.test.ts new file mode 100644 index 0000000000..bbee3e3750 --- /dev/null +++ b/packages/cli/src/frameworks/remix/remix.test.ts @@ -0,0 +1,69 @@ +import mock from "mock-fs"; +import { Remix } from "."; +import { getFramework } from ".."; +import { pathExists } from "../../utils/fileSystem"; + +afterEach(() => { + mock.restore(); +}); + +describe("Remix project detection", () => { + test("has dependency", async () => { + mock({ + "package.json": JSON.stringify({ dependencies: { "@remix-run/express": "1.0.0" } }), + }); + + const framework = await getFramework("", "npm"); + expect(framework?.id).toEqual("remix"); + }); + + test("no dependency, has remix.config.js", async () => { + mock({ + "package.json": JSON.stringify({ dependencies: { foo: "1.0.0" } }), + "remix.config.js": "module.exports = {}", + }); + + const framework = await getFramework("", "npm"); + expect(framework?.id).toEqual("remix"); + }); + + test("no dependency, no remix.config.js", async () => { + mock({ + "package.json": JSON.stringify({ dependencies: { foo: "1.0.0" } }), + }); + + const framework = await getFramework("", "npm"); + expect(framework?.id).not.toEqual("remix"); + }); +}); + +describe("install", () => { + test("javascript", async () => { + mock({ + app: { + routes: {}, + }, + }); + + const remix = new Remix(); + await remix.install("", { typescript: false, packageManager: "npm", endpointSlug: "foo" }); + expect(await pathExists("app/trigger.server.js")).toEqual(true); + expect(await pathExists("app/routes/api.trigger.js")).toEqual(true); + expect(await pathExists("app/jobs/example.server.js")).toEqual(true); + }); + + test("typescript", async () => { + mock({ + app: { + routes: {}, + }, + "tsconfig.json": JSON.stringify({}), + }); + + const remix = new Remix(); + await remix.install("", { typescript: true, packageManager: "npm", endpointSlug: "foo" }); + expect(await pathExists("app/trigger.server.ts")).toEqual(true); + expect(await pathExists("app/routes/api.trigger.ts")).toEqual(true); + expect(await pathExists("app/jobs/example.server.ts")).toEqual(true); + }); +}); diff --git a/packages/cli/src/frameworks/watchConfig.ts b/packages/cli/src/frameworks/watchConfig.ts new file mode 100644 index 0000000000..51524fb0b7 --- /dev/null +++ b/packages/cli/src/frameworks/watchConfig.ts @@ -0,0 +1,10 @@ +export const standardWatchFilePaths = [ + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + "**/*.json", + "pnpm-lock.yaml", +]; + +export const standardWatchIgnoreRegex = /(node_modules)/; diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts new file mode 100644 index 0000000000..64dd8c90f0 --- /dev/null +++ b/packages/cli/src/paths.ts @@ -0,0 +1,14 @@ +import path from "path"; +import { fileURLToPath } from "url"; + +export function rootPath() { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + return __dirname; +} + +export function templatesPath() { + const root = rootPath(); + const templatePath = path.join(root, "templates"); + return templatePath; +} diff --git a/packages/cli/src/telemetry/telemetry.ts b/packages/cli/src/telemetry/telemetry.ts index fd2f3e6603..6ff8d5bc80 100644 --- a/packages/cli/src/telemetry/telemetry.ts +++ b/packages/cli/src/telemetry/telemetry.ts @@ -3,6 +3,7 @@ import { InitCommandOptions } from "../commands/init"; import { nanoid } from "nanoid"; import { getVersion } from "../utils/getVersion"; import { DevCommandOptions } from "../commands/dev"; +import { ProjectInstallOptions } from "../frameworks"; const postHogApiKey = "phc_hwYmedO564b3Ik8nhA4Csrb5SueY0EwFJWCbseGwWW"; @@ -82,11 +83,26 @@ export class TelemetryClient { properties: this.#initProperties(options), }); }, - createFiles: (options: InitCommandOptions, routerType: "pages" | "app") => { + install: ( + options: InitCommandOptions, + framework: string, + installOptions: ProjectInstallOptions + ) => { + this.#client.capture({ + distinctId: this.#sessionId, + event: "cli_init_install", + properties: { ...this.#initProperties(options), framework, ...installOptions }, + }); + }, + postInstall: ( + options: InitCommandOptions, + framework: string, + installOptions: ProjectInstallOptions + ) => { this.#client.capture({ distinctId: this.#sessionId, - event: "cli_init_create_files", - properties: { ...this.#initProperties(options), routerType }, + event: "cli_init_post_install", + properties: { ...this.#initProperties(options), framework, ...installOptions }, }); }, completed: (options: InitCommandOptions) => { diff --git a/packages/cli/src/templates/nextjs/appApiRoute.js b/packages/cli/src/templates/nextjs/appApiRoute.js new file mode 100644 index 0000000000..546336103f --- /dev/null +++ b/packages/cli/src/templates/nextjs/appApiRoute.js @@ -0,0 +1,7 @@ +import { createAppRoute } from "@trigger.dev/nextjs"; +import { client } from "${routePathPrefix}trigger"; + +import "${routePathPrefix}jobs"; + +//this route is used to send and receive data with Trigger.dev +export const { POST, dynamic } = createAppRoute(client); diff --git a/packages/cli/src/templates/nextjs/exampleJob.js b/packages/cli/src/templates/nextjs/exampleJob.js new file mode 100644 index 0000000000..ff0fbdc800 --- /dev/null +++ b/packages/cli/src/templates/nextjs/exampleJob.js @@ -0,0 +1,27 @@ +import { eventTrigger } from "@trigger.dev/sdk"; +import { client } from "${jobsPathPrefix}trigger"; + +// Your first job +// This Job will be triggered by an event, log a joke to the console, and then wait 5 seconds before logging the punchline +client.defineJob({ + // This is the unique identifier for your Job, it must be unique across all Jobs in your project + id: "example-job", + name: "Example Job: a joke with a delay", + version: "0.0.1", + // This is triggered by an event using eventTrigger. You can also trigger Jobs with webhooks, on schedules, and more: https://trigger.dev/docs/documentation/concepts/triggers/introduction + trigger: eventTrigger({ + name: "example.event", + }), + run: async (payload, io, ctx) => { + // This logs a message to the console + await io.logger.info("🧪 Example Job: a joke with a delay"); + await io.logger.info("How do you comfort a JavaScript bug?"); + // This waits for 5 seconds, the second parameter is the number of seconds to wait, you can add delays of up to a year + await io.wait("Wait 5 seconds for the punchline...", 5); + await io.logger.info("You console it! 🤦"); + await io.logger.info( + "✨ Congratulations, You just ran your first successful Trigger.dev Job! ✨" + ); + // To learn how to write much more complex (and probably funnier) Jobs, check out our docs: https://trigger.dev/docs/documentation/guides/create-a-job + }, +}); diff --git a/packages/cli/src/templates/nextjs/jobsIndex.js b/packages/cli/src/templates/nextjs/jobsIndex.js new file mode 100644 index 0000000000..042a4e5828 --- /dev/null +++ b/packages/cli/src/templates/nextjs/jobsIndex.js @@ -0,0 +1,3 @@ +// export all your job files here + +export * from "./examples"; diff --git a/packages/cli/src/templates/nextjs/pagesApiRoute.js b/packages/cli/src/templates/nextjs/pagesApiRoute.js new file mode 100644 index 0000000000..213055b828 --- /dev/null +++ b/packages/cli/src/templates/nextjs/pagesApiRoute.js @@ -0,0 +1,10 @@ +import { createPagesRoute } from "@trigger.dev/nextjs"; +import { client } from "${routePathPrefix}trigger"; + +import "${routePathPrefix}jobs"; + +//this route is used to send and receive data with Trigger.dev +const { handler, config } = createPagesRoute(client); +export { config }; + +export default handler; diff --git a/packages/cli/src/templates/nextjs/trigger.js b/packages/cli/src/templates/nextjs/trigger.js new file mode 100644 index 0000000000..578cfb089b --- /dev/null +++ b/packages/cli/src/templates/nextjs/trigger.js @@ -0,0 +1,7 @@ +import { TriggerClient } from "@trigger.dev/sdk"; + +export const client = new TriggerClient({ + id: "${endpointSlug}", + apiKey: process.env.TRIGGER_API_KEY, + apiUrl: process.env.TRIGGER_API_URL, +}); diff --git a/packages/cli/src/templates/remix/apiRoute.js b/packages/cli/src/templates/remix/apiRoute.js new file mode 100644 index 0000000000..d0f6d2a92a --- /dev/null +++ b/packages/cli/src/templates/remix/apiRoute.js @@ -0,0 +1,8 @@ +import { createRemixRoute } from "@trigger.dev/remix"; +import { client } from "${routePathPrefix}trigger.server"; + +// Remix will automatically strip files with side effects +// So you need to *export* your Job definitions like this: +export * from "${routePathPrefix}jobs/example.server"; + +export const { action } = createRemixRoute(client); diff --git a/packages/cli/src/templates/remix/exampleJob.js b/packages/cli/src/templates/remix/exampleJob.js new file mode 100644 index 0000000000..affba011fd --- /dev/null +++ b/packages/cli/src/templates/remix/exampleJob.js @@ -0,0 +1,27 @@ +import { eventTrigger } from "@trigger.dev/sdk"; +import { client } from "${jobsPathPrefix}trigger.server"; + +// Your first job +// This Job will be triggered by an event, log a joke to the console, and then wait 5 seconds before logging the punchline +export const job = client.defineJob({ + // This is the unique identifier for your Job, it must be unique across all Jobs in your project + id: "example-job", + name: "Example Job: a joke with a delay", + version: "0.0.1", + // This is triggered by an event using eventTrigger. You can also trigger Jobs with webhooks, on schedules, and more: https://trigger.dev/docs/documentation/concepts/triggers/introduction + trigger: eventTrigger({ + name: "example.event", + }), + run: async (payload, io, ctx) => { + // This logs a message to the console + await io.logger.info("🧪 Example Job: a joke with a delay"); + await io.logger.info("How do you comfort a JavaScript bug?"); + // This waits for 5 seconds, the second parameter is the number of seconds to wait, you can add delays of up to a year + await io.wait("Wait 5 seconds for the punchline...", 5); + await io.logger.info("You console it! 🤦"); + await io.logger.info( + "✨ Congratulations, You just ran your first successful Trigger.dev Job! ✨" + ); + // To learn how to write much more complex (and probably funnier) Jobs, check out our docs: https://trigger.dev/docs/documentation/guides/create-a-job + }, +}); diff --git a/packages/cli/src/templates/remix/trigger.js b/packages/cli/src/templates/remix/trigger.js new file mode 100644 index 0000000000..578cfb089b --- /dev/null +++ b/packages/cli/src/templates/remix/trigger.js @@ -0,0 +1,7 @@ +import { TriggerClient } from "@trigger.dev/sdk"; + +export const client = new TriggerClient({ + id: "${endpointSlug}", + apiKey: process.env.TRIGGER_API_KEY, + apiUrl: process.env.TRIGGER_API_URL, +}); diff --git a/packages/cli/src/utils/createFileFromTemplate.test.ts b/packages/cli/src/utils/createFileFromTemplate.test.ts new file mode 100644 index 0000000000..464b1f3b13 --- /dev/null +++ b/packages/cli/src/utils/createFileFromTemplate.test.ts @@ -0,0 +1,59 @@ +import fs from "fs/promises"; +import mock from "mock-fs"; +import path from "path"; +import { createFileFromTemplate, replaceAll } from "./createFileFromTemplate"; + +afterEach(() => { + mock.restore(); +}); + +const preReplacement = `import { createPagesRoute } from "@trigger.dev/nextjs"; +import { client } from "\${routePathPrefix}trigger"; +import { other } from "\${routePathPrefix}trigger"; +import "\${anotherPathPrefix}jobs"; + +const { handler, config } = createPagesRoute(client);`; + +const postReplacement = `import { createPagesRoute } from "@trigger.dev/nextjs"; +import { client } from "@/trigger"; +import { other } from "@/trigger"; +import "@/src/jobs"; + +const { handler, config } = createPagesRoute(client);`; + +describe("Replace function", () => { + test("simple replacements", async () => { + const output = replaceAll(preReplacement, { + routePathPrefix: "@/", + anotherPathPrefix: "@/src/", + }); + expect(output).toEqual(postReplacement); + }); +}); + +describe("Template files", () => { + test("basic template", async () => { + mock({ + templates: { + "some-file.js": preReplacement, + }, + }); + + const result = await createFileFromTemplate({ + templatePath: path.join("templates", "some-file.js"), + replacements: { + routePathPrefix: "@/", + anotherPathPrefix: "@/src/", + }, + outputPath: "foo/output.ts", + mockTemplatePath: true, + }); + + expect(result.success).toEqual(true); + if (!result.success) return; + expect(result.alreadyExisted).toEqual(false); + + const fileContents = await fs.readFile("foo/output.ts", "utf-8"); + expect(fileContents).toEqual(postReplacement); + }); +}); diff --git a/packages/cli/src/utils/createFileFromTemplate.ts b/packages/cli/src/utils/createFileFromTemplate.ts new file mode 100644 index 0000000000..6a106456c9 --- /dev/null +++ b/packages/cli/src/utils/createFileFromTemplate.ts @@ -0,0 +1,68 @@ +import fs from "fs/promises"; +import { pathExists } from "./fileSystem"; +import path from "path"; +import { readFileIgnoringMock } from "./readFileIgnoringMock"; + +type Result = + | { + success: true; + alreadyExisted: boolean; + } + | { + success: false; + error: string; + }; + +export async function createFileFromTemplate(params: { + templatePath: string; + replacements: Record; + outputPath: string; + mockTemplatePath?: boolean; +}): Promise { + let template = ""; + if (params.mockTemplatePath === true) { + template = await fs.readFile(params.templatePath, "utf-8"); + } else { + template = await readFileIgnoringMock(params.templatePath); + } + + if (await pathExists(params.outputPath)) { + return { + success: true, + alreadyExisted: true, + }; + } + + try { + const output = replaceAll(template, params.replacements); + + const directoryName = path.dirname(params.outputPath); + await fs.mkdir(directoryName, { recursive: true }); + await fs.writeFile(params.outputPath, output); + + return { + success: true, + alreadyExisted: false, + }; + } catch (e) { + if (e instanceof Error) { + return { + success: false, + error: e.message, + }; + } + return { + success: false, + error: JSON.stringify(e), + }; + } +} + +// find strings that match ${varName} and replace with the value from a Record where { varName: "value" } +export function replaceAll(input: string, replacements: Record) { + let output = input; + for (const [key, value] of Object.entries(replacements)) { + output = output.replace(new RegExp(`\\$\\{${key}\\}`, "g"), value); + } + return output; +} diff --git a/packages/cli/src/utils/detectNextJsProject.ts b/packages/cli/src/utils/detectNextJsProject.ts deleted file mode 100644 index 664501f874..0000000000 --- a/packages/cli/src/utils/detectNextJsProject.ts +++ /dev/null @@ -1,29 +0,0 @@ -import fs from "fs/promises"; -import pathModule from "path"; -import { readPackageJson } from "./readPackageJson"; - -/** Detects if the project is a Next.js project at path */ -export async function detectNextJsProject(path: string): Promise { - const hasNextConfigFile = await detectNextConfigFile(path); - if (hasNextConfigFile) { - return true; - } - - return await detectNextDependency(path); -} - -async function detectNextConfigFile(path: string): Promise { - return fs - .access(pathModule.join(path, "next.config.js")) - .then(() => true) - .catch(() => false); -} - -async function detectNextDependency(path: string): Promise { - const packageJsonContent = await readPackageJson(path); - if (!packageJsonContent) { - return false; - } - - return packageJsonContent.dependencies?.next !== undefined; -} diff --git a/packages/cli/src/utils/env.ts b/packages/cli/src/utils/env.ts new file mode 100644 index 0000000000..9750cf64f6 --- /dev/null +++ b/packages/cli/src/utils/env.ts @@ -0,0 +1,75 @@ +import fs from "fs/promises"; +import pathModule from "path"; +import { pathExists } from "./fileSystem"; +import { logger } from "./logger"; +import { renderApiKey } from "./renderApiKey"; + +export async function getEnvFilename( + directory: string, + possibleNames: string[] +): Promise { + if (possibleNames.length === 0) { + throw new Error("No possible names provided"); + } + + for (let index = 0; index < possibleNames.length; index++) { + const name = possibleNames[index]; + if (!name) continue; + + const path = pathModule.join(directory, name); + const envFileExists = await pathExists(path); + if (envFileExists) { + return name; + } + } + + return undefined; +} + +export async function setApiKeyEnvironmentVariable(dir: string, fileName: string, apiKey: string) { + await setEnvironmentVariable(dir, fileName, "TRIGGER_API_KEY", apiKey, true, renderApiKey); +} + +export async function setApiUrlEnvironmentVariable(dir: string, fileName: string, apiUrl: string) { + await setEnvironmentVariable(dir, fileName, "TRIGGER_API_URL", apiUrl, true); +} + +async function setEnvironmentVariable( + dir: string, + fileName: string, + variableName: string, + value: string, + replaceValue: boolean = true, + renderer: (value: string) => string = (value) => value +) { + const path = pathModule.join(dir, fileName); + const envFileExists = await pathExists(path); + + if (!envFileExists) { + await fs.writeFile(path, ""); + } + + const envFileContent = await fs.readFile(path, "utf-8"); + + if (envFileContent.includes(variableName)) { + if (!replaceValue) { + logger.info( + `☑ Skipping setting ${variableName}=${renderer(value)} because it already exists` + ); + return; + } + // Update the existing value + const updatedEnvFileContent = envFileContent.replace( + new RegExp(`${variableName}=.*\\n`, "g"), + `${variableName}=${value}\n` + ); + + await fs.writeFile(path, updatedEnvFileContent); + + logger.success(`✔ Set ${variableName}=${renderer(value)} in ${fileName}`); + } else { + await fs.appendFile(path, `\n${variableName}=${value}`); + + logger.success(`✔ Added ${variableName}=${renderer(value)} to ${fileName}`); + } +} diff --git a/packages/cli/src/utils/pathAlias.test.ts b/packages/cli/src/utils/pathAlias.test.ts new file mode 100644 index 0000000000..f28e06e887 --- /dev/null +++ b/packages/cli/src/utils/pathAlias.test.ts @@ -0,0 +1,133 @@ +import mock from "mock-fs"; +import { getPathAlias } from "./pathAlias"; + +describe("javascript config", () => { + test("no jsconfig means no alias", async () => { + mock({ + "src/pages": {}, + }); + + const alias = await getPathAlias({ + projectPath: "", + isTypescriptProject: false, + usesSrcDir: true, + }); + + expect(alias).toBeUndefined(); + }); + + test("jsconfig without alias", async () => { + mock({ + "src/pages": {}, + "jsconfig.json": JSON.stringify({}), + }); + + const alias = await getPathAlias({ + projectPath: "", + isTypescriptProject: false, + usesSrcDir: true, + }); + + expect(alias).toBeUndefined(); + }); + + test("jsconfig without paths", async () => { + mock({ + "src/pages": {}, + "jsconfig.json": `{"compilerOptions": {}}`, + }); + + const alias = await getPathAlias({ + projectPath: "", + isTypescriptProject: false, + usesSrcDir: true, + }); + + expect(alias).toBeUndefined(); + }); + + test("jsconfig without matching alias", async () => { + mock({ + "src/pages": {}, + "jsconfig.json": `{"compilerOptions": { "paths": { + "~/*": ["./app/*"] + }}}`, + }); + + const alias = await getPathAlias({ + projectPath: "", + isTypescriptProject: false, + usesSrcDir: true, + }); + + expect(alias).toBeUndefined(); + }); + + test("jsconfig src dir", async () => { + mock({ + "src/pages": {}, + "jsconfig.json": `{"compilerOptions": { "paths": { + "~/*": ["./src/*"] + }}}`, + }); + + const alias = await getPathAlias({ + projectPath: "", + isTypescriptProject: false, + usesSrcDir: true, + }); + + expect(alias).toEqual("~"); + }); + + test("jsconfig no src dir", async () => { + mock({ + pages: {}, + "jsconfig.json": `{"compilerOptions": { "paths": { + "~/*": ["./*"] + }}}`, + }); + + const alias = await getPathAlias({ + projectPath: "", + isTypescriptProject: false, + usesSrcDir: false, + }); + + expect(alias).toEqual("~"); + }); + + test("tsconfig no src dir", async () => { + mock({ + "src/pages": {}, + "tsconfig.json": `{"compilerOptions": { "paths": { + "~/*": ["./src/*"] + }}}`, + }); + + const alias = await getPathAlias({ + projectPath: "", + isTypescriptProject: true, + usesSrcDir: true, + }); + + expect(alias).toEqual("~"); + }); + + test("tsconfig no src dir", async () => { + mock({ + pages: {}, + "tsconfig.json": `{"compilerOptions": { "paths": { + "~/*": ["./*"] + }}}`, + }); + + const alias = await getPathAlias({ + projectPath: "", + isTypescriptProject: true, + usesSrcDir: false, + }); + + expect(alias).toEqual("~"); + }); +}); diff --git a/packages/cli/src/utils/pathAlias.ts b/packages/cli/src/utils/pathAlias.ts new file mode 100644 index 0000000000..dc949bb727 --- /dev/null +++ b/packages/cli/src/utils/pathAlias.ts @@ -0,0 +1,55 @@ +import pathModule from "path"; +import { pathExists } from "./fileSystem"; +import { parse } from "tsconfck"; + +type Options = { projectPath: string; isTypescriptProject: boolean; extraDirectories?: string[] }; + +// Find the alias that points to the "src" directory. +// So for example, the paths object could be: +// { +// "@/*": ["./src/*"] +// } +// In this case, we would return "@" +export async function getPathAlias({ + projectPath, + isTypescriptProject, + extraDirectories, +}: Options) { + const configFileName = isTypescriptProject ? "tsconfig.json" : "jsconfig.json"; + const tsConfigPath = pathModule.join(projectPath, configFileName); + const configFileExists = await pathExists(tsConfigPath); + + //no config and javascript, no alias + if (!isTypescriptProject && !configFileExists) { + return; + } + + const { tsconfig } = await parse(tsConfigPath); + + const paths = tsconfig?.compilerOptions?.paths; + if (paths === undefined) { + return; + } + + const alias = Object.keys(paths).find((key) => { + const value = paths[key]; + + if (value.length === 0) { + return false; + } + + const path = value[0]; + if (extraDirectories && extraDirectories.length > 0) { + return path === `./${extraDirectories.join("/")}/*`; + } else { + return path === "./*"; + } + }); + + // Make sure to remove the trailing "/*" + if (alias) { + return alias.slice(0, -2); + } + + return; +} diff --git a/packages/cli/src/utils/readFileIgnoringMock.ts b/packages/cli/src/utils/readFileIgnoringMock.ts new file mode 100644 index 0000000000..15ab7f688b --- /dev/null +++ b/packages/cli/src/utils/readFileIgnoringMock.ts @@ -0,0 +1,8 @@ +import mock from "mock-fs"; +import fs from "fs/promises"; + +export function readFileIgnoringMock(filePath: string): Promise { + return mock.bypass(async () => { + return await fs.readFile(filePath, { encoding: "utf-8" }); + }); +} diff --git a/packages/cli/src/utils/removeFileExtension.ts b/packages/cli/src/utils/removeFileExtension.ts new file mode 100644 index 0000000000..77f771c1b4 --- /dev/null +++ b/packages/cli/src/utils/removeFileExtension.ts @@ -0,0 +1,3 @@ +export function removeFileExtension(filename: string) { + return filename.replace(/\.[^.]+$/, ""); +} diff --git a/packages/cli/src/utils/requiredKeys.ts b/packages/cli/src/utils/requiredKeys.ts new file mode 100644 index 0000000000..087163e6bc --- /dev/null +++ b/packages/cli/src/utils/requiredKeys.ts @@ -0,0 +1,4 @@ +export type RequireKeys = Required> & + Omit extends infer O + ? { [P in keyof O]: O[P] } + : never; diff --git a/packages/cli/test/example.test.ts b/packages/cli/test/example.test.ts deleted file mode 100644 index 18ec80513c..0000000000 --- a/packages/cli/test/example.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { prepareEnvironment } from "@gmrchk/cli-testing-library"; -import { CLITestEnvironment } from "@gmrchk/cli-testing-library/lib/types"; -import { join } from "node:path"; - -let environment: CLITestEnvironment; - -beforeAll(async () => { - // This will create a "sandbox" terminal under `/var/folders` - environment = await prepareEnvironment(); -}); - -afterAll(async () => { - await environment.cleanup(); -}); - -// this test is not returning timeout -describe.skip('cli', () => { - // can be any path with a nextjs project - const NEXT_PROJECT_PATH = join(__dirname, '..', '..', '..', 'examples', 'nextjs-example'); - - it('should be able to execute cli', async () => { - const { waitForText, getStdout, wait, pressKey } = await environment.spawn('node', `${join(__dirname, '..', 'dist', 'index.js')} init -p ${NEXT_PROJECT_PATH}`) - - console.log('getStdout() :>> ', getStdout()); - - // this promises never resolves - // maybe we have a conflict between vitest and @gmrchk/cli-testing-library? - // with jest works fine, but with vitest not - await waitForText('Detected Next.js project'); - - console.log('getStdout() :>> ', getStdout()); - - await waitForText('Are you using the Trigger.dev cloud or self-hosted?'); - - console.log('getStdout() :>> ', getStdout()); - - await pressKey('enter'); - - console.log('getStdout() :>> ', getStdout()); - - // wait next prompt, make assertions and keep going - }); -}, 20000) \ No newline at end of file diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 94937b364e..40b3cf10f3 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "tsup"; const isDev = process.env.npm_lifecycle_event === "dev"; +//command to copy the "templates" folder to dist/templates +const copyTemplates = "cp -r src/templates dist"; export default defineConfig({ clean: true, @@ -12,5 +14,5 @@ export default defineConfig({ sourcemap: true, target: "esnext", outDir: "dist", - onSuccess: isDev ? "node dist/index.js" : undefined, + onSuccess: isDev ? `${copyTemplates} && node dist/index.js` : copyTemplates, }); diff --git a/packages/remix/package.json b/packages/remix/package.json index 9040c157da..6b669c2dc1 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -19,7 +19,7 @@ "./package.json": "./package.json" }, "devDependencies": { - "@remix-run/node": "^1.19.3", + "@remix-run/server-runtime": "^2.0.0", "@trigger.dev/tsconfig": "workspace:*", "@types/debug": "^4.1.7", "@types/ws": "^8.5.3", @@ -34,12 +34,13 @@ "build:tsup": "tsup" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^2.1.3" + "@trigger.dev/sdk": "workspace:^2.1.3", + "@remix-run/server-runtime": ">1.19.0" }, "dependencies": { "debug": "^4.3.4" }, "engines": { - "node": ">=16.8.0" + "node": ">=18.0.0" } } diff --git a/packages/remix/src/index.ts b/packages/remix/src/index.ts index c6cbe6040a..347e34da8b 100644 --- a/packages/remix/src/index.ts +++ b/packages/remix/src/index.ts @@ -1,9 +1,8 @@ -import type { ActionArgs } from "@remix-run/node"; -import { json } from "@remix-run/node"; +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import type { TriggerClient } from "@trigger.dev/sdk"; export function createRemixRoute(client: TriggerClient) { - const action = async ({ request }: ActionArgs) => { + const action = async ({ request }: ActionFunctionArgs) => { const response = await client.handleRequest(request); if (!response) { @@ -13,5 +12,4 @@ export function createRemixRoute(client: TriggerClient) { return json(response.body, { status: response.status }); }; return { action }; - }