CLI now supports multiple frameworks (with tests) (#480)

* Early work defining CLI framework support

* WIP moving CLI init logic to the Framework class

* Installing files should now work for Nextjs

* Some fixes

* WIP creating unit tests for Next.js project detection

* Delete old jest config

* Latest lockfile

* Detect use of src directory test

* Tests for detection pages/app directory

* Correct detection of Next.js project

* Renamed test file

* Create install files from template files with replacements. With tests

* Added multiple uses of the same replacement

* Created a test for the install step (it fails right now with JS)

* Removed unused import

* Another test that should pass but currently fails…

* Path alias fixed and now has tests

* New pathAlias function used

* Nextjs page install tests

* Fixed app directory install (with tests)

* Removed e2e CLI test, switched to unit testing strategy instead

* Latest lockfile

* The install files are now actual files that are copied and transformed

* Got the template files working correctly after building

* Next steps are now framework specific

* createFileFromTemplate now works with a path again. Uses mock if specified.

* Renamed apiRoute.js to pagesApiRoute.js

* Simplified pages file generation

* Next.js app API route template

* Next.js App routing support, with common files logic shared

* Dev command now uses framework default values if they exist and aren’t overridden

* Unused import

* pathAlias now works for all frameworks

* Added a test to detect Next from the next.config.js

* WIP on Remix framework support

* Tests for Remix install

* Replaced references to Next.js

* Use a green ✔️ instead of  in the CLI

* Support for multiple hostnames

* Tunneling can now use the hostname and port

* Work on multiple ports

* Improved the error messages. Added some extra pots to Next.js

* Update the Remix templates to have .server in the imports

* Remix updated to use server-runtime instead of node. Node v18+

* Frameworks can specify the watch paths and ignore paths

* Define the watch variables above, so we can easily log them for debugging

* Don’t wait for outdated package checking when running the dev command

* Improved the Remix manual setup guide

* Rewriting docs for quickstart

* Updated the Next.js quickstart

* Remix quick start

* Added a changeset

* Improved the Next.js manual setup
This commit is contained in:
Matt Aitken
2023-09-22 08:55:04 -07:00
committed by GitHub
parent c0dfa8048a
commit 1a4952720f
48 changed files with 1904 additions and 977 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/remix": patch
"@trigger.dev/cli": patch
---
CLI now supports multiple frameworks (starting with Next.js and Remix)
+13 -33
View File
@@ -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 `<root>/app/trigger.ts`, where `<root>` 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.
export const client = new TriggerClient({
id: "my-app",
apiKey: process.env.TRIGGER_API_KEY,
apiUrl: process.env.TRIGGER_API_URL,
});
```
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 :**
```
project-root/
├── app/
├── routes/
├── trigger.ts
├── other files...
```
Replace **"my-app"** with an appropriate identifier for your project.
## Creating the API Route
+26
View File
@@ -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:
<CodeGroup>
```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
```
</CodeGroup>
<br />
<Note>
You can optionally pass the port if you're not running on the default port by adding
`--port 3001` to the end.
</Note>
+26
View File
@@ -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
},
});
```
@@ -0,0 +1,18 @@
<Step title="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!**
</Step>
+80
View File
@@ -0,0 +1,80 @@
<Step title="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.
</Step>
<Step title="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.
</Step>
<Step title="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)
</Step>
<Step title="Run the CLI `init` command">
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:
<CodeGroup>
```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
```
</CodeGroup>
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.
</Step>
<Step title="Run your site">
Make sure your site is running locally, we will connect to it to register your Jobs.
<Warning>You must leave this running for the rest of the steps.</Warning>
<CodeGroup>
```bash npm
npm run dev
```
```bash pnpm
pnpm run dev
```
```bash yarn
yarn run dev
```
</CodeGroup>
</Step>
+20
View File
@@ -0,0 +1,20 @@
## What's next?
<CardGroup cols={2}>
<Card title="Write your first Job" icon="hexagon-plus" href="/documentation/guides/create-a-job">
A Guide for how to create your first real Job
</Card>
<Card
title="What is Trigger.dev"
icon="wand-magic-sparkles"
href="/documentation/concepts/what-is-triggerdotdev"
>
Learn more about how Trigger.dev works and how it can help you.
</Card>
<Card title="Examples" icon="slot-machine" href="/examples">
One of the quickest ways to learn how Trigger.dev works is to view some example Jobs.
</Card>
<Card title="Get help" icon="hire-a-helper" href="/documentation/get-help">
Struggling getting setup or have a question? We're here to help.
</Card>
</CardGroup>
+36 -62
View File
@@ -22,19 +22,15 @@ To begin, install the necessary packages in your Next.js project directory. You
<CodeGroup>
```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
```
</CodeGroup>
@@ -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:
Create a file at `<root>/src/trigger.ts` or `<root>/trigger.ts` depending on whether you're using the `src` directory or not. `<root>` 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 src/trigger.(ts/js)
// trigger.ts (for TypeScript) or trigger.js (for JavaScript)
2. **Choose Directory:**
import { TriggerClient } from "@trigger.dev/sdk";
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.
export const client = new TriggerClient({
id: "my-app",
apiKey: process.env.TRIGGER_API_KEY,
apiUrl: process.env.TRIGGER_API_URL,
});
```
3. **Add Configuration Code:**
Open the configuration file you created and add the following code:
```typescript
// 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. **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...
```
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:
<Warning>
Make sure your Next.js site is running locally before continuing. You must also leave this `dev`
terminal command running while you develop.
</Warning>
<CodeGroup>
In a **new terminal window or tab** run:
```bash npm
npm run dev
```
```bash pnpm
pnpm run dev
```
```bash yarn
yarn run dev
```
</CodeGroup>
### Run the CLI 'dev' command
In a **_separate terminal window or tab_** run:
<CodeGroup>
@@ -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
</Note>
<Note>
You can optionally pass the hostname if you're not running on localhost by adding
`--hostname <host>`. Example, in case your Next.js is running on 0.0.0.0: `--hostname 0.0.0.0`.
`--hostname <host>`. Example, in case your Remix is running on 0.0.0.0: `--hostname 0.0.0.0`.
</Note>
<Tip>
+14 -157
View File
@@ -17,106 +17,12 @@ Trigger.dev works with either the Pages or App Router configuration.
</Accordion>
## Create a Trigger.dev account
<Steps titleSize="h3">
<Snippet file="quickstart-setup-steps.mdx" />
You can either:
<Step title="Run the CLI `dev` command">
- 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:
<CodeGroup>
```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
```
</CodeGroup>
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.
<Warning>You must leave this running for the rest of the steps.</Warning>
<CodeGroup>
```bash npm
npm run dev
```
```bash pnpm
pnpm run dev
```
```bash yarn
yarn run dev
```
</CodeGroup>
## 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:
<CodeGroup>
```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
```
</CodeGroup>
<br />
<Note>
You can optionally pass the port if you're not running on 3000 by adding
`--port 3001` to the end
</Note>
<Snippet file="quickstart-cli-dev.mdx" />
<AccordionGroup>
<Accordion title="Experiencing an error? This could be due to middleware.">
@@ -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
</Accordion>
</AccordionGroup>
## Your first job
</Step>
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`.
<Step title="Your first job">
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!",
};
},
});
```
<Snippet file="quickstart-example-job.mdx" />
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
</Step>
There are two way to trigger this Job.
<Snippet file="quickstart-running-your-job.mdx" />
1. Use the "Test" functionality in the dashboard.
2. Use the Trigger.dev API (either via our SDK or a web request)
</Steps>
### "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?
<CardGroup cols={2}>
<Card title="Write your first Job" icon="hexagon-plus" href="/documentation/guides/create-a-job">
A Guide for how to create your first real Job
</Card>
<Card
title="What is Trigger.dev"
icon="wand-magic-sparkles"
href="/documentation/concepts/what-is-triggerdotdev"
>
Learn more about how Trigger.dev works and how it can help you.
</Card>
<Card title="Examples" icon="slot-machine" href="/examples">
One of the quickest ways to learn how Trigger.dev works is to view some example Jobs.
</Card>
<Card title="Get help" icon="hire-a-helper" href="/documentation/get-help">
Struggling getting setup or have a question? We're here to help.
</Card>
</CardGroup>
<Snippet file="quickstart-whats-next.mdx" />
+80 -1
View File
@@ -4,4 +4,83 @@ sidebarTitle: "Remix"
description: "Start creating Jobs in 5 minutes in your Remix project."
---
<Snippet file="manual-setup-remix.mdx" />
This quick start guide will get you up and running with Trigger.dev.
<Accordion title="Need to create a new Remix project to add Trigger.dev to?">
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.
</Accordion>
<Steps titleSize="h3">
<Snippet file="quickstart-setup-steps.mdx" />
<Step title="Run the CLI `dev` command">
<Snippet file="quickstart-cli-dev.mdx" />
<AccordionGroup>
<Accordion title="Advanced: Run your Remix server together with the CLI">
You can modify your `package.json` to run both the Remix server and the CLI `dev` command together.
1. Install the `concurrently` package:
<CodeGroup>
```bash npm
npm install concurrently --save-dev
```
```bash pnpm
pnpm install concurrently --save-dev
```
```bash yarn
yarn add concurrently --dev
```
</CodeGroup>
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",
//...
}
//...
```
</Accordion>
</AccordionGroup>
</Step>
<Step title="Your first job">
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:
<Snippet file="quickstart-example-job.mdx" />
If you navigate to your Trigger.dev project you will see this Job in the "Jobs" section:
![Your first Job](/images/first-job.png)
</Step>
<Snippet file="quickstart-running-your-job.mdx" />
</Steps>
<Snippet file="quickstart-whats-next.mdx" />
+3 -3
View File
@@ -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
-10
View File
@@ -1,10 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: [
"<rootDir>/test/**/*.ts?(x)",
"<rootDir>/test/**/?(*.)+(spec|test).ts?(x)",
"<rootDir>/src/**/?(*.)+(spec|test).ts?(x)"
],
};
+2 -3
View File
@@ -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",
+7 -7
View File
@@ -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 <project-path>", "The path to the Next.js project", ".")
.description("Initialize Trigger.dev in your project")
.option("-p, --project-path <project-path>", "The path to the project", ".")
.option("-k, --api-key <api-key>", "The development API key to use for the project.")
.option(
"-e, --endpoint-id <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 <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 <port>", "The local port your server is on", "3000")
.option("-H, --hostname <hostname>", "Hostname on which the application is served", "localhost")
.option("-e, --env-file <name>", "The name of the env file to load", ".env.local")
.option("-p, --port <port>", "Override the local port your server is on")
.option("-H, --hostname <hostname>", "Override the hostname on which the application is served")
.option("-e, --env-file <name>", "Override the name of the env file to load")
.option(
"-i, --client-id <name>",
"The ID of the client to use for this project. Will use the value from the package.json file if not provided."
@@ -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(
+169 -79
View File
@@ -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<typeof DevCommandOptionsSchema>;
type ResolvedOptions = RequireKeys<DevCommandOptions, "handlerPath" | "envFile">;
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) {
const updates = await ncuRun({
packageFile: `${path}/package.json`,
filter: "/trigger.dev\/.+$/",
upgrade: false,
}) as {
[key: string]: string;
async function resolveOptions(
framework: Framework | undefined,
path: string,
unresolvedOptions: DevCommandOptions
): Promise<ResolvedOptions> {
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,
};
}
if (typeof updates === 'undefined' || Object.keys(updates).length === 0) {
//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/.+$/",
upgrade: false,
})) as {
[key: string]: string;
};
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" &&
+54 -536
View File
@@ -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 installOptions = {
typescript: isTypescriptProject,
packageManager,
endpointSlug: resolvedOptions.endpointSlug,
};
const routeDir = pathModule.join(resolvedPath, usesSrcDir ? "src" : "");
telemetryClient.init.install(resolvedOptions, framework.name, installOptions);
await framework.install(resolvedPath, installOptions);
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
);
}
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<Omit<InitCommandOptions, "endpointSlug">> & {
@@ -189,8 +179,7 @@ type OptionsAfterPrompts = Required<Omit<InitCommandOptions, "endpointSlug">> &
const resolveOptionsWithPrompts = async (
options: InitCommandOptions,
path: string,
telemetryClient: TelemetryClient
path: string
): Promise<OptionsAfterPrompts> => {
const resolvedOptions: InitCommandOptions = { ...options };
@@ -279,474 +268,3 @@ async function detectTypescriptProject(path: string): Promise<boolean> {
return false;
}
}
async function detectUseOfSrcDir(path: string): Promise<boolean> {
// 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<Array<string>> {
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(/\.[^.]+$/, "");
}
+2 -2
View File
@@ -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;
}
+65
View File
@@ -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<boolean>;
/** List of packages to install */
dependencies(): Promise<InstallPackage[]>;
/** Priority list of env filenames, e.g. ".env" */
possibleEnvFilenames(): string[];
/** Install the required files */
install(path: string, options: ProjectInstallOptions): Promise<void>;
/** You can check for middleware, add extra instructions, etc */
postInstall(path: string, options: ProjectInstallOptions): Promise<void>;
/** 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<Framework | undefined> => {
for (const framework of frameworks) {
if (await framework.isMatch(path, packageManager)) {
return framework;
}
}
return;
};
export function frameworkNames() {
return frameworks.map((f) => f.name).join(", ");
}
+217
View File
@@ -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<boolean> {
const hasNextConfigFile = await detectNextConfigFile(path);
if (hasNextConfigFile) {
return true;
}
return await detectNextDependency(path);
}
async dependencies(): Promise<InstallPackage[]> {
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<void> {
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<void> {
await detectMiddlewareUsage(path);
}
defaultHostnames = ["localhost"];
defaultPorts = [3000, 3001, 3002];
watchFilePaths = standardWatchFilePaths;
watchIgnoreRegex = /(node_modules|\.next)/;
}
async function detectNextConfigFile(path: string): Promise<boolean> {
return pathExists(pathModule.join(path, "next.config.js"));
}
export async function detectNextDependency(path: string): Promise<boolean> {
const packageJsonContent = await readPackageJson(path);
if (!packageJsonContent) {
return false;
}
return packageJsonContent.dependencies?.next !== undefined;
}
export async function detectUseOfSrcDir(path: string): Promise<boolean> {
// 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}`);
}
@@ -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<Array<string>> {
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
}
}
@@ -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);
});
});
+110
View File
@@ -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<boolean> {
//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<InstallPackage[]> {
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<void> {
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<void> {}
defaultHostnames = ["localhost"];
defaultPorts = [3000, 8788, 3333];
watchFilePaths = standardWatchFilePaths;
watchIgnoreRegex = /(node_modules|build)/;
}
@@ -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);
});
});
@@ -0,0 +1,10 @@
export const standardWatchFilePaths = [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"**/*.json",
"pnpm-lock.yaml",
];
export const standardWatchIgnoreRegex = /(node_modules)/;
+14
View File
@@ -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;
}
+19 -3
View File
@@ -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_create_files",
properties: { ...this.#initProperties(options), routerType },
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_post_install",
properties: { ...this.#initProperties(options), framework, ...installOptions },
});
},
completed: (options: InitCommandOptions) => {
@@ -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);
@@ -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
},
});
@@ -0,0 +1,3 @@
// export all your job files here
export * from "./examples";
@@ -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;
@@ -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,
});
@@ -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);
@@ -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
},
});
@@ -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,
});
@@ -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);
});
});
@@ -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<string, string>;
outputPath: string;
mockTemplatePath?: boolean;
}): Promise<Result> {
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<string, string> where { varName: "value" }
export function replaceAll(input: string, replacements: Record<string, string>) {
let output = input;
for (const [key, value] of Object.entries(replacements)) {
output = output.replace(new RegExp(`\\$\\{${key}\\}`, "g"), value);
}
return output;
}
@@ -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<boolean> {
const hasNextConfigFile = await detectNextConfigFile(path);
if (hasNextConfigFile) {
return true;
}
return await detectNextDependency(path);
}
async function detectNextConfigFile(path: string): Promise<boolean> {
return fs
.access(pathModule.join(path, "next.config.js"))
.then(() => true)
.catch(() => false);
}
async function detectNextDependency(path: string): Promise<boolean> {
const packageJsonContent = await readPackageJson(path);
if (!packageJsonContent) {
return false;
}
return packageJsonContent.dependencies?.next !== undefined;
}
+75
View File
@@ -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<string | undefined> {
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}`);
}
}
+133
View File
@@ -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("~");
});
});
+55
View File
@@ -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;
}
@@ -0,0 +1,8 @@
import mock from "mock-fs";
import fs from "fs/promises";
export function readFileIgnoringMock(filePath: string): Promise<string> {
return mock.bypass(async () => {
return await fs.readFile(filePath, { encoding: "utf-8" });
});
}
@@ -0,0 +1,3 @@
export function removeFileExtension(filename: string) {
return filename.replace(/\.[^.]+$/, "");
}
+4
View File
@@ -0,0 +1,4 @@
export type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> &
Omit<T, K> extends infer O
? { [P in keyof O]: O[P] }
: never;
-43
View File
@@ -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)
+3 -1
View File
@@ -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,
});
+4 -3
View File
@@ -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"
}
}
+2 -4
View File
@@ -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 };
}