chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
# CLI Package
|
||||
|
||||
The `trigger.dev` CLI package, published as `trigger.dev` on npm. Executable: `trigger`.
|
||||
|
||||
## Dev vs Deploy
|
||||
|
||||
### Dev Mode (`src/dev/`)
|
||||
Runs tasks locally in the user's Node.js process. No containers involved. Uses `src/dev/` for the dev command, connects to the local webapp for coordination.
|
||||
|
||||
### Deploy Mode (`src/deploy/`)
|
||||
Bundles task code and builds Docker images for production:
|
||||
1. **Bundle**: `src/build/` bundles worker code using the build system
|
||||
2. **Archive**: `src/deploy/archiveContext.ts` packages files for deployment
|
||||
3. **Build image**: `src/deploy/buildImage.ts` creates Docker images (local Docker/Depot or remote builds)
|
||||
4. **Push**: Pushes image to registry, registers with webapp API
|
||||
|
||||
## Customer Task Images
|
||||
|
||||
Code in `src/entryPoints/` runs **inside customer containers** - this is a different runtime environment from the CLI itself. Changes here affect deployed task execution directly.
|
||||
|
||||
The build system (`src/build/`) uses the config from `trigger.config.ts` in user projects to determine what to bundle, which build extensions to apply, and how to structure the output.
|
||||
|
||||
## Commands
|
||||
|
||||
CLI command definitions live in `src/commands/`. Key commands:
|
||||
- `dev.ts` - Local development mode
|
||||
- `deploy.ts` - Production deployment
|
||||
- `init.ts` - Project initialization
|
||||
- `login.ts` - Authentication
|
||||
- `promote.ts` - Deployment promotion
|
||||
|
||||
## MCP Server
|
||||
|
||||
`src/mcp/` provides an MCP server for AI-assisted task development.
|
||||
|
||||
## SDK Documentation Rules
|
||||
|
||||
The `rules/` directory at the repo root contains versioned SDK documentation that gets installed alongside customer projects. Update both `rules/` and `.claude/skills/trigger-dev-tasks/` when SDK features change.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Running the CLI from source
|
||||
|
||||
1. Run the CLI and watch for changes
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
2. Test the local CLI using the job-catalogs located in the `/references` directory
|
||||
|
||||
```sh
|
||||
pnpm i
|
||||
pnpm exec trigger <command>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If you want to use it in a new folder, you need to first add it as a dev dependency in package.json:
|
||||
|
||||
```json
|
||||
//...
|
||||
"devDependencies": {
|
||||
"trigger.dev": "workspace:*",
|
||||
//...
|
||||
}
|
||||
//...
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Trigger.dev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,51 @@
|
||||
<div align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/3f5ad4c1-c4c8-4277-b622-290e7f37bd00/public">
|
||||
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
|
||||
</picture>
|
||||
|
||||
[](https://www.npmjs.com/package/trigger.dev)
|
||||
[](https://www.npmjs.com/package/trigger.dev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
|
||||
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs) | [Examples](https://trigger.dev/docs/examples)
|
||||
|
||||
</div>
|
||||
|
||||
# Trigger.dev CLI
|
||||
|
||||
A CLI that allows you to create, run locally and deploy Trigger.dev background tasks.
|
||||
|
||||
Note: this only works with Trigger.dev v3 projects and later. For older projects use the [@trigger.dev/cli](https://www.npmjs.com/package/@trigger.dev/cli) package.
|
||||
|
||||
## About Trigger.dev
|
||||
|
||||
Trigger.dev is an open source platform for building and deploying fully-managed AI agents and workflows. Write workflows in normal async TypeScript for everything from simple tasks to long-running AI agents, heavy media processing, complex real-time systems and more. Complete with full observability, managed queues, and elastic infrastructure which handles the horizontal scaling.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| :------------------------------------------------------------------- | :----------------------------------------------------------------- |
|
||||
| [login](https://trigger.dev/docs/cli-login-commands) | Login with Trigger.dev so you can perform authenticated actions. |
|
||||
| [init](https://trigger.dev/docs/cli-init-commands) | Initialize your existing project for development with Trigger.dev. |
|
||||
| [dev](https://trigger.dev/docs/cli-dev-commands) | Run your Trigger.dev tasks locally. |
|
||||
| [deploy](https://trigger.dev/docs/cli-deploy-commands) | Deploy your Trigger.dev v3 project to the cloud. |
|
||||
| [whoami](https://trigger.dev/docs/cli-whoami-commands) | Display the current logged in user and project details. |
|
||||
| [logout](https://trigger.dev/docs/cli-logout-commands) | Logout of Trigger.dev. |
|
||||
| [list-profiles](https://trigger.dev/docs/cli-list-profiles-commands) | List all of your CLI profiles. |
|
||||
| [preview archive](https://trigger.dev/docs/cli-preview-archive) | Archive a preview branch. |
|
||||
| [promote](https://trigger.dev/docs/cli-promote-commands) | Promote a previously deployed version to the current version. |
|
||||
| [switch](https://trigger.dev/docs/cli-switch) | Switch between CLI profiles. |
|
||||
| [update](https://trigger.dev/docs/cli-update-commands) | Updates all `@trigger.dev/*` packages to match the CLI version. |
|
||||
|
||||
## CLI documentation
|
||||
|
||||
For more information on the CLI, please refer to our [docs](https://trigger.dev/docs/cli-introduction).
|
||||
|
||||
## Support
|
||||
|
||||
If you have any questions, please reach out to us on [Discord](https://trigger.dev/discord) and we'll be happy to help.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Trigger.dev CLI E2E suite
|
||||
|
||||
E2E test suite for the Trigger.dev v3 CLI.
|
||||
|
||||
Note: this only works with Trigger.dev v3 projects and later. There is no E2E test suite for the [@trigger.dev/cli](https://www.npmjs.com/package/@trigger.dev/cli) package yet.
|
||||
|
||||
Trigger.dev is an open source platform that makes it easy to create event-driven background tasks directly in your existing project.
|
||||
|
||||
## Description
|
||||
|
||||
This suite aims to test the outputs fo the `triggerdev deploy` command.
|
||||
To do so, it runs the deploy code against fixture projects that are located under `packages/cli-v3/e2e/fixtures/`.
|
||||
Those fixtures reproduce minimal project structure and contents, in order to reproduce known bugs and run fast.
|
||||
|
||||
**Notes**
|
||||
|
||||
- The suite uses vitest
|
||||
- Everything happens locally
|
||||
- There is no login required
|
||||
- There is not real project reference needed
|
||||
- No docker image is created or built, instead, the bundled worker file is started with node directly inside the vitest process
|
||||
|
||||
## Usage
|
||||
|
||||
If you have not done it yet, build the CLI:
|
||||
|
||||
```sh
|
||||
pnpm run build --filter trigger.dev
|
||||
```
|
||||
|
||||
Then, run the v3 CLI E2E test suite:
|
||||
|
||||
```sh
|
||||
pnpm --filter trigger.dev run test:e2e
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------- | ---------------------------------------------------------------------------- |
|
||||
| `MOD=<fixture-name>` | The name of any folder directly nested under `packages/cli-v3/e2e/fixtures/` |
|
||||
| `PM=<package-manager>` | The package manager to use. One of `npm`, `pnpm`, `yarn`. Defaults to `npm` |
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
MOD=server-only PM=yarn pnpm --filter trigger.dev run test:e2e
|
||||
```
|
||||
|
||||
This will run the test suite for the `server-only` fixture using `yarn` to install and resolve dependencies.
|
||||
|
||||
## Debugging
|
||||
|
||||
When debugging an issue with the `triggerdev deploy` or `triggerdev dev` command, it is recommended to reproduce it with a minimal project fixture in the e2e suite.
|
||||
Check [Adding a fixture](#adding-a-fixture) for more information.
|
||||
|
||||
Then run:
|
||||
|
||||
```sh
|
||||
MOD=<fixture-name> pnpm run test:e2e
|
||||
```
|
||||
|
||||
This will test your fixture project, and generate outputs in the `packages/cli-v3/e2e/fixtures/<fixture-name>/.trigger` folder, so you can easily debug.
|
||||
|
||||
## Adding a fixture
|
||||
|
||||
1. Create a new `packages/cli-v3/e2e/fixtures/<fixture-name>` folder.
|
||||
|
||||
It will hold the project to test.
|
||||
|
||||
2. Add a `package.json` file in your `packages/cli-v3/e2e/fixtures/<fixture-name>` folder.
|
||||
|
||||
Use the following template:
|
||||
|
||||
```json package.json
|
||||
{
|
||||
"name": "<fixture-name>",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"pnpm": "8.15.5",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"packageManager": "pnpm@8.15.5"
|
||||
}
|
||||
```
|
||||
|
||||
> The `engines` field is used to store the versions of pnpm and yarn to use when running the suite.
|
||||
|
||||
3. Add an empty `pnpm-workspace.yaml` in your `packages/cli-v3/e2e/fixtures/<fixture-name>` folder.
|
||||
|
||||
This is necessary to prevent the Trigger.dev monorepo from handling this project.
|
||||
Please check https://github.com/pnpm/pnpm/issues/2412 for more inforation.
|
||||
|
||||
4. Add an empty `yarn.lock` in your fixture folder.
|
||||
|
||||
This is necessary to allow to use `yarn` without having a warning on the current project being a `pnpm` project.
|
||||
|
||||
5. Add the following `.yarnrc.yaml` in your fixture folder.
|
||||
|
||||
This will avoid having `.pnp.cjs` and `.pnp.loader.mjs` and keep versioned files to a minimum.
|
||||
|
||||
```yaml .yarnrc.yml
|
||||
nodeLinker: node-modules
|
||||
```
|
||||
|
||||
6. Install the fixture dependencies and generate lockfiles.
|
||||
|
||||
Like you would in any project.
|
||||
E.g. if your fixture contains a trigger task that uses the `jsdom` library:
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3/e2e/fixtures/<fixture-name>
|
||||
corepack use pnpm@8.15.5
|
||||
pnpm install jsdom
|
||||
```
|
||||
|
||||
> This will update the `package.json` and generate the `pnpm-lock.yaml` file.
|
||||
|
||||
7. Make sure typescript is installed in the fixture project.
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3/e2e/fixtures/<fixture-name>
|
||||
corepack use pnpm@8.15.5
|
||||
pnpm install typescript
|
||||
```
|
||||
|
||||
> This is necessary to typecheck the project during the test suite.
|
||||
|
||||
8. Add a tsconfig.json file similar to the one below:
|
||||
|
||||
```json tsconfig.json
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
9. To run the test suite against multiple package manager, we need to generate the other lockfiles.
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3/e2e/fixtures/<fixture-name>
|
||||
rm -rf **/node_modules
|
||||
npm install
|
||||
rm -rf **/node_modules
|
||||
corepack use yarn@4.2.2 # will update the yarn lockfile
|
||||
```
|
||||
|
||||
> Do it in this order, otherwise `npm install` will update the existing `yarn.lock` file with legacy version 1.
|
||||
|
||||
10. Create a new `packages/cli-v3/e2e/fixtures/trigger` folder, and create a trigger task in it.
|
||||
|
||||
Here is an example:
|
||||
|
||||
```javascript
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const helloWorldTask = task({
|
||||
id: "hello-world",
|
||||
run: async (payload) => {
|
||||
console.log("Hello, World!", payload);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
11. Add a trigger configuration file.
|
||||
|
||||
The configuration file is mandatory here, the E2E suite does not execute `trigger.dev` commands.
|
||||
|
||||
```javascript
|
||||
export const config = {
|
||||
project: "<fixture-name>",
|
||||
triggerDirectories: ["./trigger"],
|
||||
};
|
||||
```
|
||||
|
||||
> The project reference can be anything here, as the suite runs locally without connecting to the platform.
|
||||
|
||||
12. Commit your changes.
|
||||
|
||||
13. Add your fixture test configuration in `fixtures.config.js`.
|
||||
|
||||
```javascript fixtures.config.js
|
||||
export const fixturesConfig = [
|
||||
// ...
|
||||
{
|
||||
id: "<fixture-name>",
|
||||
},
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
> You might expect a specific error for a specific test, so use those configuration option at your discretion.
|
||||
|
||||
## Updating the SDK in the fixtures
|
||||
|
||||
The `@trigger.dev/sdk` package is installed in the fixtures as a real dependency (not from the monorepo).
|
||||
|
||||
To update it, you'll need to update the version in the `package.json` file, and then run the following commands:
|
||||
|
||||
> NOTE: Some fixtures don't support all the package managers, like the monorepo-react-email only supports yarn and pnpm.
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3/e2e/fixtures/<fixture-name>
|
||||
rm -rf **/node_modules
|
||||
corepack use pnpm@8.15.5
|
||||
rm -rf **/node_modules
|
||||
npm install
|
||||
rm -rf **/node_modules
|
||||
corepack use yarn@4.2.2
|
||||
rm -rf **/node_modules
|
||||
```
|
||||
@@ -0,0 +1,337 @@
|
||||
import { alwaysExternal } from "@trigger.dev/core/v3/build";
|
||||
import type { BuildManifest, WorkerManifest } from "@trigger.dev/core/v3/schemas";
|
||||
import * as fs from "node:fs";
|
||||
import { mkdir, rename, rm } from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { rimraf } from "rimraf";
|
||||
import { buildWorker, rewriteBuildManifestPaths } from "../src/build/buildWorker.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { indexWorkerManifest } from "../src/indexing/indexWorkerManifest.js";
|
||||
import { writeJSONFile } from "../src/utilities/fileSystem.js";
|
||||
import { logger } from "../src/utilities/logger.js";
|
||||
import { normalizeImportPath } from "../src/utilities/normalizeImportPath.js";
|
||||
import { getTmpDir } from "../src/utilities/tempDirectories.js";
|
||||
import type { TestCase } from "./fixtures.js";
|
||||
import { fixturesConfig } from "./fixtures.js";
|
||||
import type { E2EOptions } from "./schemas.js";
|
||||
import { E2EOptionsSchema } from "./schemas.js";
|
||||
import type { PackageManager } from "./utils.js";
|
||||
import {
|
||||
executeTestCaseRun,
|
||||
installFixtureDeps,
|
||||
LOCKFILES,
|
||||
parsePackageManager,
|
||||
runTsc,
|
||||
} from "./utils.js";
|
||||
|
||||
const TIMEOUT = 120_000;
|
||||
|
||||
interface E2EFixtureTest extends TestCase {
|
||||
fixtureDir: string;
|
||||
packageManager: PackageManager;
|
||||
tempDir: string;
|
||||
workspaceDir: string;
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = process.env.MOD
|
||||
? fixturesConfig.filter(({ id }) => process.env.MOD === id)
|
||||
: fixturesConfig;
|
||||
|
||||
let options: E2EOptions;
|
||||
|
||||
try {
|
||||
options = E2EOptionsSchema.parse({
|
||||
logLevel: process.env.LOG,
|
||||
packageManager: process.env.PM,
|
||||
});
|
||||
} catch (_e) {
|
||||
options = {
|
||||
logLevel: "log",
|
||||
};
|
||||
}
|
||||
|
||||
logger.loggerLevel = options.logLevel;
|
||||
|
||||
if (testCases.length === 0) {
|
||||
if (process.env.MOD) {
|
||||
throw new Error(`No test case found for ${process.env.MOD}`);
|
||||
} else {
|
||||
throw new Error("Nothing to test");
|
||||
}
|
||||
}
|
||||
|
||||
describe("buildWorker", async () => {
|
||||
beforeEach<E2EFixtureTest>(async ({ fixtureDir, skip, packageManager, workspaceDir }) => {
|
||||
await rimraf(path.join(workspaceDir, "**/node_modules"), {
|
||||
glob: true,
|
||||
});
|
||||
|
||||
await rimraf(path.join(workspaceDir, ".yarn"), { glob: true });
|
||||
|
||||
if (
|
||||
packageManager === "npm" &&
|
||||
(fs.existsSync(path.resolve(path.join(workspaceDir, "yarn.lock"))) ||
|
||||
fs.existsSync(path.resolve(path.join(workspaceDir, "yarn.lock.copy"))))
|
||||
) {
|
||||
// `npm ci` & `npm install` will update an existing yarn.lock
|
||||
try {
|
||||
await rename(
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock")),
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock.copy"))
|
||||
);
|
||||
} catch (_e) {
|
||||
await rename(
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock.copy")),
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
options.packageManager &&
|
||||
!fs.existsSync(path.resolve(fixtureDir, LOCKFILES[options.packageManager]))
|
||||
) {
|
||||
skip();
|
||||
}
|
||||
|
||||
await installFixtureDeps({ fixtureDir, packageManager, workspaceDir });
|
||||
}, TIMEOUT);
|
||||
|
||||
afterEach<E2EFixtureTest>(async ({ packageManager, workspaceDir }) => {
|
||||
if (packageManager === "npm") {
|
||||
try {
|
||||
await rename(
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock.copy")),
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock"))
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
for (let testCase of testCases) {
|
||||
test.extend<E2EFixtureTest>({
|
||||
// Seed `workspaceRelativeDir` before the spread so the key always exists.
|
||||
// vitest 4 resolves fixture-to-fixture dependencies strictly at
|
||||
// `test.extend()` time: the `workspaceDir` fixture below destructures
|
||||
// `workspaceRelativeDir`, so it must be a defined fixture even for test
|
||||
// cases that don't set it (it's an optional `TestCase` field). The spread
|
||||
// overrides this default when the case provides its own value.
|
||||
workspaceRelativeDir: "",
|
||||
...testCase,
|
||||
fixtureDir: async ({ id }, use) =>
|
||||
await use(path.resolve(path.join(process.cwd(), "e2e/fixtures", id))),
|
||||
workspaceDir: async ({ fixtureDir, workspaceRelativeDir }, use) =>
|
||||
await use(path.resolve(path.join(fixtureDir, workspaceRelativeDir))),
|
||||
packageManager: async ({ workspaceDir }, use) =>
|
||||
await use(await parsePackageManager(options.packageManager, workspaceDir)),
|
||||
tempDir: async ({ workspaceDir }, use) => {
|
||||
const existingTempDir = path.resolve(path.join(workspaceDir, ".trigger"));
|
||||
|
||||
if (fs.existsSync(existingTempDir)) {
|
||||
await rm(existingTempDir, { force: true, recursive: true });
|
||||
}
|
||||
await use(
|
||||
(await mkdir(path.join(workspaceDir, ".trigger"), { recursive: true })) as string
|
||||
);
|
||||
},
|
||||
})(
|
||||
`fixture ${testCase.id}`,
|
||||
{ timeout: TIMEOUT },
|
||||
async ({
|
||||
id,
|
||||
tempDir,
|
||||
tsconfig,
|
||||
packageManager,
|
||||
fixtureDir,
|
||||
workspaceDir,
|
||||
wantConfigInvalidError,
|
||||
wantConfigNotFoundError,
|
||||
wantBuildWorkerError,
|
||||
wantIndexingError,
|
||||
buildManifestMatcher,
|
||||
workerManifestMatcher,
|
||||
runs,
|
||||
}) => {
|
||||
let resolvedConfig: Awaited<ReturnType<typeof loadConfig>>;
|
||||
|
||||
const configExpect = expect(
|
||||
(async () => {
|
||||
resolvedConfig = await loadConfig({
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
})(),
|
||||
wantConfigNotFoundError || wantConfigInvalidError
|
||||
? "does not resolve config"
|
||||
: "resolves config"
|
||||
);
|
||||
|
||||
if (wantConfigNotFoundError) {
|
||||
await configExpect.rejects.toThrowError();
|
||||
return;
|
||||
}
|
||||
|
||||
await configExpect.resolves.not.toThrowError();
|
||||
|
||||
if (wantConfigInvalidError) {
|
||||
expect(resolvedConfig!).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(resolvedConfig!).toBeTruthy();
|
||||
|
||||
if (tsconfig) {
|
||||
const tscResult = await runTsc(
|
||||
workspaceDir,
|
||||
tsconfig,
|
||||
packageManager === "yarn" ? fixtureDir : undefined
|
||||
);
|
||||
|
||||
expect(tscResult.success).toBe(true);
|
||||
}
|
||||
|
||||
const destination = getTmpDir(workspaceDir, "build");
|
||||
|
||||
let buildManifest: BuildManifest;
|
||||
|
||||
const buildExpect = expect(
|
||||
(async () => {
|
||||
buildManifest = await buildWorker({
|
||||
target: "deploy",
|
||||
environment: "test",
|
||||
destination: destination.path,
|
||||
resolvedConfig: resolvedConfig!,
|
||||
rewritePaths: false,
|
||||
forcedExternals: alwaysExternal,
|
||||
});
|
||||
})(),
|
||||
wantBuildWorkerError ? "does not build" : "builds"
|
||||
);
|
||||
|
||||
if (wantBuildWorkerError) {
|
||||
await buildExpect.rejects.toThrowError();
|
||||
return;
|
||||
}
|
||||
|
||||
await buildExpect.resolves.not.toThrowError();
|
||||
|
||||
if (buildManifestMatcher) {
|
||||
for (const external of buildManifestMatcher.externals ?? []) {
|
||||
expect(buildManifest!.externals).toContainEqual(external);
|
||||
}
|
||||
|
||||
for (const file of buildManifestMatcher.files ?? []) {
|
||||
const found = (buildManifestMatcher.files ?? []).find((f) => f?.entry === file?.entry);
|
||||
expect(found).toBeTruthy();
|
||||
}
|
||||
} else {
|
||||
expect(buildManifest!).toBeTruthy();
|
||||
}
|
||||
|
||||
logger.debug("Build manifest", buildManifest!);
|
||||
|
||||
const rewrittenManifest = rewriteBuildManifestPaths(buildManifest!, destination.path);
|
||||
|
||||
if (resolvedConfig!.instrumentedPackageNames?.length ?? 0 > 0) {
|
||||
expect(rewrittenManifest.loaderEntryPoint).toBe("/app/src/entryPoints/loader.mjs");
|
||||
} else {
|
||||
expect(rewrittenManifest.loaderEntryPoint).toBeUndefined();
|
||||
}
|
||||
|
||||
expect(rewrittenManifest.indexWorkerEntryPoint).toBe(
|
||||
"/app/src/entryPoints/managed-index-worker.mjs"
|
||||
);
|
||||
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
|
||||
let workerManifest: WorkerManifest;
|
||||
|
||||
const indexExpect = expect(
|
||||
(async () => {
|
||||
workerManifest = await indexWorkerManifest({
|
||||
runtime: buildManifest!.runtime,
|
||||
indexWorkerPath: buildManifest!.indexWorkerEntryPoint,
|
||||
buildManifestPath: path.join(destination.path, "build.json"),
|
||||
nodeOptions: buildManifest!.loaderEntryPoint
|
||||
? `--import=${normalizeImportPath(buildManifest!.loaderEntryPoint)}`
|
||||
: undefined,
|
||||
env: testCase.envVars ?? {},
|
||||
otelHookExclude: buildManifest!.otelImportHook?.exclude,
|
||||
otelHookInclude: buildManifest!.otelImportHook?.include,
|
||||
handleStdout(data) {
|
||||
stdout.push(data);
|
||||
logger.debug("indexWorkerManifest handleStdout");
|
||||
logger.debug(data);
|
||||
},
|
||||
handleStderr(data) {
|
||||
if (!data.includes("DeprecationWarning")) {
|
||||
stderr.push(data);
|
||||
logger.debug("indexWorkerManifest handleStderr");
|
||||
logger.debug(data);
|
||||
}
|
||||
},
|
||||
});
|
||||
})(),
|
||||
wantIndexingError ? "does not index" : "indexes"
|
||||
);
|
||||
|
||||
if (wantIndexingError) {
|
||||
await indexExpect.rejects.toThrowError();
|
||||
return;
|
||||
}
|
||||
|
||||
await indexExpect.resolves.not.toThrowError();
|
||||
|
||||
if (workerManifestMatcher) {
|
||||
expect(workerManifest!).toMatchObject(workerManifestMatcher);
|
||||
} else {
|
||||
expect(workerManifest!).toBeTruthy();
|
||||
}
|
||||
|
||||
logger.debug("Worker manifest", workerManifest!);
|
||||
|
||||
if (runs && runs.length > 0) {
|
||||
await writeJSONFile(path.join(destination.path, "index.json"), workerManifest!);
|
||||
}
|
||||
|
||||
for (const taskRun of runs || []) {
|
||||
const { result, totalDurationMs, spans } = await executeTestCaseRun({
|
||||
run: taskRun,
|
||||
testCase,
|
||||
destination: destination.path,
|
||||
workerManifest: workerManifest!,
|
||||
contentHash: buildManifest!.contentHash,
|
||||
});
|
||||
|
||||
logger.debug("Task run result", result);
|
||||
|
||||
expect(result.ok).toBe(taskRun.result.ok);
|
||||
|
||||
if (result.ok) {
|
||||
if (taskRun.result.durationMs) {
|
||||
expect(totalDurationMs).toBeGreaterThanOrEqual(taskRun.result.durationMs);
|
||||
}
|
||||
|
||||
if (taskRun.result.output) {
|
||||
expect(result.output).toEqual(taskRun.result.output);
|
||||
}
|
||||
|
||||
if (taskRun.result.outputType) {
|
||||
expect(result.outputType).toEqual(taskRun.result.outputType);
|
||||
}
|
||||
|
||||
if (taskRun.result.spans) {
|
||||
for (const spanName of taskRun.result.spans) {
|
||||
const foundSpan = spans.find((span) => span.name === spanName);
|
||||
|
||||
expect(foundSpan).toBeTruthy();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { BuildManifest, WorkerManifest } from "@trigger.dev/core/v3/schemas";
|
||||
|
||||
type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
export interface TestCaseRun {
|
||||
task: {
|
||||
id: string;
|
||||
filePath: string;
|
||||
exportName?: string;
|
||||
};
|
||||
payload: string;
|
||||
payloadType?: string;
|
||||
result: {
|
||||
ok: boolean;
|
||||
durationMs?: number;
|
||||
output?: string;
|
||||
outputType?: string;
|
||||
spans?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestCase {
|
||||
resolveEnv?: { [key: string]: string };
|
||||
id: string;
|
||||
workspaceRelativeDir?: string;
|
||||
wantConfigNotFoundError?: boolean;
|
||||
wantConfigInvalidError?: boolean;
|
||||
wantBuildWorkerError?: boolean;
|
||||
wantIndexingError?: boolean;
|
||||
wantWorkerError?: boolean;
|
||||
wantDependenciesError?: boolean;
|
||||
wantInstallationError?: boolean;
|
||||
buildManifestMatcher?: DeepPartial<BuildManifest>;
|
||||
workerManifestMatcher?: DeepPartial<WorkerManifest>;
|
||||
runs?: TestCaseRun[];
|
||||
tsconfig?: string;
|
||||
envVars?: { [key: string]: string };
|
||||
}
|
||||
|
||||
export const fixturesConfig: TestCase[] = [
|
||||
{
|
||||
id: "hello-world",
|
||||
buildManifestMatcher: {
|
||||
runtime: "node",
|
||||
externals: [
|
||||
{
|
||||
name: "import-in-the-middle",
|
||||
version: "3.0.1",
|
||||
},
|
||||
],
|
||||
files: [{ entry: "src/trigger/helloWorld.ts" }],
|
||||
},
|
||||
workerManifestMatcher: {
|
||||
tasks: [
|
||||
{
|
||||
id: "helloWorld",
|
||||
filePath: "src/trigger/helloWorld.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
task: { id: "helloWorld", filePath: "src/trigger/helloWorld.ts", exportName: "helloWorld" },
|
||||
payload: "{}",
|
||||
result: { ok: true, durationMs: 500 },
|
||||
},
|
||||
],
|
||||
tsconfig: "tsconfig.json",
|
||||
},
|
||||
{
|
||||
id: "otel-telemetry-loader",
|
||||
buildManifestMatcher: {
|
||||
runtime: "node",
|
||||
externals: [
|
||||
{
|
||||
name: "openai",
|
||||
version: "4.47.0",
|
||||
},
|
||||
{
|
||||
name: "import-in-the-middle",
|
||||
version: "3.0.1",
|
||||
},
|
||||
],
|
||||
files: [{ entry: "src/trigger/ai.ts" }],
|
||||
},
|
||||
workerManifestMatcher: {
|
||||
tasks: [
|
||||
{
|
||||
id: "ai",
|
||||
filePath: "src/trigger/ai.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
task: { id: "ai", filePath: "src/trigger/ai.ts" },
|
||||
payload: '{"prompt":"be funny"}',
|
||||
result: { ok: true, durationMs: 1 },
|
||||
},
|
||||
],
|
||||
tsconfig: "tsconfig.json",
|
||||
envVars: {
|
||||
OPENAI_API_KEY: "my-api-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "emit-decorator-metadata",
|
||||
buildManifestMatcher: {
|
||||
runtime: "node",
|
||||
externals: [
|
||||
{
|
||||
name: "import-in-the-middle",
|
||||
version: "3.0.1",
|
||||
},
|
||||
],
|
||||
files: [{ entry: "src/trigger/decorators.ts" }],
|
||||
},
|
||||
workerManifestMatcher: {
|
||||
tasks: [
|
||||
{
|
||||
id: "decoratorsTask",
|
||||
filePath: "src/trigger/decorators.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
task: { id: "decoratorsTask", filePath: "src/trigger/decorators.ts" },
|
||||
payload: "{}",
|
||||
result: { ok: true, durationMs: 1 },
|
||||
},
|
||||
],
|
||||
tsconfig: "tsconfig.json",
|
||||
},
|
||||
{
|
||||
id: "monorepo-react-email",
|
||||
workspaceRelativeDir: "packages/trigger",
|
||||
tsconfig: "tsconfig.json",
|
||||
buildManifestMatcher: {
|
||||
runtime: "node",
|
||||
externals: [
|
||||
{
|
||||
name: "import-in-the-middle",
|
||||
version: "3.0.1",
|
||||
},
|
||||
],
|
||||
files: [{ entry: "src/reactEmail.tsx" }],
|
||||
},
|
||||
workerManifestMatcher: {
|
||||
tasks: [
|
||||
{
|
||||
id: "react-email",
|
||||
filePath: "src/reactEmail.tsx",
|
||||
},
|
||||
],
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
task: { id: "react-email", filePath: "src/reactEmail.tsx" },
|
||||
payload: "{}",
|
||||
result: {
|
||||
ok: true,
|
||||
output:
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><html dir="ltr" lang="en"><a href="https://example.com" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background:#000;color:#fff;padding:12px 20px 12px 20px" target="_blank"><span><!--[if mso]><i style="mso-font-width:500%;mso-text-raise:18" hidden>  </i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Click me</span><span><!--[if mso]><i style="mso-font-width:500%" hidden>  ​</i><![endif]--></span></a></html><!--/$-->',
|
||||
outputType: "text/plain",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "esm-only-external",
|
||||
buildManifestMatcher: {
|
||||
runtime: "node",
|
||||
externals: [
|
||||
{
|
||||
name: "import-in-the-middle",
|
||||
version: "3.0.1",
|
||||
},
|
||||
{
|
||||
name: "mupdf",
|
||||
version: "0.3.0",
|
||||
},
|
||||
],
|
||||
files: [{ entry: "src/trigger/helloWorld.ts" }],
|
||||
},
|
||||
workerManifestMatcher: {
|
||||
tasks: [
|
||||
{
|
||||
id: "helloWorld",
|
||||
filePath: "src/trigger/helloWorld.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
task: { id: "helloWorld", filePath: "src/trigger/helloWorld.ts" },
|
||||
payload: "{}",
|
||||
result: { ok: true, durationMs: 1 },
|
||||
},
|
||||
],
|
||||
tsconfig: "tsconfig.json",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "emit-decorator-metadata",
|
||||
"private": true,
|
||||
"packageManager": "yarn@4.2.2+sha512.c44e283c54e02de9d1da8687025b030078c1b9648d2895a65aab8e64225bfb7becba87e1809fc0b4b6778bbd47a1e2ab6ac647de4c5e383a53a7c17db6c3ff4b",
|
||||
"engines": {
|
||||
"pnpm": "10.33.2",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@trigger.dev/sdk": "0.0.0-prerelease-20250321122618",
|
||||
"reflect-metadata": "0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trigger.dev/build": "0.0.0-prerelease-20250321122618",
|
||||
"@types/node": "^22.20.0",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# https://github.com/pnpm/pnpm/issues/2412
|
||||
packages:
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,56 @@
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
import "reflect-metadata";
|
||||
|
||||
class Point {
|
||||
constructor(
|
||||
public x: number,
|
||||
public y: number
|
||||
) {}
|
||||
}
|
||||
|
||||
class Line {
|
||||
private _start: Point;
|
||||
private _end: Point;
|
||||
|
||||
@validate
|
||||
set start(value: Point) {
|
||||
this._start = value;
|
||||
}
|
||||
|
||||
get start() {
|
||||
return this._start;
|
||||
}
|
||||
|
||||
@validate
|
||||
set end(value: Point) {
|
||||
this._end = value;
|
||||
}
|
||||
|
||||
get end() {
|
||||
return this._end;
|
||||
}
|
||||
}
|
||||
|
||||
function validate<T>(target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<T>) {
|
||||
let set = descriptor.set!;
|
||||
|
||||
descriptor.set = function (value: T) {
|
||||
let type = Reflect.getMetadata("design:type", target, propertyKey);
|
||||
|
||||
if (!(value instanceof type)) {
|
||||
throw new TypeError(`Invalid type, got ${typeof value} not ${type.name}.`);
|
||||
}
|
||||
|
||||
set.call(this, value);
|
||||
};
|
||||
}
|
||||
|
||||
export const decoratorsTask = task({
|
||||
id: "decoratorsTask",
|
||||
run: async () => {
|
||||
const line = new Line();
|
||||
line.start = new Point(0, 0);
|
||||
|
||||
console.log("Hello, World!", { line });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
import { emitDecoratorMetadata } from "@trigger.dev/build/extensions/typescript";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<fixture project>",
|
||||
dirs: ["./src/trigger"],
|
||||
build: {
|
||||
extensions: [emitDecoratorMetadata()],
|
||||
},
|
||||
maxDuration: 3600,
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "esm-only-external",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "yarn@4.2.2+sha256.1aa43a5304405be7a7cb9cb5de7b97de9c4e8ddd3273e4dad00d6ae3eb39f0ef",
|
||||
"engines": {
|
||||
"pnpm": "8.15.5",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@trigger.dev/sdk": "0.0.0-prerelease-20250321122618",
|
||||
"mupdf": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# https://github.com/pnpm/pnpm/issues/2412
|
||||
packages:
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,11 @@
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
import * as mupdf from "mupdf";
|
||||
|
||||
export const helloWorld = task({
|
||||
id: "helloWorld",
|
||||
run: async () => {
|
||||
console.log("Hello, World!", {
|
||||
metaformat: mupdf.PDFDocument.META_FORMAT,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<fixture project>",
|
||||
dirs: ["./src/trigger"],
|
||||
build: {
|
||||
external: ["mupdf"],
|
||||
},
|
||||
maxDuration: 3600,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
+2035
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "hello-world",
|
||||
"private": true,
|
||||
"packageManager": "yarn@4.2.2+sha256.1aa43a5304405be7a7cb9cb5de7b97de9c4e8ddd3273e4dad00d6ae3eb39f0ef",
|
||||
"engines": {
|
||||
"pnpm": "8.15.5",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@trigger.dev/sdk": "0.0.0-prerelease-20250321122618"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
+1180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# https://github.com/pnpm/pnpm/issues/2412
|
||||
packages:
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,10 @@
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
export const helloWorld = task({
|
||||
id: "helloWorld",
|
||||
run: async () => {
|
||||
await setTimeout(1000);
|
||||
console.log("Hello, World!");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<fixture project>",
|
||||
dirs: ["./src/trigger"],
|
||||
maxDuration: 3600,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "monorepo-react-email",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@8.15.5+sha256.4b4efa12490e5055d59b9b9fc9438b7d581a6b7af3b5675eb5c5f447cee1a589",
|
||||
"engines": {
|
||||
"pnpm": "8.15.5",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@repo/email",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.0.24",
|
||||
"@react-email/render": "1.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-email": "^3.0.1"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button, Html } from "@react-email/components";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
function ExampleEmail(props: {}) {
|
||||
return (
|
||||
<Html>
|
||||
<Button
|
||||
href="https://example.com"
|
||||
style={{ background: "#000", color: "#fff", padding: "12px 20px" }}
|
||||
>
|
||||
Click me
|
||||
</Button>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderExampleEmail() {
|
||||
return render(<ExampleEmail />);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./emails";
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@repo/trigger",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@repo/email": "workspace:*",
|
||||
"@trigger.dev/sdk": "0.0.0-prerelease-20250321122618"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
import { renderExampleEmail } from "@repo/email";
|
||||
|
||||
export const reactEmail = task({
|
||||
id: "react-email",
|
||||
run: async () => {
|
||||
return await renderExampleEmail();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<fixture project>",
|
||||
dirs: ["./src"],
|
||||
maxDuration: 3600,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# https://github.com/pnpm/pnpm/issues/2412
|
||||
packages:
|
||||
- "packages/*"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "otel-telemetry-loader",
|
||||
"private": true,
|
||||
"packageManager": "yarn@4.2.2+sha256.1aa43a5304405be7a7cb9cb5de7b97de9c4e8ddd3273e4dad00d6ae3eb39f0ef",
|
||||
"engines": {
|
||||
"pnpm": "8.15.5",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@trigger.dev/sdk": "0.0.0-prerelease-20250321122618",
|
||||
"openai": "4.47.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@traceloop/instrumentation-openai": "^0.10.0",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# https://github.com/pnpm/pnpm/issues/2412
|
||||
packages:
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,20 @@
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
import OpenAI from "openai";
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_BASE_URL,
|
||||
});
|
||||
|
||||
export const aiTask = task({
|
||||
id: "ai",
|
||||
run: async (payload: { prompt: string }) => {
|
||||
const chatCompletion = await openai.chat.completions.create({
|
||||
messages: [{ role: "user", content: payload.prompt }],
|
||||
model: "gpt-3.5-turbo",
|
||||
});
|
||||
|
||||
return chatCompletion.choices[0];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
import { OpenAIInstrumentation } from "@traceloop/instrumentation-openai";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<fixture project>",
|
||||
dirs: ["./src/trigger"],
|
||||
instrumentations: [new OpenAIInstrumentation()],
|
||||
maxDuration: 3600,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const LogLevelSchema = z.enum(["debug", "info", "log", "warn", "error", "none"]).default("log");
|
||||
const PackageManagerSchema = z.enum(["npm", "pnpm", "yarn"]);
|
||||
export const E2EOptionsSchema = z.object({
|
||||
logLevel: LogLevelSchema,
|
||||
packageManager: PackageManagerSchema.optional(),
|
||||
});
|
||||
export type E2EOptions = z.infer<typeof E2EOptionsSchema>;
|
||||
@@ -0,0 +1,485 @@
|
||||
import { execa } from "execa";
|
||||
import * as nodePath from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import { logger } from "../src/utilities/logger.js";
|
||||
import { findUpMultiple, findUp } from "find-up";
|
||||
import type { TaskRunExecutionResult, WorkerManifest } from "@trigger.dev/core/v3/schemas";
|
||||
import { TaskRunProcess } from "../src/executions/taskRunProcess.js";
|
||||
import { createTestHttpServer } from "@epic-web/test-server/http";
|
||||
import type { TestCase, TestCaseRun } from "./fixtures.js";
|
||||
import { access } from "node:fs/promises";
|
||||
import type { MachinePreset } from "@trigger.dev/core/v3";
|
||||
|
||||
export type PackageManager = "npm" | "pnpm" | "yarn";
|
||||
|
||||
export const LOCKFILES = {
|
||||
npm: "package-lock.json",
|
||||
npmShrinkwrap: "npm-shrinkwrap.json",
|
||||
pnpm: "pnpm-lock.yaml",
|
||||
yarn: "yarn.lock",
|
||||
bun: "bun.lockb",
|
||||
};
|
||||
|
||||
export async function installFixtureDeps(options: {
|
||||
fixtureDir: string;
|
||||
packageManager: PackageManager;
|
||||
workspaceDir: string;
|
||||
}) {
|
||||
const { packageManager, workspaceDir } = options;
|
||||
|
||||
if (["pnpm", "yarn"].includes(packageManager)) {
|
||||
const version = await detectPackageManagerVersion(options);
|
||||
|
||||
debug(`Detected ${packageManager}@${version} from package.json 'engines' field`);
|
||||
|
||||
const { stdout, stderr } = await execa("corepack", ["use", `${packageManager}@${version}`], {
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
debug(stdout);
|
||||
|
||||
if (stderr) console.error(stderr);
|
||||
} else {
|
||||
const { stdout, stderr } = await execa(packageManager, installArgs(packageManager), {
|
||||
cwd: workspaceDir,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_PATH: nodePath.resolve(nodePath.join(workspaceDir, "node_modules")),
|
||||
},
|
||||
});
|
||||
|
||||
debug(stdout);
|
||||
|
||||
if (stderr) console.error(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
export async function parsePackageManager(
|
||||
packageManager: PackageManager | undefined,
|
||||
fixtureDir: string
|
||||
): Promise<PackageManager> {
|
||||
let $packageManager: PackageManager;
|
||||
|
||||
if (packageManager) {
|
||||
$packageManager = packageManager;
|
||||
} else {
|
||||
$packageManager = await detectPackageManagerFromArtifacts(fixtureDir);
|
||||
}
|
||||
|
||||
return $packageManager;
|
||||
}
|
||||
|
||||
export async function detectPackageManagerFromArtifacts(path: string): Promise<PackageManager> {
|
||||
const foundPath = await findUp(Object.values(LOCKFILES), { cwd: path });
|
||||
|
||||
if (!foundPath) {
|
||||
throw new Error("Could not detect package manager from artifacts");
|
||||
}
|
||||
|
||||
logger.debug("Found path from package manager artifacts", { foundPath });
|
||||
|
||||
switch (nodePath.basename(foundPath)) {
|
||||
case LOCKFILES.yarn:
|
||||
logger.debug("Found yarn artifact", { foundPath });
|
||||
return "yarn";
|
||||
case LOCKFILES.pnpm:
|
||||
logger.debug("Found pnpm artifact", { foundPath });
|
||||
return "pnpm";
|
||||
case LOCKFILES.npm:
|
||||
case LOCKFILES.npmShrinkwrap:
|
||||
logger.debug("Found npm artifact", { foundPath });
|
||||
return "npm";
|
||||
case LOCKFILES.bun:
|
||||
logger.debug("Found bun artifact", { foundPath });
|
||||
return "npm";
|
||||
default:
|
||||
throw new Error(`Unhandled package manager detection path: ${foundPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function debug(message: string) {
|
||||
if (logger.loggerLevel === "debug") {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
function installArgs(packageManager: string) {
|
||||
switch (packageManager) {
|
||||
case "bun":
|
||||
return ["install", "--frozen-lockfile"];
|
||||
case "pnpm":
|
||||
case "yarn":
|
||||
throw new Error("pnpm and yarn must install using `corepack use`");
|
||||
case "npm":
|
||||
return ["ci", "--no-audit"];
|
||||
default:
|
||||
throw new Error(`Unknown package manager '${packageManager}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async function detectPackageManagerVersion(options: {
|
||||
fixtureDir: string;
|
||||
packageManager: PackageManager;
|
||||
workspaceDir: string;
|
||||
}): Promise<string> {
|
||||
const { fixtureDir, packageManager, workspaceDir } = options;
|
||||
const pkgPaths = await findUpMultiple("package.json", { cwd: workspaceDir, stopAt: fixtureDir });
|
||||
for (let pkgPath of pkgPaths) {
|
||||
const buffer = fs.readFileSync(pkgPath, "utf8");
|
||||
const pkgJSON = JSON.parse(buffer.toString());
|
||||
if (!pkgJSON.engines) continue;
|
||||
const version = pkgJSON.engines[packageManager];
|
||||
if (version) return version;
|
||||
}
|
||||
|
||||
throw new Error(`No version found for package manager ${packageManager}`);
|
||||
}
|
||||
|
||||
export interface TypeScriptError {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TscResult {
|
||||
success: boolean;
|
||||
errors: TypeScriptError[];
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function runTsc(
|
||||
cwd: string,
|
||||
tsconfigName: string = "tsconfig.json",
|
||||
binBasePath: string = cwd
|
||||
): Promise<TscResult> {
|
||||
const tsconfigPath = nodePath.join(cwd, tsconfigName);
|
||||
const tscPath = nodePath.join(binBasePath, "node_modules", ".bin", "tsc");
|
||||
|
||||
// Ensure the tsconfig file exists
|
||||
try {
|
||||
await access(tsconfigPath);
|
||||
} catch (_error) {
|
||||
throw new Error(`TSConfig file not found: ${tsconfigPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Running TypeScript compiler: ${tscPath} --project ${tsconfigPath} --noEmit`, {
|
||||
cwd,
|
||||
});
|
||||
|
||||
const result = await execa(tscPath, ["--project", tsconfigPath, "--noEmit"], {
|
||||
cwd,
|
||||
reject: false,
|
||||
});
|
||||
|
||||
const success = result.exitCode === 0;
|
||||
const errors = success ? [] : parseTypeScriptErrors(result.stderr);
|
||||
|
||||
logger.debug(result.stdout);
|
||||
logger.debug(result.stderr);
|
||||
|
||||
return {
|
||||
success,
|
||||
errors,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run TypeScript compiler: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseTypeScriptErrors(stderr: string): TypeScriptError[] {
|
||||
const errorRegex = /(.+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g;
|
||||
const errors: TypeScriptError[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = errorRegex.exec(stderr)) !== null) {
|
||||
errors.push({
|
||||
file: match[1]!,
|
||||
line: parseInt(match[2]!, 10),
|
||||
column: parseInt(match[3]!, 10),
|
||||
message: match[4]!,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export type ExecuteTaskCaseRunOptions = {
|
||||
testCase: TestCase;
|
||||
run: TestCaseRun;
|
||||
destination: string;
|
||||
workerManifest: WorkerManifest;
|
||||
contentHash: string;
|
||||
};
|
||||
|
||||
export type ExecuteTaskRunUsageReport = {
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type ExecuteTaskRunResult = {
|
||||
result: TaskRunExecutionResult;
|
||||
usageReports: Array<ExecuteTaskRunUsageReport>;
|
||||
totalDurationMs: number;
|
||||
spans: Array<ExecuteTaskTraceEvent>;
|
||||
};
|
||||
|
||||
export type ExecuteTaskTraceEvent = {
|
||||
name: string;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId?: string;
|
||||
durationMs: number;
|
||||
attributes?: { [key: string]: string | number | boolean | undefined };
|
||||
};
|
||||
|
||||
export async function executeTestCaseRun({
|
||||
run,
|
||||
testCase,
|
||||
destination,
|
||||
workerManifest,
|
||||
contentHash,
|
||||
}: ExecuteTaskCaseRunOptions): Promise<ExecuteTaskRunResult> {
|
||||
const usageReports: Array<ExecuteTaskRunUsageReport> = [];
|
||||
const spans: Array<ExecuteTaskTraceEvent> = [];
|
||||
|
||||
// Create a disposable "server" instance.
|
||||
const server = await createTestHttpServer({
|
||||
defineRoutes(router) {
|
||||
router.post("/usage", async ({ req }) => {
|
||||
const jsonBody = await req.json();
|
||||
|
||||
usageReports.push({
|
||||
durationMs: jsonBody.durationMs,
|
||||
});
|
||||
|
||||
return Response.json({});
|
||||
});
|
||||
router.post("/v1/traces", async ({ req }) => {
|
||||
const jsonBody = await req.json();
|
||||
|
||||
spans.push(...parseTraceBodyIntoEvents(jsonBody));
|
||||
// TODO: Implement trace endpoint
|
||||
return Response.json({});
|
||||
});
|
||||
router.post("/v1/logs", () => {
|
||||
// TODO: Implement logs endpoint
|
||||
return Response.json({});
|
||||
});
|
||||
router.post("/v1/chat/completions", async ({ req }) => {
|
||||
return Response.json({
|
||||
id: "chatcmpl-7XYZ123ABC456DEF789GHI",
|
||||
object: "chat.completion",
|
||||
created: 1631619199,
|
||||
model: "gpt-3.5-turbo-0613",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content:
|
||||
"The capital of France is Paris. Paris is not only the political capital but also the cultural and economic center of France. It's known for its iconic landmarks such as the Eiffel Tower, the Louvre Museum, and Notre-Dame Cathedral.",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 29,
|
||||
completion_tokens: 48,
|
||||
total_tokens: 77,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const machine = {
|
||||
name: "small-1x",
|
||||
cpu: 1,
|
||||
memory: 256,
|
||||
centsPerMs: 0.0000001,
|
||||
} satisfies MachinePreset;
|
||||
|
||||
try {
|
||||
const taskRunProcess = new TaskRunProcess({
|
||||
workerManifest: workerManifest!,
|
||||
cwd: destination,
|
||||
env: {
|
||||
USAGE_EVENT_URL: server.http.url("/usage").href,
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: server.http.url().origin,
|
||||
TRIGGER_JWT: "test-jwt",
|
||||
TRIGGER_SECRET_KEY: "test-secret",
|
||||
TRIGGER_API_URL: server.http.url().origin,
|
||||
USAGE_HEARTBEAT_INTERVAL_MS: "500",
|
||||
OPENAI_API_KEY: "api-key",
|
||||
OPENAI_BASE_URL: server.http.url().origin + "/v1",
|
||||
},
|
||||
serverWorker: {
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
contentHash,
|
||||
},
|
||||
machineResources: machine,
|
||||
}).initialize();
|
||||
|
||||
const result = await taskRunProcess.execute({
|
||||
payload: {
|
||||
traceContext: {
|
||||
traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
||||
},
|
||||
environment: {},
|
||||
execution: {
|
||||
task: run.task,
|
||||
attempt: {
|
||||
id: "attempt_1234",
|
||||
status: "RUNNING",
|
||||
number: 1,
|
||||
startedAt: new Date(),
|
||||
backgroundWorkerId: "worker_1234",
|
||||
backgroundWorkerTaskId: "task_1234",
|
||||
},
|
||||
run: {
|
||||
id: "run_1234",
|
||||
startedAt: new Date(),
|
||||
payload: run.payload,
|
||||
payloadType: run.payloadType ?? "application/json",
|
||||
tags: [],
|
||||
context: {},
|
||||
isTest: false,
|
||||
createdAt: new Date(),
|
||||
durationMs: 0,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
version: "1.0.0",
|
||||
},
|
||||
queue: {
|
||||
id: "queue_1234",
|
||||
name: "test",
|
||||
},
|
||||
environment: {
|
||||
type: "DEVELOPMENT",
|
||||
id: "env_1234",
|
||||
slug: "dev",
|
||||
},
|
||||
organization: {
|
||||
id: "org_1234",
|
||||
slug: "test",
|
||||
name: "test",
|
||||
},
|
||||
project: {
|
||||
id: "project_1234",
|
||||
slug: "test",
|
||||
ref: "main",
|
||||
name: "test",
|
||||
},
|
||||
machine,
|
||||
},
|
||||
},
|
||||
messageId: "run_1234",
|
||||
});
|
||||
|
||||
await taskRunProcess.cleanup(true);
|
||||
|
||||
return {
|
||||
result,
|
||||
usageReports,
|
||||
totalDurationMs: usageReports.reduce((acc, report) => acc + report.durationMs, 0),
|
||||
spans,
|
||||
};
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
function parseTraceBodyIntoEvents(body: any): ExecuteTaskTraceEvent[] {
|
||||
return body.resourceSpans.flatMap(parseResourceSpanIntoEvents);
|
||||
}
|
||||
|
||||
function parseResourceSpanIntoEvents(resourceSpan: any): ExecuteTaskTraceEvent[] {
|
||||
return resourceSpan.scopeSpans.flatMap((scopeSpan: any) =>
|
||||
parseScopeSpanIntoEvents(scopeSpan, resourceSpan.resource)
|
||||
);
|
||||
}
|
||||
|
||||
function parseScopeSpanIntoEvents(scopeSpan: any, resource: any): ExecuteTaskTraceEvent[] {
|
||||
return scopeSpan.spans.flatMap((span: any) => parseSpanInEvent(span, resource));
|
||||
}
|
||||
|
||||
function parseSpanInEvent(span: any, resource: any): ExecuteTaskTraceEvent {
|
||||
return {
|
||||
name: span.name,
|
||||
traceId: span.traceId,
|
||||
spanId: span.spanId,
|
||||
parentSpanId: span.parentSpanId,
|
||||
durationMs: calculateSpanDurationMs(span),
|
||||
attributes: {
|
||||
...parseAttributes(resource.attributes),
|
||||
...parseAttributes(span.attributes),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function calculateSpanDurationMs(span: any): number {
|
||||
return Number(BigInt(span.endTimeUnixNano) - BigInt(span.startTimeUnixNano) / BigInt(1e6));
|
||||
}
|
||||
|
||||
function parseAttributes(attributes: any): ExecuteTaskTraceEvent["attributes"] {
|
||||
if (!attributes) return {};
|
||||
|
||||
return attributes.reduce((acc: any, attribute: any) => {
|
||||
acc[attribute.key] = isStringValue(attribute.value)
|
||||
? attribute.value.stringValue
|
||||
: isIntValue(attribute.value)
|
||||
? Number(attribute.value.intValue)
|
||||
: isDoubleValue(attribute.value)
|
||||
? attribute.value.doubleValue
|
||||
: isBoolValue(attribute.value)
|
||||
? attribute.value.boolValue
|
||||
: isBytesValue(attribute.value)
|
||||
? binaryToHex(attribute.value.bytesValue)
|
||||
: undefined;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function isBoolValue(value: any | undefined): value is { boolValue: boolean } {
|
||||
if (!value) return false;
|
||||
|
||||
return typeof value.boolValue === "boolean";
|
||||
}
|
||||
|
||||
function isStringValue(value: any | undefined): value is { stringValue: string } {
|
||||
if (!value) return false;
|
||||
|
||||
return typeof value.stringValue === "string";
|
||||
}
|
||||
|
||||
function isIntValue(value: any | undefined): value is { intValue: bigint } {
|
||||
if (!value) return false;
|
||||
|
||||
return typeof value.intValue === "number";
|
||||
}
|
||||
|
||||
function isDoubleValue(value: any | undefined): value is { doubleValue: number } {
|
||||
if (!value) return false;
|
||||
|
||||
return typeof value.doubleValue === "number";
|
||||
}
|
||||
|
||||
function isBytesValue(value: any | undefined): value is { bytesValue: Buffer } {
|
||||
if (!value) return false;
|
||||
|
||||
return Buffer.isBuffer(value.bytesValue);
|
||||
}
|
||||
function binaryToHex(buffer: Buffer | string): string;
|
||||
function binaryToHex(buffer: Buffer | string | undefined): string | undefined;
|
||||
function binaryToHex(buffer: Buffer | string | undefined): string | undefined {
|
||||
if (!buffer) return undefined;
|
||||
if (typeof buffer === "string") return buffer;
|
||||
|
||||
return Buffer.from(Array.from(buffer)).toString("hex");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
exclude: [...configDefaults.exclude, "src/**/*"],
|
||||
},
|
||||
});
|
||||
Executable
+582
@@ -0,0 +1,582 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Default target
|
||||
TARGET="all"
|
||||
|
||||
# Parse command line arguments
|
||||
show_help() {
|
||||
echo "🚀 Trigger.dev MCP Server Installer"
|
||||
echo ""
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -t, --target TARGET Install target: claude, claude-desktop, cursor, vscode, crush, windsurf, or all (default: all)"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Targets:"
|
||||
echo " claude Install for Claude Code (~/.claude.json)"
|
||||
echo " claude-desktop Install for Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json)"
|
||||
echo " cursor Install for Cursor (~/.cursor/mcp.json)"
|
||||
echo " vscode Install for VS Code (~/Library/Application Support/Code/User/mcp.json)"
|
||||
echo " crush Install for Crush (~/.config/crush/crush.json)"
|
||||
echo " windsurf Install for Windsurf (~/.codeium/windsurf/mcp_config.json)"
|
||||
echo " all Install for all supported targets"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Install for all targets"
|
||||
echo " $0 -t claude # Install only for Claude Code"
|
||||
echo " $0 -t claude-desktop # Install only for Claude Desktop"
|
||||
echo " $0 -t cursor # Install only for Cursor"
|
||||
echo " $0 -t vscode # Install only for VS Code"
|
||||
echo " $0 -t crush # Install only for Crush"
|
||||
echo " $0 -t windsurf # Install only for Windsurf"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-t|--target)
|
||||
TARGET="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ Unknown option: $1"
|
||||
echo "Use -h or --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate target
|
||||
case $TARGET in
|
||||
claude|claude-desktop|cursor|vscode|crush|windsurf|all)
|
||||
;;
|
||||
*)
|
||||
echo "❌ Invalid target: $TARGET"
|
||||
echo "Valid targets are: claude, claude-desktop, cursor, vscode, crush, windsurf, all"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "🚀 Installing Trigger.dev MCP Server for target: $TARGET"
|
||||
|
||||
# Get the absolute path to the node binary
|
||||
NODE_PATH=$(which node)
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
echo "❌ Error: Node.js not found in PATH"
|
||||
echo "Please ensure Node.js is installed and available in your PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Construct the path to the CLI index.js file
|
||||
CLI_PATH="$SCRIPT_DIR/dist/esm/index.js"
|
||||
|
||||
# Construct the path to the MCP log file
|
||||
MCP_LOG_FILE="$SCRIPT_DIR/.mcp.log"
|
||||
|
||||
# Make sure the MCP log file exists
|
||||
touch "$MCP_LOG_FILE"
|
||||
|
||||
# Check if the CLI file exists
|
||||
if [ ! -f "$CLI_PATH" ]; then
|
||||
echo "❌ Error: CLI file not found at $CLI_PATH"
|
||||
echo "Make sure to build the CLI first with: pnpm run build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure the CLI is executable
|
||||
chmod +x "$CLI_PATH"
|
||||
|
||||
echo "✅ Found Node.js at: $NODE_PATH"
|
||||
echo "✅ Found CLI at: $CLI_PATH"
|
||||
|
||||
# Function to install for Claude Code
|
||||
install_claude() {
|
||||
echo ""
|
||||
echo "🔧 Installing for Claude Code..."
|
||||
|
||||
local CLAUDE_CONFIG="$HOME/.claude.json"
|
||||
echo "📁 Claude configuration file: $CLAUDE_CONFIG"
|
||||
|
||||
# Check if Claude config exists, create if it doesn't
|
||||
if [ ! -f "$CLAUDE_CONFIG" ]; then
|
||||
echo "📝 Creating new Claude configuration file..."
|
||||
echo '{"mcpServers": {}}' > "$CLAUDE_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating Claude configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$CLAUDE_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.mcpServers['trigger'] = {
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to Claude Code');
|
||||
console.log('');
|
||||
console.log('📋 Claude Code Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 Try typing @ in Claude Code and select \"triggerdev\" to get started.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating Claude configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Function to install for Claude Desktop
|
||||
install_claude_desktop() {
|
||||
echo ""
|
||||
echo "🔧 Installing for Claude Desktop..."
|
||||
|
||||
local CLAUDE_DESKTOP_DIR="$HOME/Library/Application Support/Claude"
|
||||
local CLAUDE_DESKTOP_CONFIG="$CLAUDE_DESKTOP_DIR/claude_desktop_config.json"
|
||||
|
||||
echo "📁 Claude Desktop configuration file: $CLAUDE_DESKTOP_CONFIG"
|
||||
|
||||
# Create Claude Desktop directory if it doesn't exist
|
||||
if [ ! -d "$CLAUDE_DESKTOP_DIR" ]; then
|
||||
echo "📝 Creating Claude Desktop configuration directory..."
|
||||
mkdir -p "$CLAUDE_DESKTOP_DIR"
|
||||
fi
|
||||
|
||||
# Check if Claude Desktop config exists, create if it doesn't
|
||||
if [ ! -f "$CLAUDE_DESKTOP_CONFIG" ]; then
|
||||
echo "📝 Creating new Claude Desktop configuration file..."
|
||||
echo '{"mcpServers": {}}' > "$CLAUDE_DESKTOP_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating Claude Desktop configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$CLAUDE_DESKTOP_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.mcpServers['trigger'] = {
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to Claude Desktop');
|
||||
console.log('');
|
||||
console.log('📋 Claude Desktop Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 You can now use Trigger.dev MCP commands in Claude Desktop.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating Claude Desktop configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Function to install for Cursor
|
||||
install_cursor() {
|
||||
echo ""
|
||||
echo "🔧 Installing for Cursor..."
|
||||
|
||||
local CURSOR_DIR="$HOME/.cursor"
|
||||
local CURSOR_CONFIG="$CURSOR_DIR/mcp.json"
|
||||
|
||||
echo "📁 Cursor configuration file: $CURSOR_CONFIG"
|
||||
|
||||
# Create Cursor directory if it doesn't exist
|
||||
if [ ! -d "$CURSOR_DIR" ]; then
|
||||
echo "📝 Creating Cursor configuration directory..."
|
||||
mkdir -p "$CURSOR_DIR"
|
||||
fi
|
||||
|
||||
# Check if Cursor config exists, create if it doesn't
|
||||
if [ ! -f "$CURSOR_CONFIG" ]; then
|
||||
echo "📝 Creating new Cursor configuration file..."
|
||||
echo '{"mcpServers": {}}' > "$CURSOR_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating Cursor configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$CURSOR_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.mcpServers['trigger'] = {
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to Cursor');
|
||||
console.log('');
|
||||
console.log('📋 Cursor Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 You can now use Trigger.dev MCP commands in Cursor.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating Cursor configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Function to install for VS Code
|
||||
install_vscode() {
|
||||
echo ""
|
||||
echo "🔧 Installing for VS Code..."
|
||||
|
||||
local VSCODE_DIR="$HOME/Library/Application Support/Code/User"
|
||||
local VSCODE_CONFIG="$VSCODE_DIR/mcp.json"
|
||||
|
||||
echo "📁 VS Code configuration file: $VSCODE_CONFIG"
|
||||
|
||||
# Create VS Code User directory if it doesn't exist
|
||||
if [ ! -d "$VSCODE_DIR" ]; then
|
||||
echo "📝 Creating VS Code User configuration directory..."
|
||||
mkdir -p "$VSCODE_DIR"
|
||||
fi
|
||||
|
||||
# Check if VS Code config exists, create if it doesn't
|
||||
if [ ! -f "$VSCODE_CONFIG" ]; then
|
||||
echo "📝 Creating new VS Code configuration file..."
|
||||
echo '{"servers": {}}' > "$VSCODE_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating VS Code configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$VSCODE_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure servers object exists
|
||||
if (!config.servers) {
|
||||
config.servers = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.servers['trigger'] = {
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to VS Code');
|
||||
console.log('');
|
||||
console.log('📋 VS Code Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 You can now use Trigger.dev MCP commands in VS Code.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating VS Code configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Function to install for Crush
|
||||
install_crush() {
|
||||
echo ""
|
||||
echo "🔧 Installing for Crush..."
|
||||
|
||||
local CRUSH_DIR="$HOME/.config/crush"
|
||||
local CRUSH_CONFIG="$CRUSH_DIR/crush.json"
|
||||
|
||||
echo "📁 Crush configuration file: $CRUSH_CONFIG"
|
||||
|
||||
# Create Crush config directory if it doesn't exist
|
||||
if [ ! -d "$CRUSH_DIR" ]; then
|
||||
echo "📝 Creating Crush configuration directory..."
|
||||
mkdir -p "$CRUSH_DIR"
|
||||
fi
|
||||
|
||||
# Check if Crush config exists, create if it doesn't
|
||||
if [ ! -f "$CRUSH_CONFIG" ]; then
|
||||
echo "📝 Creating new Crush configuration file..."
|
||||
echo '{"$schema": "https://charm.land/crush.json", "mcp": {}}' > "$CRUSH_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating Crush configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$CRUSH_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure schema and mcp object exists
|
||||
if (!config['\$schema']) {
|
||||
config['\$schema'] = 'https://charm.land/crush.json';
|
||||
}
|
||||
if (!config.mcp) {
|
||||
config.mcp = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.mcp['trigger'] = {
|
||||
type: 'stdio',
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to Crush');
|
||||
console.log('');
|
||||
console.log('📋 Crush Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 You can now use Trigger.dev MCP commands in Crush.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating Crush configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Function to install for Windsurf
|
||||
install_windsurf() {
|
||||
echo ""
|
||||
echo "🔧 Installing for Windsurf..."
|
||||
|
||||
local WINDSURF_DIR="$HOME/.codeium/windsurf"
|
||||
local WINDSURF_CONFIG="$WINDSURF_DIR/mcp_config.json"
|
||||
|
||||
echo "📁 Windsurf configuration file: $WINDSURF_CONFIG"
|
||||
|
||||
# Create Windsurf config directory if it doesn't exist
|
||||
if [ ! -d "$WINDSURF_DIR" ]; then
|
||||
echo "📝 Creating Windsurf configuration directory..."
|
||||
mkdir -p "$WINDSURF_DIR"
|
||||
fi
|
||||
|
||||
# Check if Windsurf config exists, create if it doesn't
|
||||
if [ ! -f "$WINDSURF_CONFIG" ]; then
|
||||
echo "📝 Creating new Windsurf configuration file..."
|
||||
echo '{"mcpServers": {}}' > "$WINDSURF_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating Windsurf configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$WINDSURF_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.mcpServers['trigger'] = {
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to Windsurf');
|
||||
console.log('');
|
||||
console.log('📋 Windsurf Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 You can now use Trigger.dev MCP commands in Windsurf.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating Windsurf configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Install based on target
|
||||
case $TARGET in
|
||||
claude)
|
||||
install_claude
|
||||
;;
|
||||
claude-desktop)
|
||||
install_claude_desktop
|
||||
;;
|
||||
cursor)
|
||||
install_cursor
|
||||
;;
|
||||
vscode)
|
||||
install_vscode
|
||||
;;
|
||||
crush)
|
||||
install_crush
|
||||
;;
|
||||
windsurf)
|
||||
install_windsurf
|
||||
;;
|
||||
all)
|
||||
install_claude
|
||||
install_claude_desktop
|
||||
install_cursor
|
||||
install_vscode
|
||||
install_crush
|
||||
install_windsurf
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "🎉 Installation complete!"
|
||||
echo ""
|
||||
echo "🔍 You can test the MCP server with:"
|
||||
echo " pnpm run inspector"
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"name": "trigger.dev",
|
||||
"version": "4.5.3",
|
||||
"description": "A Command-Line Interface for Trigger.dev projects",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/triggerdotdev/trigger.dev",
|
||||
"directory": "packages/cli-v3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"mcpName": "io.github.triggerdotdev/trigger.dev",
|
||||
"keywords": [
|
||||
"typescript",
|
||||
"trigger.dev",
|
||||
"workflows",
|
||||
"orchestration",
|
||||
"events",
|
||||
"webhooks",
|
||||
"integrations",
|
||||
"apis",
|
||||
"jobs",
|
||||
"background jobs",
|
||||
"nextjs",
|
||||
"tanstack-intent"
|
||||
],
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"bin": {
|
||||
"trigger": "./dist/esm/index.js"
|
||||
},
|
||||
"tshy": {
|
||||
"selfLink": false,
|
||||
"main": false,
|
||||
"module": false,
|
||||
"dialects": [
|
||||
"esm"
|
||||
],
|
||||
"project": "./tsconfig.src.json",
|
||||
"exclude": [
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@epic-web/test-server": "^0.1.0",
|
||||
"@types/eventsource": "^1.1.15",
|
||||
"@types/gradient-string": "^1.1.2",
|
||||
"@types/ini": "^4.1.1",
|
||||
"@types/object-hash": "3.0.6",
|
||||
"@types/polka": "^0.5.7",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/rimraf": "^4.0.5",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@types/source-map-support": "0.5.10",
|
||||
"@types/ws": "^8.5.3",
|
||||
"cpy-cli": "^5.0.0",
|
||||
"execa": "^8.0.1",
|
||||
"find-up": "^7.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"ts-essentials": "10.0.1",
|
||||
"tshy": "^3.0.2",
|
||||
"tsx": "4.17.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .tshy .tshy-build .turbo",
|
||||
"typecheck": "tsc -p tsconfig.src.json --noEmit",
|
||||
"build": "tshy && pnpm run update-version",
|
||||
"dev": "tshy --watch",
|
||||
"test": "vitest",
|
||||
"test:e2e": "vitest --run -c ./e2e/vitest.config.ts",
|
||||
"update-version": "tsx ../../scripts/updateVersion.ts",
|
||||
"install-mcp": "./install-mcp.sh",
|
||||
"inspector": "npx @modelcontextprotocol/inspector dist/esm/index.js mcp --log-file .mcp.log --api-url http://localhost:3030",
|
||||
"mcp:test": "tsx src/mcp/tools.test.ts",
|
||||
"mcp:smoke": "tsx src/mcp/smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "0.11.0",
|
||||
"@depot/cli": "0.0.1-cli.2.80.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@opentelemetry/api-logs": "0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
|
||||
"@opentelemetry/instrumentation": "0.218.0",
|
||||
"@opentelemetry/instrumentation-fetch": "0.218.0",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "1.41.1",
|
||||
"@s2-dev/streamstore": "^0.22.10",
|
||||
"@trigger.dev/build": "workspace:4.5.3",
|
||||
"@trigger.dev/core": "workspace:4.5.3",
|
||||
"@trigger.dev/schema-to-json": "workspace:4.5.3",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"braces": "^3.0.3",
|
||||
"c12": "^1.11.1",
|
||||
"chalk": "^5.2.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"cli-table3": "^0.6.3",
|
||||
"commander": "^9.4.1",
|
||||
"confbox": "^0.2.2",
|
||||
"defu": "^6.1.4",
|
||||
"dotenv": "^16.4.5",
|
||||
"esbuild": "^0.23.0",
|
||||
"eventsource": "^3.0.2",
|
||||
"evt": "^2.4.13",
|
||||
"fast-npm-meta": "^0.2.2",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"gradient-string": "^2.0.2",
|
||||
"has-flag": "^5.0.1",
|
||||
"ignore": "^7.0.5",
|
||||
"import-in-the-middle": "3.0.1",
|
||||
"import-meta-resolve": "^4.1.0",
|
||||
"ini": "^5.0.0",
|
||||
"json-stable-stringify": "^1.3.0",
|
||||
"jsonc-parser": "3.2.1",
|
||||
"magicast": "^0.3.4",
|
||||
"minimatch": "^10.0.1",
|
||||
"mlly": "^1.7.1",
|
||||
"nypm": "^0.5.4",
|
||||
"object-hash": "^3.0.0",
|
||||
"open": "^10.0.3",
|
||||
"p-limit": "^6.2.0",
|
||||
"p-retry": "^6.1.0",
|
||||
"partysocket": "^1.0.2",
|
||||
"pkg-types": "^1.1.3",
|
||||
"polka": "^0.5.2",
|
||||
"resolve": "^1.22.8",
|
||||
"semver": "^7.5.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"socket.io-client": "4.7.5",
|
||||
"source-map-support": "0.5.21",
|
||||
"std-env": "^3.7.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"supports-color": "^10.0.0",
|
||||
"tar": "^7.5.13",
|
||||
"tiny-invariant": "^1.2.0",
|
||||
"tinyexec": "^0.3.1",
|
||||
"tinyglobby": "^0.2.10",
|
||||
"ws": "^8.18.0",
|
||||
"xdg-app-paths": "^8.3.0",
|
||||
"zod": "3.25.76",
|
||||
"zod-validation-error": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.20.0"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"default": "./dist/esm/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: trigger-authoring-chat-agent
|
||||
description: >
|
||||
Author and run a durable AI chat agent with chat.agent from @trigger.dev/sdk/ai: the per-turn
|
||||
run loop, why you MUST spread ...chat.toStreamTextOptions() first, returning a StreamTextResult
|
||||
vs calling chat.pipe(), the two server actions (chat.createStartSessionAction +
|
||||
auth.createPublicToken), and wiring useChat to useTriggerChatTransport. Load this when building,
|
||||
modifying, or debugging a chat backend (the agent task or its lifecycle hooks) or its React
|
||||
transport, when declaring typed tools or custom data parts, or when migrating a plain AI SDK
|
||||
streamText route to chat.agent.
|
||||
type: core
|
||||
library: trigger.dev
|
||||
---
|
||||
|
||||
# Authoring a chat.agent
|
||||
|
||||
The full, version-pinned reference ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
|
||||
|
||||
- **Skill:** `node_modules/@trigger.dev/sdk/skills/trigger-authoring-chat-agent/SKILL.md` — the per-turn run loop, `chat.toStreamTextOptions()`, the two server actions, typed tools/data parts, and the React transport.
|
||||
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/ai-chat/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "toStreamTextOptions" node_modules/@trigger.dev/sdk/docs/`.
|
||||
|
||||
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- **CRITICAL: forgetting `...chat.toStreamTextOptions()`.**
|
||||
```ts
|
||||
// Wrong - compaction / steering / background injection silently no-op
|
||||
return streamText({ model, messages, abortSignal: signal });
|
||||
// Correct - spread FIRST so explicit overrides win
|
||||
return streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal });
|
||||
```
|
||||
It wires the `prepareStep` callback behind compaction, mid-turn steering, and background
|
||||
injection, injects the system prompt from `chat.prompt()`, resolves the registry model, and adds
|
||||
telemetry. Omitting it makes all of those silently no-op with no error.
|
||||
|
||||
- **Declaring tools only on `streamText`.** Also declare them on `chat.agent({ tools })`, read them
|
||||
back from `run`, and pass `chat.toStreamTextOptions({ tools })`. Otherwise each tool's
|
||||
`toModelOutput` runs on turn 1 but is dropped when history is re-converted on later turns.
|
||||
|
||||
- **Not forwarding `signal` for stop.** Without `abortSignal: signal`, Stop updates the UI but the
|
||||
model keeps generating server-side.
|
||||
|
||||
- **Initializing `chat.local` in `onChatStart`.** Initialize it in `onBoot`. `onChatStart` fires
|
||||
once per chat, so continuation runs skip it and crash with
|
||||
`chat.local can only be modified after initialization`. `onBoot` fires on every fresh worker.
|
||||
|
||||
- **Minting tokens in the browser.** Never expose the environment secret key client-side. Mint via
|
||||
the two server actions; the transport calls them.
|
||||
|
||||
- **Clearing `lastEventId` on `chat.endRun()`.** Keep the cursor for the Session lifetime; clear it
|
||||
only when the Session itself closes. It is sessionId-keyed, so clearing forces a resubscribe from
|
||||
`seq_num=0` that can hit the prior turn's stale `turn-complete` and close the stream empty.
|
||||
|
||||
- **Returning the raw error from `uiMessageStreamOptions.onError`.** It leaks internals (keys,
|
||||
stack traces). Return a sanitized string instead.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills: **trigger-chat-agent-advanced** (Sessions primitive, custom transports, sub-agents, HITL, fast starts, resilience, testing, upgrades), **trigger-authoring-tasks** and **trigger-realtime-and-frontend** (the task + frontend foundations chat builds on).
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: trigger-authoring-tasks
|
||||
description: >
|
||||
Covers writing backend Trigger.dev tasks with @trigger.dev/sdk: defining task() and
|
||||
schemaTask(), the run function and its ctx, retries, waits, queues and concurrency,
|
||||
idempotency keys, run metadata, logging, triggering other tasks (and the Result shape),
|
||||
scheduled/cron tasks, and the essentials of trigger.config.ts. Load this whenever you are
|
||||
authoring or editing code inside a /trigger directory, defining a task, or writing backend
|
||||
code that triggers tasks. Realtime/React hooks and AI chat are covered by separate skills.
|
||||
type: core
|
||||
library: trigger.dev
|
||||
---
|
||||
|
||||
# Authoring Trigger.dev Tasks
|
||||
|
||||
The full, version-pinned reference for authoring tasks ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
|
||||
|
||||
- **Skill:** `node_modules/@trigger.dev/sdk/skills/trigger-authoring-tasks/SKILL.md` — the complete guide (setup, `schemaTask`, retries, triggering + the Result shape, idempotency, waits, metadata, scheduled tasks, queues/concurrency, `trigger.config.ts`).
|
||||
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "schemaTask" node_modules/@trigger.dev/sdk/docs/`.
|
||||
|
||||
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
|
||||
|
||||
Always import from `@trigger.dev/sdk` — never `@trigger.dev/sdk/v3` (deprecated alias) or `@trigger.dev/core`.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **CRITICAL: Treating the wait result as the output.** `triggerAndWait` and `wait.forToken` return a Result object, not the raw output.
|
||||
- Wrong: `const out = await childTask.triggerAndWait(p); use(out.foo);`
|
||||
- Correct: `const r = await childTask.triggerAndWait(p); if (r.ok) use(r.output.foo);` (or `.unwrap()`).
|
||||
|
||||
2. **Wrapping `triggerAndWait` / `batchTriggerAndWait` / `wait` in `Promise.all`.**
|
||||
- Wrong: `await Promise.all([childTask.triggerAndWait(a), childTask.triggerAndWait(b)]);`
|
||||
- Correct: `await childTask.batchTriggerAndWait([{ payload: a }, { payload: b }]);` (or a sequential for-loop).
|
||||
|
||||
3. **Importing the task instance into backend code.**
|
||||
- Wrong: `import { emailSequence } from "~/trigger/emails";` in a route handler.
|
||||
- Correct: `import type { emailSequence }` plus `tasks.trigger<typeof emailSequence>("email-sequence", payload)`.
|
||||
|
||||
4. **Calling `metadata.set/get` outside `run()`.**
|
||||
- Wrong: setting metadata at module scope or in unrelated backend code (a no-op; `get` returns `undefined`).
|
||||
- Correct: call inside `run()` or a task lifecycle hook.
|
||||
|
||||
5. **Assuming child tasks inherit the parent's queue or metadata.**
|
||||
- Wrong: expecting a subtask to share the parent's `concurrencyLimit` or see its metadata.
|
||||
- Correct: subtasks run on their own queue; pass metadata explicitly via `{ metadata: metadata.current() }`, or push up with `metadata.parent.*`.
|
||||
|
||||
6. **Bundling native/WASM packages.**
|
||||
- Wrong: leaving `sharp`, `re2`, `sqlite3`, or WASM packages in the default bundle.
|
||||
- Correct: add them to `build.external` in `trigger.config.ts`.
|
||||
|
||||
7. **Relying on a raw string idempotency key being global.**
|
||||
- Wrong: `trigger(p, { idempotencyKey: "welcome-email" })` expecting once-ever (true only in v4.3.0 and earlier).
|
||||
- Correct: `await idempotencyKeys.create("welcome-email", { scope: "global" })`.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills: **trigger-realtime-and-frontend** (subscribe to runs, trigger from the frontend), **trigger-authoring-chat-agent** and **trigger-chat-agent-advanced** (AI chat agents).
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: trigger-chat-agent-advanced
|
||||
description: >
|
||||
Advanced and operational chat.agent capabilities for Trigger.dev, loaded on demand. Load this when
|
||||
working on the raw Sessions primitive (sessions / SessionHandle), a custom chat transport or the
|
||||
realtime wire protocol, durable sub-agents (AgentChat, chat.stream.writer), human-in-the-loop,
|
||||
steering, actions, background injection (chat.defer / chat.inject), fast starts (preload, Head
|
||||
Start via @trigger.dev/sdk/chat-server), context resilience (compaction, recovery boot, OOM, large
|
||||
payloads), chat.local run-scoped state, offline testing with mockChatAgent, or prerelease/version
|
||||
upgrades. For the everyday chat.agent({...}) definition and the useTriggerChatTransport happy path,
|
||||
use the trigger-authoring-chat-agent skill instead.
|
||||
type: core
|
||||
library: trigger.dev
|
||||
---
|
||||
|
||||
# chat.agent — advanced & operational
|
||||
|
||||
The full, version-pinned reference ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
|
||||
|
||||
- **Skill:** `node_modules/@trigger.dev/sdk/skills/trigger-chat-agent-advanced/SKILL.md` — Sessions primitive, custom transports/wire protocol, sub-agents, HITL, steering, actions, background injection, fast starts, resilience (compaction/recovery/OOM/large payloads), `chat.local`, testing, upgrades.
|
||||
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/ai-chat/` (including `patterns/` for HITL, sub-agents, sessions); the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "mockChatAgent" node_modules/@trigger.dev/sdk/docs/`.
|
||||
|
||||
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- **CRITICAL: sending a follow-up by re-POSTing `POST /api/v1/sessions`.**
|
||||
```ts
|
||||
// Wrong - a cached re-POST silently drops basePayload.message; basePayload is trigger config, not a channel
|
||||
await fetch("/api/v1/sessions", { method: "POST", body: JSON.stringify({ ...createBody }) });
|
||||
// Correct - append to the session's input channel
|
||||
await fetch(`/realtime/v1/sessions/${id}/in/append`, { method: "POST", body: JSON.stringify({ kind: "message", payload }) });
|
||||
```
|
||||
|
||||
- **Using the wrong token for `.in` / `.out`.** Use `publicAccessToken` from the create response
|
||||
body (session-scoped). The `x-trigger-jwt` response header is run-scoped and cannot subscribe.
|
||||
|
||||
- **Initializing `chat.local` in `onChatStart`.** It is skipped on continuation runs, so `run()`
|
||||
crashes with `chat.local can only be modified after initialization`. Init in `onBoot`.
|
||||
|
||||
- **`chat.defer` for the message-history write.** A mid-stream refresh would read `[]`. `await` that
|
||||
write inline before the model streams; reserve `chat.defer` for analytics, audit, cache warming.
|
||||
|
||||
- **Giving the HITL tool an `execute`.** `streamText` calls it immediately. Leave it execute-less;
|
||||
the frontend supplies the answer via `addToolOutput` + `sendAutomaticallyWhen`.
|
||||
|
||||
- **Declaring sub-agent / heavy tools only on `streamText`.** Also declare them on
|
||||
`chat.agent({ tools })` (or pass to `convertToModelMessages(uiMessages, { tools })` in a custom
|
||||
agent) so `toModelOutput` re-applies on every turn.
|
||||
|
||||
- **Importing heavy-execute tools into the Head Start route module.** This is a build-time import
|
||||
chain problem; runtime strip helpers do not fix it. Keep schemas in an `ai` + `zod`-only module.
|
||||
|
||||
- **Returning a megabyte tool output on the stream.** One `tool-output-available` record over ~1 MiB
|
||||
throws `ChatChunkTooLargeError`. Persist to your store, write the row first, then emit only an id.
|
||||
|
||||
- **Setting `X-Peek-Settled: 1` on the active-send path.** It races the new turn's first chunk and
|
||||
closes the stream early. Use it only on reconnect-on-reload paths.
|
||||
|
||||
> Note on docs vocabulary: agent-side examples in some docs still use the legacy
|
||||
> `trigger:turn-complete` chunk type. That is the agent-emit vocabulary. A custom **reader** must
|
||||
> filter on the `trigger-control` header, not on `chunk.type`.
|
||||
>
|
||||
> MCP-driven agent chats (`list_agents`, `start_agent_chat`, `send_agent_message`,
|
||||
> `close_agent_chat`) are MCP server tools used from Claude Code / Cursor, not importable SDK
|
||||
> functions. See `/mcp-tools#agent-chat-tools`.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills: **trigger-authoring-chat-agent** (the everyday `chat.agent({...})` happy path), **trigger-authoring-tasks** and **trigger-realtime-and-frontend** (task + frontend foundations).
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: trigger-cost-savings
|
||||
description: >
|
||||
Analyze Trigger.dev tasks, schedules, and runs for cost optimization opportunities. Use when
|
||||
asked to reduce spend, optimize costs, audit usage, right-size machines, or review task
|
||||
efficiency. Combines static source analysis with live run analysis via the Trigger.dev MCP
|
||||
tools (list_runs, get_run_details, get_current_worker).
|
||||
type: core
|
||||
library: trigger.dev
|
||||
---
|
||||
|
||||
# Trigger.dev Cost Savings Analysis
|
||||
|
||||
The full, version-pinned cost-audit workflow ships **inside your installed `@trigger.dev/sdk`**. Read it before giving recommendations so they match the SDK version in this project:
|
||||
|
||||
- **Skill:** `node_modules/@trigger.dev/sdk/skills/trigger-cost-savings/SKILL.md` — the static-analysis checklist, the MCP run-analysis steps (`list_runs`, `get_run_details`, `get_current_worker`), the report format, and the machine-preset cost table.
|
||||
- **Docs:** the canonical guidance is bundled at `node_modules/@trigger.dev/sdk/docs/how-to-reduce-your-spend.mdx`, with supporting pages under `node_modules/@trigger.dev/sdk/docs/` (`machines.mdx`, `runs/max-duration.mdx`, `queue-concurrency.mdx`, `idempotency.mdx`, `triggering.mdx`, `errors-retrying.mdx`).
|
||||
|
||||
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
|
||||
|
||||
Live run analysis needs the Trigger.dev MCP server (`npx trigger.dev@latest install-mcp`). Without it, do the static source analysis only — never fabricate run data.
|
||||
|
||||
## Key principles
|
||||
|
||||
- **Waits > 5 seconds are free** — checkpointed, no compute charge.
|
||||
- **Start small, scale up** — the default `small-1x` is right for most tasks; right-size down tasks stuck on `large-*` with short durations.
|
||||
- **I/O-bound tasks don't need big machines** — API calls and DB queries wait on the network.
|
||||
- **Add `maxDuration`** — cap runaway compute.
|
||||
- **Debounce high-frequency triggers** — consolidate bursts into single runs.
|
||||
- **Idempotency keys prevent duplicate billed work.**
|
||||
- **`AbortTaskRunError` stops wasteful retries** — don't pay to retry permanent failures.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills: **trigger-authoring-tasks** (the task options these levers tune: `machine`, `maxDuration`, `retry`, `queue`, idempotency), **trigger-realtime-and-frontend**, **trigger-authoring-chat-agent** and **trigger-chat-agent-advanced** (AI agents).
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
name: trigger-getting-started
|
||||
description: >
|
||||
Bootstrap Trigger.dev into an existing project from scratch: authenticate the
|
||||
CLI, install @trigger.dev/sdk and @trigger.dev/build, write trigger.config.ts
|
||||
with the project ref and task dirs, scaffold a /trigger directory with a first
|
||||
task, wire tsconfig and .gitignore, set TRIGGER_SECRET_KEY, and run the dev
|
||||
server. Load this when a project has no trigger.config.ts yet and the user
|
||||
asks to "add Trigger.dev", "set up Trigger.dev", "initialize Trigger.dev", or
|
||||
get a first task running, including in a monorepo. Once the project is set up
|
||||
and you are writing task code, switch to the trigger-authoring-tasks skill.
|
||||
type: core
|
||||
library: trigger.dev
|
||||
library_version: "{{TRIGGER_SDK_VERSION}}"
|
||||
sources:
|
||||
- docs/quick-start.mdx
|
||||
- docs/manual-setup.mdx
|
||||
- docs/config/config-file.mdx
|
||||
- docs/triggering.mdx
|
||||
---
|
||||
|
||||
# Getting started with Trigger.dev
|
||||
|
||||
Set up Trigger.dev in an existing project. The end state is: the SDK installed, a
|
||||
`trigger.config.ts` pointing at a project ref, a `/trigger` directory with at least
|
||||
one exported task, and `trigger dev` running so the task shows up in the dashboard.
|
||||
|
||||
The fastest path is the CLI's own wizard, which performs every mechanical step below
|
||||
and also offers to install the MCP server and these agent skills:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest init
|
||||
```
|
||||
|
||||
Prefer `init` when you can. Do the manual steps further down when `init` does not fit
|
||||
(monorepos, an existing config to extend, or a non-interactive environment).
|
||||
|
||||
## Two steps need the human
|
||||
|
||||
Most of setup is automatable, but two steps require a person and cannot be done
|
||||
headlessly. When you reach them, stop and ask the user to do them, then continue:
|
||||
|
||||
1. **Authenticating the CLI.** `npx trigger.dev@latest login` opens a browser for the
|
||||
user to sign in. If they have no account, point them to https://cloud.trigger.dev
|
||||
(or a self-hosted instance) first. You cannot complete this for them.
|
||||
2. **The secret key and project ref.** `TRIGGER_SECRET_KEY` and the project ref
|
||||
(`proj_...`) come from the dashboard. Ask the user to copy the **DEV** secret key
|
||||
from the project's API Keys page, and to pick or create the project so you have its
|
||||
ref. `trigger init` can select the project interactively once the user is logged in.
|
||||
|
||||
Treat these as handoffs: state exactly what you need, wait for the user, then resume.
|
||||
|
||||
## Manual setup
|
||||
|
||||
### 1. Authenticate (human step)
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest login
|
||||
# self-hosted:
|
||||
npx trigger.dev@latest login --api-url https://your-trigger-instance.com
|
||||
```
|
||||
|
||||
### 2. Install the packages
|
||||
|
||||
`@trigger.dev/sdk` is a runtime dependency; `@trigger.dev/build` is a dev dependency.
|
||||
Pin both to the same version as the `trigger.dev` CLI you run; the CLI warns on a
|
||||
mismatch during `dev`/`deploy`.
|
||||
|
||||
```bash
|
||||
npm add @trigger.dev/sdk@latest
|
||||
npm add --save-dev @trigger.dev/build@latest
|
||||
```
|
||||
|
||||
### 3. Write `trigger.config.ts`
|
||||
|
||||
Create it in the project root (or `trigger.config.mjs` for JavaScript). The `project`
|
||||
ref and `dirs` are the only required fields.
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<project ref>", // e.g. "proj_abc123", from the dashboard
|
||||
dirs: ["./src/trigger"], // where your tasks live
|
||||
maxDuration: 3600,
|
||||
retries: {
|
||||
enabledInDev: false,
|
||||
default: { maxAttempts: 3, factor: 2, minTimeoutInMs: 1000, maxTimeoutInMs: 10000, randomize: true },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use the Bun runtime by adding `runtime: "bun"`. Build extensions (`prismaExtension`,
|
||||
`puppeteer`, `additionalFiles`, etc.) come from `@trigger.dev/build` and go in
|
||||
`build.extensions`.
|
||||
|
||||
### 4. Add a first task
|
||||
|
||||
Create the directory that matches `dirs` and export a task from it. Every task must be
|
||||
a named export with a project-unique `id`.
|
||||
|
||||
```ts
|
||||
// src/trigger/example.ts
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
|
||||
export const helloWorld = task({
|
||||
id: "hello-world",
|
||||
run: async (payload: { name: string }) => {
|
||||
return { message: `Hello ${payload.name}!` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Wire tsconfig and gitignore
|
||||
|
||||
Add `trigger.config.ts` to the `include` array in `tsconfig.json`, and add `.trigger`
|
||||
to `.gitignore` (the CLI writes local dev state there).
|
||||
|
||||
```jsonc
|
||||
// tsconfig.json
|
||||
{ "include": ["trigger.config.ts" /* ...existing */] }
|
||||
```
|
||||
|
||||
```bash
|
||||
# .gitignore
|
||||
.trigger
|
||||
```
|
||||
|
||||
### 6. Set the secret key (human step)
|
||||
|
||||
For triggering from your own code, set `TRIGGER_SECRET_KEY` to the DEV key from the
|
||||
dashboard's API Keys page. Self-hosted users also set `TRIGGER_API_URL`.
|
||||
|
||||
```bash
|
||||
# .env (or .env.local for Next.js)
|
||||
TRIGGER_SECRET_KEY=tr_dev_xxxxxxxx
|
||||
```
|
||||
|
||||
### 7. Run the dev server
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest dev
|
||||
```
|
||||
|
||||
Leave it running. Tasks register with the dashboard, where the user can fire a test run
|
||||
from the task's test page. On first run the CLI offers to install the MCP server and
|
||||
agent skills; recommend both.
|
||||
|
||||
## Triggering from your app
|
||||
|
||||
Once a task exists, trigger it from backend code with a **type-only** import so the
|
||||
task code is never bundled into your app. Trigger by id, not by calling the task object.
|
||||
|
||||
```ts
|
||||
import { tasks } from "@trigger.dev/sdk";
|
||||
import type { helloWorld } from "@/trigger/example"; // type-only
|
||||
|
||||
const handle = await tasks.trigger<typeof helloWorld>("hello-world", { name: "Ada" });
|
||||
```
|
||||
|
||||
`TRIGGER_SECRET_KEY` must be set wherever this runs. Framework specifics live in the
|
||||
Next.js / Remix / Node.js guides.
|
||||
|
||||
## Monorepos
|
||||
|
||||
Two layouts, both supported: put tasks in a shared package (`@repo/tasks` with its own
|
||||
`trigger.config.ts`, consumed via `workspace:*`), or install Trigger.dev directly in the
|
||||
app that needs it. Run `trigger dev` from the directory that holds `trigger.config.ts`.
|
||||
See the manual setup docs for full Turborepo examples before scaffolding either.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **Trying to do the human-only steps headlessly.** You cannot complete `trigger login`
|
||||
or read the dashboard secret key for the user.
|
||||
- Wrong: spawning `trigger login` and waiting on it to finish in an agent session.
|
||||
- Correct: ask the user to log in and to paste the DEV key, then continue.
|
||||
|
||||
2. **Mismatched CLI and SDK versions.** A `trigger.dev` CLI on a different major than
|
||||
`@trigger.dev/sdk` breaks dev/deploy.
|
||||
- Wrong: `npx trigger.dev@latest dev` against an old pinned SDK.
|
||||
- Correct: keep `trigger.dev`, `@trigger.dev/sdk`, and `@trigger.dev/build` on the same version.
|
||||
|
||||
3. **Importing from `@trigger.dev/sdk/v3` or using `client.defineJob()`.** Both are old.
|
||||
- Correct: always import from `@trigger.dev/sdk`; define work with `task()`.
|
||||
|
||||
4. **Tasks not exported, or outside `dirs`.** A task that is not a named export inside a
|
||||
configured directory will not be picked up.
|
||||
- Correct: `export const ... = task({ ... })` in a file under a `dirs` path.
|
||||
|
||||
5. **Importing the task instance into backend code.** This bundles the task.
|
||||
- Wrong: `import { helloWorld } from "@/trigger/example"` in a route handler.
|
||||
- Correct: `import type { helloWorld }` plus `tasks.trigger<typeof helloWorld>("hello-world", payload)`.
|
||||
|
||||
6. **Forgetting `TRIGGER_SECRET_KEY`.** Triggering from your app fails without it; the
|
||||
`dev` server itself works once the CLI is logged in.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills:
|
||||
|
||||
- **trigger-authoring-tasks** for writing the tasks themselves once setup is done: retries, waits,
|
||||
queues, scheduled tasks, triggering, and the full `trigger.config.ts`.
|
||||
- **trigger-realtime-and-frontend** for showing live run status in a frontend.
|
||||
- **trigger-authoring-chat-agent** and **trigger-chat-agent-advanced** for building AI chat agents.
|
||||
|
||||
Docs:
|
||||
|
||||
- [Quick start](https://trigger.dev/docs/quick-start)
|
||||
- [Manual setup](https://trigger.dev/docs/manual-setup)
|
||||
- [Configuration file](https://trigger.dev/docs/config/config-file)
|
||||
|
||||
## Version
|
||||
|
||||
Generated for @trigger.dev/sdk {{TRIGGER_SDK_VERSION}}. Re-run the trigger.dev skills installer after upgrading.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: trigger-realtime-and-frontend
|
||||
description: >
|
||||
Trigger.dev client/frontend surface: subscribe to runs in realtime
|
||||
(runs.subscribeToRun and the @trigger.dev/react-hooks hook useRealtimeRun),
|
||||
consume metadata and AI/text streams in React (useRealtimeStream), trigger
|
||||
tasks from the browser (useTaskTrigger, useRealtimeTaskTrigger), and mint
|
||||
scoped frontend credentials with auth.createPublicToken /
|
||||
auth.createTriggerPublicToken.
|
||||
Load when wiring a frontend (React/Next.js/Remix) or backend-for-frontend to
|
||||
show live run progress, status badges, token streams, trigger buttons, or
|
||||
wait-token approval UIs. NOT for writing the backend task itself (streams.define
|
||||
/ metadata.set is trigger-authoring-tasks territory); this is the consumer side.
|
||||
type: core
|
||||
library: trigger.dev
|
||||
---
|
||||
|
||||
# Realtime and Frontend
|
||||
|
||||
The full, version-pinned reference ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
|
||||
|
||||
- **Skill:** `node_modules/@trigger.dev/sdk/skills/trigger-realtime-and-frontend/SKILL.md` — run subscriptions, `@trigger.dev/react-hooks`, streams, frontend triggering, and scoped tokens.
|
||||
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/realtime/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for a hook, e.g. `grep -rl "useRealtimeRun" node_modules/@trigger.dev/sdk/docs/`.
|
||||
|
||||
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **CRITICAL: Triggering from the browser with a Public Access Token.** The
|
||||
read token from `createPublicToken` cannot trigger tasks.
|
||||
- Wrong: `useTaskTrigger("my-task", { accessToken: publicAccessTokenFromCreatePublicToken })`
|
||||
- Correct: mint a single-use Trigger Token with `auth.createTriggerPublicToken("my-task")` and pass that.
|
||||
|
||||
2. **Token with no scopes.** A scopeless token authorizes nothing, so every subscribe 403s.
|
||||
- Wrong: `await auth.createPublicToken()`
|
||||
- Correct: `await auth.createPublicToken({ scopes: { read: { runs: ["run_1234"] } } })`
|
||||
|
||||
3. **Polling with `useRun`/SWR for live updates.** `useRun` is the SWR-based
|
||||
management-API hook (not recommended for live state); set `refreshInterval: 0`
|
||||
to stop polling if you do use it.
|
||||
- Wrong: `useRun(runId, { refreshInterval: 1000 })` to track progress
|
||||
- Correct: `useRealtimeRun(runId, { accessToken })` (no polling, no WebSocket setup)
|
||||
|
||||
4. **Forgetting `"use client"`.** Realtime/trigger hooks cannot run in a server component.
|
||||
- Wrong: a Next.js App Router server component using `useRealtimeRun`
|
||||
- Correct: put `"use client";` at the top of any component using these hooks.
|
||||
|
||||
5. **Shipping `payload`/`output` you do not render.**
|
||||
- Wrong: `useRealtimeRun(runId, { accessToken })` for a status badge (large payloads over the wire)
|
||||
- Correct: `useRealtimeRun(runId, { accessToken, skipColumns: ["payload", "output"] })`
|
||||
|
||||
6. **Subscribing before the handle exists.**
|
||||
- Wrong: `useRealtimeRun(handle, { accessToken: handle?.publicAccessToken })` with no guard
|
||||
- Correct: add `enabled: !!handle` so it subscribes only once the trigger returns a handle.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills: **trigger-authoring-tasks** (the task side: `streams.define()`, `metadata.set()`, `wait.createToken`), **trigger-authoring-chat-agent** and **trigger-chat-agent-advanced** (chat agents build on these realtime streams).
|
||||
@@ -0,0 +1,913 @@
|
||||
import type {
|
||||
CreateArtifactRequestBody,
|
||||
CreateBackgroundWorkerRequestBody,
|
||||
DevDequeueRequestBody,
|
||||
DevDisconnectRequestBody,
|
||||
FailDeploymentRequestBody,
|
||||
FinalizeDeploymentRequestBody,
|
||||
ImportEnvironmentVariablesRequestBody,
|
||||
InitializeDeploymentRequestBody,
|
||||
StartDeploymentIndexingRequestBody,
|
||||
TriggerTaskRequestBody,
|
||||
UpsertBranchRequestBody,
|
||||
WorkersCreateRequestBody,
|
||||
CreateProjectRequestBody,
|
||||
GetJWTRequestBody,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import {
|
||||
CreateAuthorizationCodeResponseSchema,
|
||||
CreateArtifactResponseBody,
|
||||
CreateBackgroundWorkerResponse,
|
||||
DevConfigResponseBody,
|
||||
DevDequeueResponseBody,
|
||||
DevDisconnectResponseBody,
|
||||
EnvironmentVariableResponseBody,
|
||||
FailDeploymentResponseBody,
|
||||
GetDeploymentResponseBody,
|
||||
GetEnvironmentVariablesResponseBody,
|
||||
GetLatestDeploymentResponseBody,
|
||||
GetPersonalAccessTokenResponseSchema,
|
||||
GetProjectEnvResponse,
|
||||
GetProjectResponseBody,
|
||||
GetProjectsResponseBody,
|
||||
InitializeDeploymentResponseBody,
|
||||
PromoteDeploymentResponseBody,
|
||||
StartDeploymentIndexingResponseBody,
|
||||
TriggerTaskResponse,
|
||||
UpsertBranchResponseBody,
|
||||
WhoAmIResponseSchema,
|
||||
WorkersCreateResponseBody,
|
||||
WorkersListResponseBody,
|
||||
GetOrgsResponseBody,
|
||||
GetWorkerByTagResponse,
|
||||
GetJWTResponse,
|
||||
ApiBranchListResponseBody,
|
||||
GenerateRegistryCredentialsResponseBody,
|
||||
RemoteBuildProviderStatusResponseBody,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import type {
|
||||
WorkloadDebugLogRequestBody,
|
||||
WorkloadHeartbeatRequestBody,
|
||||
WorkloadRunAttemptCompleteRequestBody,
|
||||
} from "@trigger.dev/core/v3/workers";
|
||||
import {
|
||||
WorkloadHeartbeatResponseBody,
|
||||
WorkloadRunAttemptCompleteResponseBody,
|
||||
WorkloadRunAttemptStartResponseBody,
|
||||
WorkloadRunLatestSnapshotResponseBody,
|
||||
} from "@trigger.dev/core/v3/workers";
|
||||
import type { ApiResult } from "@trigger.dev/core/v3/zodfetch";
|
||||
import { wrapZodFetch, zodfetchSSE } from "@trigger.dev/core/v3/zodfetch";
|
||||
import { EventSource } from "eventsource";
|
||||
import { z } from "zod";
|
||||
import { logger } from "./utilities/logger.js";
|
||||
import { VERSION } from "./version.js";
|
||||
|
||||
const MintUserActorTokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
expiresInSeconds: z.number(),
|
||||
});
|
||||
|
||||
const CliPlatformNotificationResponseSchema = z.object({
|
||||
notification: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
payload: z.object({
|
||||
version: z.string(),
|
||||
data: z.object({
|
||||
type: z.enum(["info", "warn", "error", "success"]),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
actionLabel: z.string().optional(),
|
||||
actionUrl: z.string().optional(),
|
||||
discovery: z
|
||||
.object({
|
||||
filePatterns: z.array(z.string()),
|
||||
contentPattern: z.string().optional(),
|
||||
matchBehavior: z.enum(["show-if-found", "show-if-not-found"]),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
showCount: z.number(),
|
||||
firstSeenAt: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export class CliApiClient {
|
||||
private engineURL: string;
|
||||
private source: "cli" | "mcp";
|
||||
|
||||
constructor(
|
||||
public readonly apiURL: string,
|
||||
// TODO: consider making this required
|
||||
public readonly accessToken?: string,
|
||||
public readonly branch?: string,
|
||||
options?: { source?: "cli" | "mcp" }
|
||||
) {
|
||||
this.apiURL = apiURL.replace(/\/$/, "");
|
||||
this.engineURL = this.apiURL;
|
||||
this.source = options?.source ?? "cli";
|
||||
}
|
||||
|
||||
async createAuthorizationCode() {
|
||||
return wrapZodFetch(
|
||||
CreateAuthorizationCodeResponseSchema,
|
||||
`${this.apiURL}/api/v1/authorization-code`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getPersonalAccessToken(authorizationCode: string) {
|
||||
return wrapZodFetch(GetPersonalAccessTokenResponseSchema, `${this.apiURL}/api/v1/token`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
authorizationCode,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async whoAmI(projectRef?: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("whoAmI: No access token");
|
||||
}
|
||||
|
||||
const url = new URL("/api/v2/whoami", this.apiURL);
|
||||
|
||||
if (projectRef) {
|
||||
url.searchParams.append("projectRef", projectRef);
|
||||
}
|
||||
|
||||
return wrapZodFetch(WhoAmIResponseSchema, url.href, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async retrieveExternals() {
|
||||
return wrapZodFetch(
|
||||
z.object({ externals: z.array(z.string()) }),
|
||||
`https://jsonhero.io/j/GU7CwoDOL40k.json`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getProject(projectRef: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getProject: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(GetProjectResponseBody, `${this.apiURL}/api/v1/projects/${projectRef}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getProjects() {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getProjects: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(GetProjectsResponseBody, `${this.apiURL}/api/v1/projects`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getOrgs() {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getOrgs: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(GetOrgsResponseBody, `${this.apiURL}/api/v1/orgs`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createProject(orgParam: string, body: CreateProjectRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("createProject: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(GetProjectResponseBody, `${this.apiURL}/api/v1/orgs/${orgParam}/projects`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkerByTag(projectRef: string, envName: string, tagName: string = "current") {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getWorkerByTag: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
GetWorkerByTagResponse,
|
||||
`${this.apiURL}/api/v1/projects/${projectRef}/${envName}/workers/${tagName}`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async mintUserActorToken(body?: { cap?: string[]; client?: string; ttlSeconds?: number }) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("mintUserActorToken: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
MintUserActorTokenResponseSchema,
|
||||
`${this.apiURL}/api/v1/auth/user-actor-token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body ?? {}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getJWT(projectRef: string, envName: string, body: GetJWTRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getJWT: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
GetJWTResponse,
|
||||
`${this.apiURL}/api/v1/projects/${projectRef}/${envName}/jwt`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getDevStatus(projectRef: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getDevStatus: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
z.object({ isConnected: z.boolean() }),
|
||||
`${this.apiURL}/api/v1/projects/${projectRef}/dev-status`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async createBackgroundWorker(projectRef: string, body: CreateBackgroundWorkerRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("createBackgroundWorker: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
CreateBackgroundWorkerResponse,
|
||||
`${this.apiURL}/api/v1/projects/${projectRef}/background-workers`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getProjectEnv({ projectRef, env }: { projectRef: string; env: string }) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getProjectDevEnv: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
GetProjectEnvResponse,
|
||||
`${this.apiURL}/api/v1/projects/${projectRef}/${env}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async upsertBranch(projectRef: string, body: UpsertBranchRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("upsertBranch: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
UpsertBranchResponseBody,
|
||||
`${this.apiURL}/api/v1/projects/${projectRef}/branches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async archiveBranch(projectRef: string, env: UpsertBranchRequestBody["env"], branch: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("archiveBranch: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
z.object({ branch: z.object({ id: z.string() }) }),
|
||||
`${this.apiURL}/api/v1/projects/${projectRef}/branches/archive`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ env, branch }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async listBranches(projectRef: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("listBranches: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
ApiBranchListResponseBody,
|
||||
`${this.apiURL}/api/v1/projects/${projectRef}/branches`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getEnvironmentVariables(projectRef: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getEnvironmentVariables: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
GetEnvironmentVariablesResponseBody,
|
||||
`${this.apiURL}/api/v1/projects/${projectRef}/envvars`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async importEnvVars(
|
||||
projectRef: string,
|
||||
slug: string,
|
||||
params: ImportEnvironmentVariablesRequestBody
|
||||
) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("importEnvVars: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
EnvironmentVariableResponseBody,
|
||||
`${this.apiURL}/api/v1/projects/${projectRef}/envvars/${slug}/import`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(params),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getRemoteBuildProviderStatus() {
|
||||
return wrapZodFetch(
|
||||
RemoteBuildProviderStatusResponseBody,
|
||||
`${this.apiURL}/api/v1/remote-build-provider-status`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
// probably a good idea to add this to the other requests too
|
||||
"x-trigger-cli-version": VERSION,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async generateRegistryCredentials(deploymentId: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("generateRegistryCredentials: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
GenerateRegistryCredentialsResponseBody,
|
||||
`${this.apiURL}/api/v1/deployments/${deploymentId}/generate-registry-credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: "{}",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async createArtifact(body: CreateArtifactRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("createArtifact: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(CreateArtifactResponseBody, `${this.apiURL}/api/v1/artifacts`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async initializeDeployment(body: InitializeDeploymentRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("initializeDeployment: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(InitializeDeploymentResponseBody, `${this.apiURL}/api/v1/deployments`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async createDeploymentBackgroundWorker(
|
||||
deploymentId: string,
|
||||
body: CreateBackgroundWorkerRequestBody
|
||||
) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("createDeploymentBackgroundWorker: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
CreateBackgroundWorkerResponse,
|
||||
`${this.apiURL}/api/v1/deployments/${deploymentId}/background-workers`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async failDeployment(id: string, body: FailDeploymentRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("failDeployment: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
FailDeploymentResponseBody,
|
||||
`${this.apiURL}/api/v1/deployments/${id}/fail`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async finalizeDeployment(
|
||||
id: string,
|
||||
body: FinalizeDeploymentRequestBody,
|
||||
onLog?: (message: string) => void
|
||||
): Promise<ApiResult<FailDeploymentResponseBody>> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("finalizeDeployment: No access token");
|
||||
}
|
||||
|
||||
let resolvePromise: (value: ApiResult<FailDeploymentResponseBody>) => void;
|
||||
|
||||
const promise = new Promise<ApiResult<FailDeploymentResponseBody>>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
const source = zodfetchSSE({
|
||||
url: `${this.apiURL}/api/v3/deployments/${id}/finalize`,
|
||||
request: {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
messages: {
|
||||
error: z.object({ error: z.string() }),
|
||||
log: z.object({ message: z.string() }),
|
||||
complete: FailDeploymentResponseBody,
|
||||
},
|
||||
});
|
||||
|
||||
source.onConnectionError((error) => {
|
||||
let message = error.message ?? "Unknown error";
|
||||
|
||||
if (error.status !== undefined) {
|
||||
message = `HTTP ${error.status} ${message}`;
|
||||
}
|
||||
|
||||
resolvePromise({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
});
|
||||
|
||||
source.onMessage("complete", (message) => {
|
||||
resolvePromise({
|
||||
success: true,
|
||||
data: message,
|
||||
});
|
||||
});
|
||||
|
||||
source.onMessage("error", ({ error }) => {
|
||||
resolvePromise({
|
||||
success: false,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
if (onLog) {
|
||||
source.onMessage("log", ({ message }) => {
|
||||
onLog(message);
|
||||
});
|
||||
}
|
||||
|
||||
const result = await promise;
|
||||
|
||||
source.stop();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async promoteDeployment(version: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("promoteDeployment: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
PromoteDeploymentResponseBody,
|
||||
`${this.apiURL}/api/v1/deployments/${version}/promote`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async startDeploymentIndexing(deploymentId: string, body: StartDeploymentIndexingRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("startDeploymentIndexing: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
StartDeploymentIndexingResponseBody,
|
||||
`${this.apiURL}/api/v1/deployments/${deploymentId}/start-indexing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getDeployment(deploymentId: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getDeployment: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
GetDeploymentResponseBody,
|
||||
`${this.apiURL}/api/v1/deployments/${deploymentId}`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getCliPlatformNotification(projectRef?: string, signal?: AbortSignal) {
|
||||
if (!this.accessToken) {
|
||||
return { success: true as const, data: { notification: null } };
|
||||
}
|
||||
|
||||
const url = new URL("/api/v1/platform-notifications", this.apiURL);
|
||||
if (projectRef) {
|
||||
url.searchParams.set("projectRef", projectRef);
|
||||
}
|
||||
|
||||
return wrapZodFetch(CliPlatformNotificationResponseSchema, url.href, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async triggerTaskRun(taskId: string, body?: TriggerTaskRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("triggerTaskRun: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(TriggerTaskResponse, `${this.apiURL}/api/v1/tasks/${taskId}/trigger`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
get dev() {
|
||||
return {
|
||||
config: this.devConfig.bind(this),
|
||||
presenceConnection: this.devPresenceConnection.bind(this),
|
||||
dequeue: this.devDequeue.bind(this),
|
||||
sendDebugLog: this.devSendDebugLog.bind(this),
|
||||
getRunExecutionData: this.devGetRunExecutionData.bind(this),
|
||||
heartbeatRun: this.devHeartbeatRun.bind(this),
|
||||
startRunAttempt: this.devStartRunAttempt.bind(this),
|
||||
completeRunAttempt: this.devCompleteRunAttempt.bind(this),
|
||||
disconnect: this.devDisconnect.bind(this),
|
||||
setEngineURL: this.setEngineURL.bind(this),
|
||||
} as const;
|
||||
}
|
||||
|
||||
get workers() {
|
||||
return {
|
||||
list: this.listWorkers.bind(this),
|
||||
create: this.createWorker.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
get deployments() {
|
||||
return {
|
||||
unmanaged: {
|
||||
latest: this.getLatestUnmanagedDeployment.bind(this),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getLatestUnmanagedDeployment() {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getLatestUnmanagedDeployment: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
GetLatestDeploymentResponseBody,
|
||||
`${this.apiURL}/api/v1/deployments/latest`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async listWorkers() {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("listWorkers: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(WorkersListResponseBody, `${this.apiURL}/api/v1/workers`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
private async createWorker(options: WorkersCreateRequestBody) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("createWorker: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(WorkersCreateResponseBody, `${this.apiURL}/api/v1/workers`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
}
|
||||
|
||||
private async devConfig(): Promise<ApiResult<DevConfigResponseBody>> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("devConfig: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(DevConfigResponseBody, `${this.engineURL}/engine/v1/dev/config`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
Accept: "application/json",
|
||||
...this.getBranchHeader(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private devPresenceConnection(): EventSource {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("connectToPresence: No access token");
|
||||
}
|
||||
|
||||
let retryCount = 0;
|
||||
const maxRetries = 5;
|
||||
const retryDelay = 1000; // Start with 1 second delay
|
||||
|
||||
const eventSource = new EventSource(`${this.engineURL}/engine/v1/dev/presence`, {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
...this.getBranchHeader(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
eventSource.onopen = () => {
|
||||
logger.debug("Presence connection established");
|
||||
retryCount = 0; // Reset retry count on successful connection
|
||||
};
|
||||
|
||||
eventSource.onerror = (error: any) => {
|
||||
// The connection will automatically try to reconnect
|
||||
logger.debug("Presence connection error, will automatically attempt to reconnect", {
|
||||
error,
|
||||
readyState: eventSource.readyState,
|
||||
});
|
||||
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
logger.debug("Presence connection permanently closed", { error, retryCount });
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
const backoffDelay = retryDelay * Math.pow(2, retryCount - 1); // Exponential backoff
|
||||
|
||||
logger.debug(
|
||||
`Attempting reconnection in ${backoffDelay}ms (attempt ${retryCount}/${maxRetries})`
|
||||
);
|
||||
eventSource.close();
|
||||
|
||||
setTimeout(() => {
|
||||
this.devPresenceConnection();
|
||||
}, backoffDelay);
|
||||
} else {
|
||||
logger.debug("Max retry attempts reached, giving up");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return eventSource;
|
||||
}
|
||||
|
||||
private async devDisconnect(
|
||||
body: DevDisconnectRequestBody
|
||||
): Promise<ApiResult<DevDisconnectResponseBody>> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("devDisconnect: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(DevDisconnectResponseBody, `${this.engineURL}/engine/v1/dev/disconnect`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
Accept: "application/json",
|
||||
...this.getBranchHeader(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
private async devDequeue(
|
||||
body: DevDequeueRequestBody
|
||||
): Promise<ApiResult<DevDequeueResponseBody>> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("devConfig: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(DevDequeueResponseBody, `${this.engineURL}/engine/v1/dev/dequeue`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
Accept: "application/json",
|
||||
...this.getBranchHeader(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
private async devSendDebugLog(
|
||||
runId: string,
|
||||
body: WorkloadDebugLogRequestBody
|
||||
): Promise<ApiResult<unknown>> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("devConfig: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(z.unknown(), `${this.engineURL}/engine/v1/dev/runs/${runId}/logs/debug`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...this.getBranchHeader(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
private async devGetRunExecutionData(
|
||||
runId: string
|
||||
): Promise<ApiResult<WorkloadRunLatestSnapshotResponseBody>> {
|
||||
return wrapZodFetch(
|
||||
WorkloadRunLatestSnapshotResponseBody,
|
||||
`${this.engineURL}/engine/v1/dev/runs/${runId}/snapshots/latest`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
Accept: "application/json",
|
||||
...this.getBranchHeader(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async devHeartbeatRun(
|
||||
runId: string,
|
||||
snapshotId: string,
|
||||
body: WorkloadHeartbeatRequestBody
|
||||
): Promise<ApiResult<WorkloadHeartbeatResponseBody>> {
|
||||
return wrapZodFetch(
|
||||
WorkloadHeartbeatResponseBody,
|
||||
`${this.engineURL}/engine/v1/dev/runs/${runId}/snapshots/${snapshotId}/heartbeat`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...this.getBranchHeader(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async devStartRunAttempt(
|
||||
runId: string,
|
||||
snapshotId: string
|
||||
): Promise<ApiResult<WorkloadRunAttemptStartResponseBody>> {
|
||||
return wrapZodFetch(
|
||||
WorkloadRunAttemptStartResponseBody,
|
||||
`${this.engineURL}/engine/v1/dev/runs/${runId}/snapshots/${snapshotId}/attempts/start`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
Accept: "application/json",
|
||||
...this.getBranchHeader(),
|
||||
},
|
||||
//no body at the moment, but we'll probably add things soon
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async devCompleteRunAttempt(
|
||||
runId: string,
|
||||
snapshotId: string,
|
||||
body: WorkloadRunAttemptCompleteRequestBody
|
||||
): Promise<ApiResult<WorkloadRunAttemptCompleteResponseBody>> {
|
||||
return wrapZodFetch(
|
||||
WorkloadRunAttemptCompleteResponseBody,
|
||||
`${this.engineURL}/engine/v1/dev/runs/${runId}/snapshots/${snapshotId}/attempts/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
Accept: "application/json",
|
||||
...this.getBranchHeader(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private setEngineURL(engineURL: string) {
|
||||
this.engineURL = engineURL.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
private getHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-trigger-source": this.source,
|
||||
...this.getBranchHeader(),
|
||||
};
|
||||
}
|
||||
|
||||
private getBranchHeader(): Record<string, string> {
|
||||
return this.branch ? { "x-trigger-branch": this.branch } : {};
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
*/
|
||||
|
||||
type Transform = (str: string) => string;
|
||||
|
||||
interface Options {
|
||||
/**
|
||||
* Limit the length of the input string. Useful when the input string is generated or your application allows
|
||||
* users to pass a string, et cetera.
|
||||
*
|
||||
* @default 65536
|
||||
* @example
|
||||
* console.log(braces('a/{b,c}/d', { maxLength: 3 }));
|
||||
* //=> throws an error
|
||||
*/
|
||||
maxLength?: number | undefined;
|
||||
/**
|
||||
* Generate an "expanded" brace pattern (alternatively you can use the `braces.expand()` method).
|
||||
*
|
||||
* @default undefined
|
||||
* @example
|
||||
* console.log(braces('a/{b,c}/d', { expand: true }));
|
||||
* //=> [ 'a/b/d', 'a/c/d' ]
|
||||
*/
|
||||
expand?: boolean | undefined;
|
||||
/**
|
||||
* Remove duplicates from the returned array.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
nodupes?: boolean | undefined;
|
||||
/**
|
||||
* To prevent malicious patterns from being passed by users, an error is thrown when `braces.expand()`
|
||||
* is used or `options.expand` is true and the generated range will exceed the `rangeLimit`.
|
||||
*
|
||||
* You can customize `options.rangeLimit` or set it to `Infinity` to disable this altogether.
|
||||
*
|
||||
* @default 1000
|
||||
* @example
|
||||
* // pattern exceeds the "rangeLimit", so it's optimized automatically
|
||||
* console.log(braces.expand('{1..1000}'));
|
||||
* //=> ['([1-9]|[1-9][0-9]{1,2}|1000)']
|
||||
*
|
||||
* // pattern does not exceed "rangeLimit", so it's NOT optimized
|
||||
* console.log(braces.expand('{1..100}'));
|
||||
* //=> ['1', '2', '3', '4', '5', …, '100']
|
||||
*/
|
||||
rangeLimit?: number | undefined;
|
||||
/**
|
||||
* Customize range expansion.
|
||||
*
|
||||
* @default undefined
|
||||
* @example
|
||||
* const range = braces.expand('x{a..e}y', {
|
||||
* transform: (str) => `foo/${str}`
|
||||
* });
|
||||
*
|
||||
* console.log(range);
|
||||
* //=> [ 'xfooay', 'xfooby', 'xfoocy', 'xfoody', 'xfooey' ]
|
||||
*/
|
||||
transform?: Transform | undefined;
|
||||
/**
|
||||
* In regular expressions, quanitifiers can be used to specify how many times a token can be repeated.
|
||||
* For example, `a{1,3}` will match the letter `a` one to three times.
|
||||
*
|
||||
* Unfortunately, regex quantifiers happen to share the same syntax as [Bash lists](#lists)
|
||||
*
|
||||
* The `quantifiers` option tells braces to detect when [regex quantifiers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#quantifiers)
|
||||
* are defined in the given pattern, and not to try to expand them as lists.
|
||||
*
|
||||
* @default undefined
|
||||
* @example
|
||||
* const braces = require('braces');
|
||||
* console.log(braces('a/b{1,3}/{x,y,z}'));
|
||||
* //=> [ 'a/b(1|3)/(x|y|z)' ]
|
||||
* console.log(braces('a/b{1,3}/{x,y,z}', {quantifiers: true}));
|
||||
* //=> [ 'a/b{1,3}/(x|y|z)' ]
|
||||
* console.log(braces('a/b{1,3}/{x,y,z}', {quantifiers: true, expand: true}));
|
||||
* //=> [ 'a/b{1,3}/x', 'a/b{1,3}/y', 'a/b{1,3}/z' ]
|
||||
*/
|
||||
quantifiers?: boolean | undefined;
|
||||
/**
|
||||
* Do not strip backslashes that were used for escaping from the result.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
keepEscaping?: boolean | undefined;
|
||||
/**
|
||||
* Do not strip quotes from the result.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
keepQuotes?: boolean | undefined;
|
||||
}
|
||||
|
||||
// Ambient type override for braces to allow string or string[] as pattern
|
||||
declare module "braces" {
|
||||
function braces(pattern: string | string[], options?: Options): string[];
|
||||
|
||||
namespace braces {
|
||||
function expand(pattern: string | string[], options?: Omit<Options, "expand">): string[];
|
||||
}
|
||||
|
||||
export default braces;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
|
||||
import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas";
|
||||
import { BundleResult, bundleWorker, createBuildManifestFromBundle } from "./bundle.js";
|
||||
import { bundleSkills } from "./bundleSkills.js";
|
||||
import {
|
||||
createBuildContext,
|
||||
notifyExtensionOnBuildComplete,
|
||||
notifyExtensionOnBuildStart,
|
||||
resolvePluginsForContext,
|
||||
} from "./extensions.js";
|
||||
import { createExternalsBuildExtension } from "./externals.js";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import { generateContainerfile } from "../deploy/buildImage.js";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { buildManifestToJSON } from "../utilities/buildManifest.js";
|
||||
import { readPackageJSON } from "pkg-types";
|
||||
import { writeJSONFile } from "../utilities/fileSystem.js";
|
||||
import { isWindows } from "std-env";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { SdkVersionExtractor } from "./plugins.js";
|
||||
import { spinner } from "../utilities/windows.js";
|
||||
|
||||
export type BuildWorkerEventListener = {
|
||||
onBundleStart?: () => void;
|
||||
onBundleComplete?: (result: BundleResult) => void;
|
||||
};
|
||||
|
||||
export type BuildWorkerOptions = {
|
||||
destination: string;
|
||||
target: BuildTarget;
|
||||
environment: string;
|
||||
branch?: string;
|
||||
resolvedConfig: ResolvedConfig;
|
||||
listener?: BuildWorkerEventListener;
|
||||
envVars?: Record<string, string>;
|
||||
rewritePaths?: boolean;
|
||||
forcedExternals?: string[];
|
||||
plain?: boolean;
|
||||
};
|
||||
|
||||
export async function buildWorker(options: BuildWorkerOptions) {
|
||||
logger.debug("Starting buildWorker", {
|
||||
options,
|
||||
});
|
||||
|
||||
const resolvedConfig = options.resolvedConfig;
|
||||
|
||||
const externalsExtension = createExternalsBuildExtension(
|
||||
options.target,
|
||||
resolvedConfig,
|
||||
options.forcedExternals
|
||||
);
|
||||
const buildContext = createBuildContext(options.target, resolvedConfig, {
|
||||
logger: options.plain
|
||||
? {
|
||||
debug: (...args) => console.log(...args),
|
||||
log: (...args) => console.log(...args),
|
||||
warn: (...args) => console.log(...args),
|
||||
progress: (message) => console.log(message),
|
||||
spinner: (message) => {
|
||||
const $spinner = spinner({ plain: true });
|
||||
$spinner.start(message);
|
||||
return $spinner;
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
buildContext.prependExtension(externalsExtension);
|
||||
await notifyExtensionOnBuildStart(buildContext);
|
||||
const pluginsFromExtensions = resolvePluginsForContext(buildContext);
|
||||
|
||||
const sdkVersionExtractor = new SdkVersionExtractor();
|
||||
|
||||
options.listener?.onBundleStart?.();
|
||||
|
||||
const bundleResult = await bundleWorker({
|
||||
target: options.target,
|
||||
cwd: resolvedConfig.workingDir,
|
||||
destination: options.destination,
|
||||
watch: false,
|
||||
resolvedConfig,
|
||||
plugins: [sdkVersionExtractor.plugin, ...pluginsFromExtensions],
|
||||
jsxFactory: resolvedConfig.build.jsx.factory,
|
||||
jsxFragment: resolvedConfig.build.jsx.fragment,
|
||||
jsxAutomatic: resolvedConfig.build.jsx.automatic,
|
||||
});
|
||||
|
||||
options.listener?.onBundleComplete?.(bundleResult);
|
||||
|
||||
let buildManifest = await createBuildManifestFromBundle({
|
||||
bundle: bundleResult,
|
||||
destination: options.destination,
|
||||
resolvedConfig,
|
||||
environment: options.environment,
|
||||
branch: options.branch,
|
||||
target: options.target,
|
||||
envVars: options.envVars,
|
||||
});
|
||||
|
||||
// Built-in skill bundler — discovers `ai.defineSkill` registrations
|
||||
// via a local indexer run and copies each skill folder into
|
||||
// `{destination}/.trigger/skills/{id}/` before Docker COPY picks up
|
||||
// the bundle. First-class, not a build extension.
|
||||
const skillsTmpDir = await mkdtemp(join(tmpdir(), "trigger-skills-"));
|
||||
const skillsBuildManifestPath = join(skillsTmpDir, "build.json");
|
||||
try {
|
||||
await writeFile(skillsBuildManifestPath, JSON.stringify(buildManifest));
|
||||
const skillsResult = await bundleSkills({
|
||||
buildManifest,
|
||||
buildManifestPath: skillsBuildManifestPath,
|
||||
workingDir: resolvedConfig.workingDir,
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.envVars ?? {}),
|
||||
},
|
||||
logger: buildContext.logger,
|
||||
});
|
||||
buildManifest = skillsResult.buildManifest;
|
||||
} catch (err) {
|
||||
logger.warn("Skill bundling failed; continuing without skills", err);
|
||||
} finally {
|
||||
await rm(skillsTmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest);
|
||||
|
||||
if (options.target !== "dev") {
|
||||
buildManifest = options.rewritePaths
|
||||
? rewriteBuildManifestPaths(buildManifest, options.destination)
|
||||
: buildManifest;
|
||||
|
||||
await writeDeployFiles({
|
||||
buildManifest,
|
||||
resolvedConfig,
|
||||
outputPath: options.destination,
|
||||
bundleResult,
|
||||
});
|
||||
}
|
||||
|
||||
return buildManifest;
|
||||
}
|
||||
|
||||
export function rewriteBuildManifestPaths(
|
||||
buildManifest: BuildManifest,
|
||||
destinationDir: string
|
||||
): BuildManifest {
|
||||
return {
|
||||
...buildManifest,
|
||||
files: buildManifest.files.map((file) => ({
|
||||
...file,
|
||||
entry: cleanEntryPath(file.entry),
|
||||
out: rewriteOutputPath(destinationDir, file.out),
|
||||
})),
|
||||
outputPath: rewriteOutputPath(destinationDir, buildManifest.outputPath),
|
||||
configPath: rewriteOutputPath(destinationDir, buildManifest.configPath),
|
||||
runControllerEntryPoint: buildManifest.runControllerEntryPoint
|
||||
? rewriteOutputPath(destinationDir, buildManifest.runControllerEntryPoint)
|
||||
: undefined,
|
||||
runWorkerEntryPoint: rewriteOutputPath(destinationDir, buildManifest.runWorkerEntryPoint),
|
||||
indexControllerEntryPoint: buildManifest.indexControllerEntryPoint
|
||||
? rewriteOutputPath(destinationDir, buildManifest.indexControllerEntryPoint)
|
||||
: undefined,
|
||||
indexWorkerEntryPoint: rewriteOutputPath(destinationDir, buildManifest.indexWorkerEntryPoint),
|
||||
loaderEntryPoint: buildManifest.loaderEntryPoint
|
||||
? rewriteOutputPath(destinationDir, buildManifest.loaderEntryPoint)
|
||||
: undefined,
|
||||
initEntryPoint: buildManifest.initEntryPoint
|
||||
? rewriteOutputPath(destinationDir, buildManifest.initEntryPoint)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
// Remove any query parameters from the entry path
|
||||
// For example, src/trigger/ai.ts?sentryProxyModule=true -> src/trigger/ai.ts
|
||||
function cleanEntryPath(entry: string): string {
|
||||
return entry.split("?")[0]!;
|
||||
}
|
||||
|
||||
function rewriteOutputPath(destinationDir: string, filePath: string) {
|
||||
if (isWindows) {
|
||||
return `/app/${relative(
|
||||
pathToFileURL(destinationDir).pathname,
|
||||
pathToFileURL(filePath).pathname
|
||||
)
|
||||
.split(sep)
|
||||
.join("/")}`;
|
||||
} else {
|
||||
return `/app/${relative(destinationDir, filePath)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDeployFiles({
|
||||
buildManifest,
|
||||
resolvedConfig,
|
||||
outputPath,
|
||||
bundleResult,
|
||||
}: {
|
||||
buildManifest: BuildManifest;
|
||||
resolvedConfig: ResolvedConfig;
|
||||
outputPath: string;
|
||||
bundleResult: BundleResult;
|
||||
}) {
|
||||
// Step 1. Read the package.json file
|
||||
const packageJson = await readProjectPackageJson(resolvedConfig.packageJsonPath);
|
||||
|
||||
if (!packageJson) {
|
||||
throw new Error("Could not read the package.json file");
|
||||
}
|
||||
|
||||
const dependencies =
|
||||
buildManifest.externals?.reduce(
|
||||
(acc, external) => {
|
||||
acc[external.name] = external.version;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
) ?? {};
|
||||
|
||||
// Step 3: Write the resolved dependencies to the package.json file
|
||||
await writeJSONFile(
|
||||
join(outputPath, "package.json"),
|
||||
{
|
||||
...packageJson,
|
||||
name: packageJson.name ?? "trigger-project",
|
||||
dependencies: {
|
||||
...dependencies,
|
||||
},
|
||||
trustedDependencies: Object.keys(dependencies).sort(),
|
||||
devDependencies: {},
|
||||
peerDependencies: {},
|
||||
scripts: {},
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
await writeJSONFile(join(outputPath, "build.json"), buildManifestToJSON(buildManifest));
|
||||
await writeContainerfile(outputPath, buildManifest);
|
||||
}
|
||||
|
||||
async function readProjectPackageJson(packageJsonPath: string) {
|
||||
const packageJson = await readPackageJSON(packageJsonPath);
|
||||
|
||||
return packageJson;
|
||||
}
|
||||
|
||||
async function writeContainerfile(outputPath: string, buildManifest: BuildManifest) {
|
||||
if (!buildManifest.runControllerEntryPoint || !buildManifest.indexControllerEntryPoint) {
|
||||
throw new Error("Something went wrong with the build. Aborting deployment. [code 7789]");
|
||||
}
|
||||
|
||||
const containerfile = await generateContainerfile({
|
||||
runtime: buildManifest.runtime,
|
||||
entrypoint: buildManifest.runControllerEntryPoint,
|
||||
build: buildManifest.build,
|
||||
image: buildManifest.image,
|
||||
indexScript: buildManifest.indexControllerEntryPoint,
|
||||
});
|
||||
|
||||
const containerfilePath = join(outputPath, "Containerfile");
|
||||
|
||||
logger.debug("Writing Containerfile", { containerfilePath });
|
||||
logger.debug(containerfile);
|
||||
|
||||
await writeFile(containerfilePath, containerfile);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import { CORE_VERSION } from "@trigger.dev/core/v3";
|
||||
import { DEFAULT_RUNTIME, ResolvedConfig } from "@trigger.dev/core/v3/build";
|
||||
import { BuildManifest, BuildTarget, TaskFile } from "@trigger.dev/core/v3/schemas";
|
||||
import * as esbuild from "esbuild";
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, join, relative, resolve } from "node:path";
|
||||
import { createFile, createFileWithStore } from "../utilities/fileSystem.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { resolveFileSources } from "../utilities/sourceFiles.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { createEntryPointManager } from "./entryPoints.js";
|
||||
import { copyManifestToDir } from "./manifests.js";
|
||||
import {
|
||||
getIndexControllerForTarget,
|
||||
getIndexWorkerForTarget,
|
||||
getRunControllerForTarget,
|
||||
getRunWorkerForTarget,
|
||||
isIndexControllerForTarget,
|
||||
isIndexWorkerForTarget,
|
||||
isInitEntryPoint,
|
||||
isLoaderEntryPoint,
|
||||
isRunControllerForTarget,
|
||||
isRunWorkerForTarget,
|
||||
shims,
|
||||
} from "./packageModules.js";
|
||||
import { buildPlugins } from "./plugins.js";
|
||||
import { cliLink, prettyError } from "../utilities/cliOutput.js";
|
||||
import { SkipLoggingError } from "../cli/common.js";
|
||||
|
||||
export interface BundleOptions {
|
||||
target: BuildTarget;
|
||||
destination: string;
|
||||
cwd: string;
|
||||
resolvedConfig: ResolvedConfig;
|
||||
jsxFactory?: string;
|
||||
jsxFragment?: string;
|
||||
jsxAutomatic?: boolean;
|
||||
watch?: boolean;
|
||||
plugins?: esbuild.Plugin[];
|
||||
/** Shared store directory for deduplicating chunk files via hardlinks */
|
||||
storeDir?: string;
|
||||
}
|
||||
|
||||
export type BundleResult = {
|
||||
contentHash: string;
|
||||
files: TaskFile[];
|
||||
configPath: string;
|
||||
metafile: esbuild.Metafile;
|
||||
loaderEntryPoint: string | undefined;
|
||||
runWorkerEntryPoint: string | undefined;
|
||||
runControllerEntryPoint: string | undefined;
|
||||
indexWorkerEntryPoint: string | undefined;
|
||||
indexControllerEntryPoint: string | undefined;
|
||||
initEntryPoint: string | undefined;
|
||||
stop: (() => Promise<void>) | undefined;
|
||||
/** Maps output file paths to their content hashes for deduplication */
|
||||
outputHashes: Record<string, string>;
|
||||
};
|
||||
|
||||
export class BundleError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly issues?: esbuild.Message[]
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function bundleWorker(options: BundleOptions): Promise<BundleResult> {
|
||||
const { resolvedConfig } = options;
|
||||
|
||||
let currentContext: esbuild.BuildContext | undefined;
|
||||
|
||||
const entryPointManager = await createEntryPointManager(
|
||||
resolvedConfig.dirs,
|
||||
resolvedConfig,
|
||||
options.target,
|
||||
typeof options.watch === "boolean" ? options.watch : false,
|
||||
async (newEntryPoints) => {
|
||||
if (currentContext) {
|
||||
// Rebuild with new entry points
|
||||
await currentContext.cancel();
|
||||
await currentContext.dispose();
|
||||
const buildOptions = await createBuildOptions({
|
||||
...options,
|
||||
entryPoints: newEntryPoints,
|
||||
});
|
||||
|
||||
logger.debug("Rebuilding worker with options", buildOptions);
|
||||
|
||||
currentContext = await esbuild.context(buildOptions);
|
||||
await currentContext.watch();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (entryPointManager.entryPoints.length === 0) {
|
||||
const errorMessageBody = `
|
||||
Dirs config:
|
||||
${resolvedConfig.dirs.join("\n- ")}
|
||||
|
||||
Search patterns:
|
||||
${entryPointManager.patterns.join("\n- ")}
|
||||
|
||||
Possible solutions:
|
||||
1. Check if the directory paths in your config are correct
|
||||
2. Verify that your files match the search patterns
|
||||
3. Update the search patterns in your config
|
||||
`.replace(/^ {6}/gm, "");
|
||||
|
||||
prettyError(
|
||||
"No trigger files found",
|
||||
errorMessageBody,
|
||||
cliLink("View the config docs", "https://trigger.dev/docs/config/config-file")
|
||||
);
|
||||
|
||||
throw new SkipLoggingError();
|
||||
}
|
||||
|
||||
let initialBuildResult: (result: esbuild.BuildResult) => void;
|
||||
const initialBuildResultPromise = new Promise<esbuild.BuildResult>(
|
||||
(resolve) => (initialBuildResult = resolve)
|
||||
);
|
||||
const buildResultPlugin: esbuild.Plugin = {
|
||||
name: "Initial build result plugin",
|
||||
setup(build) {
|
||||
build.onEnd(initialBuildResult);
|
||||
},
|
||||
};
|
||||
|
||||
const buildOptions = await createBuildOptions({
|
||||
...options,
|
||||
entryPoints: entryPointManager.entryPoints,
|
||||
buildResultPlugin,
|
||||
});
|
||||
|
||||
let result: esbuild.BuildResult<typeof buildOptions>;
|
||||
let stop: BundleResult["stop"];
|
||||
|
||||
logger.debug("Building worker with options", buildOptions);
|
||||
|
||||
if (options.watch) {
|
||||
currentContext = await esbuild.context(buildOptions);
|
||||
await currentContext.watch();
|
||||
result = await initialBuildResultPromise;
|
||||
if (result.errors.length > 0) {
|
||||
throw new BundleError("Failed to build", result.errors);
|
||||
}
|
||||
|
||||
stop = async function () {
|
||||
await entryPointManager.stop();
|
||||
await currentContext?.dispose();
|
||||
};
|
||||
} else {
|
||||
result = await esbuild.build(buildOptions);
|
||||
|
||||
stop = async function () {
|
||||
await entryPointManager.stop();
|
||||
};
|
||||
}
|
||||
|
||||
const bundleResult = await getBundleResultFromBuild(
|
||||
options.target,
|
||||
options.cwd,
|
||||
options.resolvedConfig,
|
||||
result,
|
||||
options.storeDir
|
||||
);
|
||||
|
||||
if (!bundleResult) {
|
||||
throw new Error("Failed to get bundle result");
|
||||
}
|
||||
|
||||
return { ...bundleResult, stop };
|
||||
}
|
||||
|
||||
// Helper function to create build options
|
||||
async function createBuildOptions(
|
||||
options: BundleOptions & { entryPoints: string[]; buildResultPlugin?: esbuild.Plugin }
|
||||
): Promise<esbuild.BuildOptions & { metafile: true }> {
|
||||
const customConditions = options.resolvedConfig.build?.conditions ?? [];
|
||||
const conditions = [...customConditions, "trigger.dev", "module", "node"];
|
||||
|
||||
const keepNames =
|
||||
options.resolvedConfig.build?.keepNames ??
|
||||
options.resolvedConfig.build?.experimental_keepNames ??
|
||||
true;
|
||||
const minify =
|
||||
options.resolvedConfig.build?.minify ??
|
||||
options.resolvedConfig.build?.experimental_minify ??
|
||||
false;
|
||||
|
||||
const $buildPlugins = await buildPlugins(options.target, options.resolvedConfig);
|
||||
|
||||
return {
|
||||
entryPoints: options.entryPoints,
|
||||
outdir: options.destination,
|
||||
absWorkingDir: options.cwd,
|
||||
bundle: true,
|
||||
metafile: true,
|
||||
write: false,
|
||||
minify,
|
||||
splitting: true,
|
||||
charset: "utf8",
|
||||
platform: "node",
|
||||
sourcemap: true,
|
||||
sourcesContent: options.target === "dev",
|
||||
conditions,
|
||||
keepNames,
|
||||
format: "esm",
|
||||
target: ["node20", "es2022"],
|
||||
loader: {
|
||||
".js": "jsx",
|
||||
".mjs": "jsx",
|
||||
".cjs": "jsx",
|
||||
".wasm": "copy",
|
||||
},
|
||||
outExtension: { ".js": ".mjs" },
|
||||
inject: [...shims], // TODO: copy this into the working dir to work with Yarn PnP
|
||||
jsx: options.jsxAutomatic ? "automatic" : undefined,
|
||||
jsxDev: options.jsxAutomatic && options.target === "dev" ? true : undefined,
|
||||
plugins: [
|
||||
...$buildPlugins,
|
||||
...(options.plugins ?? []),
|
||||
...(options.buildResultPlugin ? [options.buildResultPlugin] : []),
|
||||
],
|
||||
...(options.jsxFactory && { jsxFactory: options.jsxFactory }),
|
||||
...(options.jsxFragment && { jsxFragment: options.jsxFragment }),
|
||||
logLevel: "silent",
|
||||
logOverride: {
|
||||
"empty-glob": "silent",
|
||||
"package.json": "silent",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBundleResultFromBuild(
|
||||
target: BuildTarget,
|
||||
workingDir: string,
|
||||
resolvedConfig: ResolvedConfig,
|
||||
result: esbuild.BuildResult<{ metafile: true; write: false }>,
|
||||
storeDir?: string
|
||||
): Promise<Omit<BundleResult, "stop"> | undefined> {
|
||||
const hasher = createHash("md5");
|
||||
const outputHashes: Record<string, string> = {};
|
||||
|
||||
for (const outputFile of result.outputFiles) {
|
||||
hasher.update(outputFile.hash);
|
||||
// Store the hash for each output file (keyed by path)
|
||||
outputHashes[outputFile.path] = outputFile.hash;
|
||||
|
||||
if (storeDir) {
|
||||
// Use content-addressable store with esbuild's built-in hash for ALL files
|
||||
await createFileWithStore(outputFile.path, outputFile.contents, storeDir, outputFile.hash);
|
||||
} else {
|
||||
await createFile(outputFile.path, outputFile.contents);
|
||||
}
|
||||
}
|
||||
|
||||
const files: Array<{ entry: string; out: string }> = [];
|
||||
|
||||
let configPath: string | undefined;
|
||||
let loaderEntryPoint: string | undefined;
|
||||
let runWorkerEntryPoint: string | undefined;
|
||||
let runControllerEntryPoint: string | undefined;
|
||||
let indexWorkerEntryPoint: string | undefined;
|
||||
let indexControllerEntryPoint: string | undefined;
|
||||
let initEntryPoint: string | undefined;
|
||||
|
||||
const configEntryPoint = resolvedConfig.configFile
|
||||
? relative(resolvedConfig.workingDir, resolvedConfig.configFile)
|
||||
: "trigger.config.ts";
|
||||
|
||||
for (const [outputPath, outputMeta] of Object.entries(result.metafile.outputs)) {
|
||||
if (outputPath.endsWith(".mjs")) {
|
||||
const $outputPath = resolve(workingDir, outputPath);
|
||||
|
||||
if (!outputMeta.entryPoint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (outputMeta.entryPoint.startsWith(configEntryPoint)) {
|
||||
configPath = $outputPath;
|
||||
} else if (isLoaderEntryPoint(outputMeta.entryPoint)) {
|
||||
loaderEntryPoint = $outputPath;
|
||||
} else if (isRunControllerForTarget(outputMeta.entryPoint, target)) {
|
||||
runControllerEntryPoint = $outputPath;
|
||||
} else if (isRunWorkerForTarget(outputMeta.entryPoint, target)) {
|
||||
runWorkerEntryPoint = $outputPath;
|
||||
} else if (isIndexControllerForTarget(outputMeta.entryPoint, target)) {
|
||||
indexControllerEntryPoint = $outputPath;
|
||||
} else if (isIndexWorkerForTarget(outputMeta.entryPoint, target)) {
|
||||
indexWorkerEntryPoint = $outputPath;
|
||||
} else if (isInitEntryPoint(outputMeta.entryPoint, resolvedConfig.dirs)) {
|
||||
initEntryPoint = $outputPath;
|
||||
} else {
|
||||
if (
|
||||
!outputMeta.entryPoint.startsWith("..") &&
|
||||
!outputMeta.entryPoint.includes("node_modules")
|
||||
) {
|
||||
files.push({
|
||||
entry: outputMeta.entryPoint,
|
||||
out: $outputPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!configPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
configPath: configPath,
|
||||
loaderEntryPoint,
|
||||
runWorkerEntryPoint,
|
||||
runControllerEntryPoint,
|
||||
indexWorkerEntryPoint,
|
||||
indexControllerEntryPoint,
|
||||
initEntryPoint,
|
||||
contentHash: hasher.digest("hex"),
|
||||
metafile: result.metafile,
|
||||
outputHashes,
|
||||
};
|
||||
}
|
||||
|
||||
// Converts a directory to a glob that matches all the entry points in that
|
||||
function dirToEntryPointGlob(dir: string): string[] {
|
||||
return [
|
||||
join(dir, "**", "*.ts"),
|
||||
join(dir, "**", "*.tsx"),
|
||||
join(dir, "**", "*.mts"),
|
||||
join(dir, "**", "*.cts"),
|
||||
join(dir, "**", "*.js"),
|
||||
join(dir, "**", "*.jsx"),
|
||||
join(dir, "**", "*.mjs"),
|
||||
join(dir, "**", "*.cjs"),
|
||||
];
|
||||
}
|
||||
|
||||
export function logBuildWarnings(warnings: esbuild.Message[]) {
|
||||
const logs = esbuild.formatMessagesSync(warnings, { kind: "warning", color: true });
|
||||
for (const log of logs) {
|
||||
console.warn(log);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs all errors/warnings associated with an esbuild BuildFailure in the same
|
||||
* style esbuild would.
|
||||
*/
|
||||
export function logBuildFailure(errors: esbuild.Message[], warnings: esbuild.Message[]) {
|
||||
const logs = esbuild.formatMessagesSync(errors, { kind: "error", color: true });
|
||||
for (const log of logs) {
|
||||
console.error(log);
|
||||
}
|
||||
logBuildWarnings(warnings);
|
||||
}
|
||||
|
||||
export async function createBuildManifestFromBundle({
|
||||
bundle,
|
||||
destination,
|
||||
resolvedConfig,
|
||||
workerDir,
|
||||
environment,
|
||||
branch,
|
||||
target,
|
||||
envVars,
|
||||
sdkVersion,
|
||||
storeDir,
|
||||
}: {
|
||||
bundle: BundleResult;
|
||||
destination: string;
|
||||
resolvedConfig: ResolvedConfig;
|
||||
workerDir?: string;
|
||||
environment: string;
|
||||
branch?: string;
|
||||
target: BuildTarget;
|
||||
envVars?: Record<string, string>;
|
||||
sdkVersion?: string;
|
||||
storeDir?: string;
|
||||
}): Promise<BuildManifest> {
|
||||
const buildManifest: BuildManifest = {
|
||||
contentHash: bundle.contentHash,
|
||||
runtime: resolvedConfig.runtime ?? DEFAULT_RUNTIME,
|
||||
environment: environment,
|
||||
branch,
|
||||
packageVersion: sdkVersion ?? CORE_VERSION,
|
||||
cliPackageVersion: VERSION,
|
||||
target: target,
|
||||
files: bundle.files,
|
||||
sources: await resolveFileSources(bundle.files, resolvedConfig),
|
||||
externals: [],
|
||||
config: {
|
||||
project: resolvedConfig.project,
|
||||
dirs: resolvedConfig.dirs,
|
||||
},
|
||||
outputPath: destination,
|
||||
indexControllerEntryPoint:
|
||||
bundle.indexControllerEntryPoint ?? getIndexControllerForTarget(target),
|
||||
indexWorkerEntryPoint: bundle.indexWorkerEntryPoint ?? getIndexWorkerForTarget(target),
|
||||
runControllerEntryPoint: bundle.runControllerEntryPoint ?? getRunControllerForTarget(target),
|
||||
runWorkerEntryPoint: bundle.runWorkerEntryPoint ?? getRunWorkerForTarget(target),
|
||||
loaderEntryPoint: bundle.loaderEntryPoint,
|
||||
initEntryPoint: bundle.initEntryPoint,
|
||||
configPath: bundle.configPath,
|
||||
customConditions: resolvedConfig.build.conditions ?? [],
|
||||
deploy: {
|
||||
env: envVars ?? {},
|
||||
},
|
||||
build: {},
|
||||
otelImportHook: {
|
||||
include: resolvedConfig.instrumentedPackageNames ?? [],
|
||||
},
|
||||
// `outputHashes` is only needed for dev builds for the deduplication mechanism during rebuilds.
|
||||
// For deploys builds, we omit it to ensure deterministic builds
|
||||
outputHashes: target === "dev" ? bundle.outputHashes : {},
|
||||
};
|
||||
|
||||
if (!workerDir) {
|
||||
return buildManifest;
|
||||
}
|
||||
|
||||
return copyManifestToDir(buildManifest, destination, workerDir, storeDir);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { isAbsolute, join, resolve as resolvePath } from "node:path";
|
||||
import type { BuildManifest, SkillManifest } from "@trigger.dev/core/v3/schemas";
|
||||
import { copyDirectoryRecursive } from "@trigger.dev/build/internal";
|
||||
import { indexWorkerManifest } from "../indexing/indexWorkerManifest.js";
|
||||
import { execOptionsForRuntime, type BuildLogger } from "@trigger.dev/core/v3/build";
|
||||
|
||||
export type BundleSkillsOptions = {
|
||||
buildManifest: BuildManifest;
|
||||
buildManifestPath: string;
|
||||
workingDir: string;
|
||||
env: Record<string, string | undefined>;
|
||||
logger: BuildLogger;
|
||||
};
|
||||
|
||||
export type BundleSkillsResult = {
|
||||
/** The input manifest, annotated with `skills` on return. */
|
||||
buildManifest: BuildManifest;
|
||||
/** Discovered skills, in deterministic order. */
|
||||
skills: SkillManifest[];
|
||||
};
|
||||
|
||||
export type CopySkillFoldersOptions = {
|
||||
skills: SkillManifest[];
|
||||
/** Root where `{destinationRoot}/{id}/` folders will be created. */
|
||||
destinationRoot: string;
|
||||
/** Used to resolve relative `filePath` references in skill manifests. */
|
||||
workingDir: string;
|
||||
/** Only `debug` is used. `BuildLogger` and the cli `logger` both satisfy this shape. */
|
||||
logger: { debug: (...args: unknown[]) => void };
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy each skill's source folder to `{destinationRoot}/{id}/`. Validates
|
||||
* that `SKILL.md` exists and has the required frontmatter. Pure file IO —
|
||||
* no indexer subprocess, no env handling.
|
||||
*
|
||||
* Used by the dev path (driven by the main worker indexer's skills list)
|
||||
* and indirectly by the deploy path (via `bundleSkills` which discovers
|
||||
* skills via its own indexer pass first, then delegates here).
|
||||
*/
|
||||
export async function copySkillFolders(options: CopySkillFoldersOptions): Promise<SkillManifest[]> {
|
||||
const { skills, destinationRoot, workingDir, logger } = options;
|
||||
|
||||
if (skills.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const skill of skills) {
|
||||
const callerDir = skill.filePath ? resolvePath(workingDir, skill.filePath, "..") : workingDir;
|
||||
const sourcePath = isAbsolute(skill.sourcePath)
|
||||
? skill.sourcePath
|
||||
: resolvePath(callerDir, skill.sourcePath);
|
||||
const skillMdPath = join(sourcePath, "SKILL.md");
|
||||
|
||||
let skillMd: string;
|
||||
try {
|
||||
skillMd = await readFile(skillMdPath, "utf8");
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Skill "${skill.id}": SKILL.md not found at ${skillMdPath}. ` +
|
||||
`Registered via skills.define({ id: "${skill.id}", path: "${skill.sourcePath}" }) ` +
|
||||
`at ${skill.filePath}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^---\r?\n[\s\S]*?\r?\n---/.test(skillMd)) {
|
||||
throw new Error(
|
||||
`Skill "${skill.id}": SKILL.md at ${skillMdPath} is missing a frontmatter block.`
|
||||
);
|
||||
}
|
||||
if (!/\bname:\s*\S/.test(skillMd) || !/\bdescription:\s*\S/.test(skillMd)) {
|
||||
throw new Error(
|
||||
`Skill "${skill.id}": SKILL.md at ${skillMdPath} frontmatter must include both \`name\` and \`description\`.`
|
||||
);
|
||||
}
|
||||
|
||||
const skillDest = join(destinationRoot, skill.id);
|
||||
logger.debug(`[copySkillFolders] Copying ${sourcePath} → ${skillDest}`);
|
||||
await copyDirectoryRecursive(sourcePath, skillDest);
|
||||
}
|
||||
|
||||
return [...skills].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in skill bundler — not an extension. Runs the indexer locally
|
||||
* against the bundled worker output to discover `skills.define(...)`
|
||||
* registrations, validates each skill's `SKILL.md`, and copies the
|
||||
* folder into `{outputPath}/.trigger/skills/{id}/` so the deploy image
|
||||
* picks it up via the existing Dockerfile `COPY`.
|
||||
*
|
||||
* Used by the deploy path. The dev path uses `copySkillFolders` directly,
|
||||
* driven by the main worker indexer that already runs in `BackgroundWorker.initialize` —
|
||||
* no duplicate indexer pass needed there.
|
||||
*
|
||||
* No `trigger.config.ts` changes required — discovery is side-effect
|
||||
* based, same mechanism as task/prompt registration.
|
||||
*/
|
||||
export async function bundleSkills(options: BundleSkillsOptions): Promise<BundleSkillsResult> {
|
||||
const { buildManifest, buildManifestPath, workingDir, env, logger } = options;
|
||||
|
||||
let skills: SkillManifest[];
|
||||
try {
|
||||
const workerManifest = await indexWorkerManifest({
|
||||
runtime: buildManifest.runtime,
|
||||
indexWorkerPath: buildManifest.indexWorkerEntryPoint,
|
||||
buildManifestPath,
|
||||
nodeOptions: execOptionsForRuntime(buildManifest.runtime, buildManifest),
|
||||
env,
|
||||
cwd: workingDir,
|
||||
otelHookInclude: buildManifest.otelImportHook?.include,
|
||||
otelHookExclude: buildManifest.otelImportHook?.exclude,
|
||||
handleStdout(data) {
|
||||
logger.debug(`[bundleSkills] ${data}`);
|
||||
},
|
||||
handleStderr(data) {
|
||||
if (!data.includes("Debugger attached")) {
|
||||
logger.debug(`[bundleSkills:stderr] ${data}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
skills = workerManifest.skills ?? [];
|
||||
} catch (err) {
|
||||
// Skill discovery via the indexer is best-effort — if the user's
|
||||
// bundle doesn't load cleanly here the downstream full indexer will
|
||||
// surface the real error. Warn so the user sees what went wrong.
|
||||
logger.warn(
|
||||
`[bundleSkills] skill discovery failed, skipping skill bundling: ${(err as Error).message}`
|
||||
);
|
||||
return { buildManifest, skills: [] };
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
return { buildManifest, skills: [] };
|
||||
}
|
||||
|
||||
// Deploy target: the Dockerfile COPY picks up everything under outputPath
|
||||
// into /app, so we target {outputPath}/.trigger/skills/{id}/ and the
|
||||
// container's cwd (/app) resolves correctly.
|
||||
const destinationRoot = join(buildManifest.outputPath, ".trigger", "skills");
|
||||
|
||||
const sortedSkills = await copySkillFolders({
|
||||
skills,
|
||||
destinationRoot,
|
||||
workingDir,
|
||||
logger,
|
||||
});
|
||||
|
||||
return {
|
||||
buildManifest: { ...buildManifest, skills: sortedSkills },
|
||||
skills: sortedSkills,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { BuildTarget } from "@trigger.dev/core/v3";
|
||||
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
|
||||
import * as chokidar from "chokidar";
|
||||
import { glob, escapePath, isDynamicPattern } from "tinyglobby";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import {
|
||||
devEntryPoints,
|
||||
managedEntryPoints,
|
||||
telemetryEntryPoint,
|
||||
unmanagedEntryPoints,
|
||||
} from "./packageModules.js";
|
||||
|
||||
type EntryPointManager = {
|
||||
entryPoints: string[];
|
||||
patterns: string[];
|
||||
ignorePatterns: string[];
|
||||
watcher?: chokidar.FSWatcher;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
const DEFAULT_IGNORE_PATTERNS = [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.mts",
|
||||
"**/*.test.cts",
|
||||
"**/*.test.js",
|
||||
"**/*.test.mjs",
|
||||
"**/*.test.cjs",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.mts",
|
||||
"**/*.spec.cts",
|
||||
"**/*.spec.js",
|
||||
"**/*.spec.mjs",
|
||||
"**/*.spec.cjs",
|
||||
];
|
||||
|
||||
export async function createEntryPointManager(
|
||||
dirs: string[],
|
||||
config: ResolvedConfig,
|
||||
target: BuildTarget,
|
||||
watch: boolean,
|
||||
onEntryPointsChange?: (entryPoints: string[]) => Promise<void>
|
||||
): Promise<EntryPointManager> {
|
||||
// Patterns to match files
|
||||
const patterns = dirs.flatMap((dir) => [
|
||||
`${
|
||||
isDynamicPattern(dir)
|
||||
? `${dir}/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`
|
||||
: `${escapePath(dir)}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`
|
||||
}`,
|
||||
]);
|
||||
|
||||
// Patterns to ignore
|
||||
let ignorePatterns = config.ignorePatterns ?? DEFAULT_IGNORE_PATTERNS;
|
||||
ignorePatterns = ignorePatterns.concat([
|
||||
"**/node_modules/**",
|
||||
"**/.git/**",
|
||||
"**/.trigger/**",
|
||||
"**/.next/**",
|
||||
]);
|
||||
|
||||
async function getEntryPoints() {
|
||||
// Get initial entry points
|
||||
const entryPoints = await glob(patterns, {
|
||||
ignore: ignorePatterns,
|
||||
absolute: false,
|
||||
cwd: config.workingDir,
|
||||
});
|
||||
|
||||
if (entryPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Add required entry points
|
||||
if (config.configFile) {
|
||||
entryPoints.push(config.configFile);
|
||||
}
|
||||
|
||||
switch (target) {
|
||||
case "dev": {
|
||||
entryPoints.push(...devEntryPoints);
|
||||
break;
|
||||
}
|
||||
case "deploy": {
|
||||
entryPoints.push(...managedEntryPoints);
|
||||
break;
|
||||
}
|
||||
case "unmanaged": {
|
||||
entryPoints.push(...unmanagedEntryPoints);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
entryPoints.push(...managedEntryPoints);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.instrumentedPackageNames?.length ?? 0 > 0) {
|
||||
entryPoints.push(telemetryEntryPoint);
|
||||
}
|
||||
|
||||
// Sort to ensure consistent comparison
|
||||
return entryPoints.sort();
|
||||
}
|
||||
|
||||
const initialEntryPoints = await getEntryPoints();
|
||||
|
||||
logger.debug("Initial entry points", {
|
||||
entryPoints: initialEntryPoints,
|
||||
patterns,
|
||||
cwd: config.workingDir,
|
||||
});
|
||||
|
||||
let currentEntryPoints = initialEntryPoints;
|
||||
|
||||
// Only setup watcher if watch is true
|
||||
let watcher: chokidar.FSWatcher | undefined;
|
||||
|
||||
if (watch && onEntryPointsChange) {
|
||||
logger.debug("Watching entry points for changes", {
|
||||
dirs,
|
||||
cwd: config.workingDir,
|
||||
patterns,
|
||||
ignorePatterns,
|
||||
});
|
||||
// Watch the parent directories
|
||||
watcher = chokidar.watch(patterns, {
|
||||
ignored: ignorePatterns,
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
useFsEvents: false,
|
||||
});
|
||||
|
||||
// Handle file changes
|
||||
const updateEntryPoints = async (event: string, path: string) => {
|
||||
logger.debug("Entry point change detected", { event, path });
|
||||
|
||||
const newEntryPoints = await getEntryPoints();
|
||||
|
||||
// Compare arrays to see if they're different
|
||||
const hasChanged =
|
||||
newEntryPoints.length !== currentEntryPoints.length ||
|
||||
newEntryPoints.some((entry, index) => entry !== currentEntryPoints[index]);
|
||||
|
||||
if (hasChanged) {
|
||||
logger.debug("Entry points changed", {
|
||||
old: currentEntryPoints,
|
||||
new: newEntryPoints,
|
||||
});
|
||||
currentEntryPoints = newEntryPoints;
|
||||
await onEntryPointsChange(newEntryPoints);
|
||||
}
|
||||
};
|
||||
|
||||
watcher
|
||||
.on("add", (path) => updateEntryPoints("add", path))
|
||||
.on("addDir", (path) => updateEntryPoints("addDir", path))
|
||||
.on("unlink", (path) => updateEntryPoints("unlink", path))
|
||||
.on("unlinkDir", (path) => updateEntryPoints("unlinkDir", path))
|
||||
.on("error", (error) => logger.error("Watcher error:", error));
|
||||
}
|
||||
|
||||
return {
|
||||
entryPoints: initialEntryPoints,
|
||||
watcher,
|
||||
patterns,
|
||||
ignorePatterns,
|
||||
stop: async () => {
|
||||
await watcher?.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
type BuildLogger,
|
||||
BuildContext,
|
||||
BuildExtension,
|
||||
BuildLayer,
|
||||
RegisteredPlugin,
|
||||
ResolvedConfig,
|
||||
} from "@trigger.dev/core/v3/build";
|
||||
import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas";
|
||||
import * as esbuild from "esbuild";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { resolveModule } from "./resolveModule.js";
|
||||
import { log } from "@clack/prompts";
|
||||
import { spinner } from "../utilities/windows.js";
|
||||
|
||||
export interface InternalBuildContext extends BuildContext {
|
||||
getLayers(): BuildLayer[];
|
||||
clearLayers(): void;
|
||||
getPlugins(): RegisteredPlugin[];
|
||||
appendExtension(extension: BuildExtension): void;
|
||||
prependExtension(extension: BuildExtension): void;
|
||||
getExtensions(): BuildExtension[];
|
||||
}
|
||||
|
||||
export async function notifyExtensionOnBuildStart(context: InternalBuildContext) {
|
||||
for (const extension of context.getExtensions()) {
|
||||
if (extension.onBuildStart) {
|
||||
await extension.onBuildStart(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyExtensionOnBuildComplete(
|
||||
context: InternalBuildContext,
|
||||
manifest: BuildManifest
|
||||
): Promise<BuildManifest> {
|
||||
let $manifest = manifest;
|
||||
|
||||
for (const extension of context.getExtensions()) {
|
||||
if (extension.onBuildComplete) {
|
||||
try {
|
||||
await extension.onBuildComplete(context, $manifest);
|
||||
logger.debug(`Applying extension ${extension.name} to manifest`, {
|
||||
context,
|
||||
manifest,
|
||||
});
|
||||
$manifest = applyContextLayersToManifest(context, $manifest);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to apply extension ${extension.name} onBuildComplete`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
export function createBuildContext(
|
||||
target: BuildTarget,
|
||||
config: ResolvedConfig,
|
||||
options?: { logger?: BuildLogger }
|
||||
): InternalBuildContext {
|
||||
const layers: BuildLayer[] = [];
|
||||
const registeredPlugins: RegisteredPlugin[] = [];
|
||||
const extensions: BuildExtension[] = config.build.extensions ?? [];
|
||||
|
||||
const buildLogger = options?.logger ?? {
|
||||
debug: (...args) => logger.debug(...args),
|
||||
log: (...args) => logger.log(...args),
|
||||
warn: (...args) => logger.warn(...args),
|
||||
progress: (message) => log.message(message),
|
||||
spinner: (message) => {
|
||||
const $spinner = spinner();
|
||||
$spinner.start(message);
|
||||
return $spinner;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
target,
|
||||
config: config,
|
||||
workingDir: config.workingDir,
|
||||
addLayer(layer) {
|
||||
layers.push(layer);
|
||||
},
|
||||
getLayers() {
|
||||
return layers;
|
||||
},
|
||||
clearLayers() {
|
||||
layers.splice(0);
|
||||
},
|
||||
registerPlugin(plugin, options) {
|
||||
if (options?.target && options.target !== target) {
|
||||
return;
|
||||
}
|
||||
|
||||
registeredPlugins.push({ plugin, ...options });
|
||||
},
|
||||
getPlugins() {
|
||||
return registeredPlugins;
|
||||
},
|
||||
resolvePath: async (path) => {
|
||||
try {
|
||||
return await resolveModule(path, config.workingDir);
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
getExtensions() {
|
||||
return extensions;
|
||||
},
|
||||
appendExtension(extension) {
|
||||
extensions.push(extension);
|
||||
},
|
||||
prependExtension(extension) {
|
||||
extensions.unshift(extension);
|
||||
},
|
||||
logger: buildLogger,
|
||||
};
|
||||
}
|
||||
|
||||
function applyContextLayersToManifest(
|
||||
context: InternalBuildContext,
|
||||
manifest: BuildManifest
|
||||
): BuildManifest {
|
||||
for (const layer of context.getLayers()) {
|
||||
manifest = applyLayerToManifest(layer, manifest);
|
||||
}
|
||||
|
||||
context.clearLayers();
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function applyLayerToManifest(layer: BuildLayer, manifest: BuildManifest): BuildManifest {
|
||||
let $manifest = { ...manifest };
|
||||
|
||||
if (layer.commands) {
|
||||
$manifest.build.commands ??= [];
|
||||
$manifest.build.commands = $manifest.build.commands.concat(layer.commands);
|
||||
}
|
||||
|
||||
if (layer.build?.env) {
|
||||
$manifest.build.env ??= {};
|
||||
Object.assign($manifest.build.env, layer.build.env);
|
||||
}
|
||||
|
||||
if (layer.deploy?.env) {
|
||||
$manifest.deploy.env ??= {};
|
||||
$manifest.deploy.sync ??= {};
|
||||
$manifest.deploy.sync.env ??= {};
|
||||
$manifest.deploy.sync.parentEnv ??= {};
|
||||
|
||||
for (const [key, value] of Object.entries(layer.deploy.env)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (layer.deploy.override || $manifest.deploy.env[key] === undefined) {
|
||||
const existingValue = $manifest.deploy.env[key];
|
||||
|
||||
if (existingValue !== value) {
|
||||
$manifest.deploy.sync.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (layer.deploy?.parentEnv) {
|
||||
$manifest.deploy.env ??= {};
|
||||
$manifest.deploy.sync ??= {};
|
||||
$manifest.deploy.sync.parentEnv ??= {};
|
||||
|
||||
for (const [key, value] of Object.entries(layer.deploy.parentEnv)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (layer.deploy.override || $manifest.deploy.env[key] === undefined) {
|
||||
const existingValue = $manifest.deploy.env[key];
|
||||
|
||||
if (existingValue !== value) {
|
||||
$manifest.deploy.sync.parentEnv[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (layer.deploy?.secretEnv) {
|
||||
$manifest.deploy.env ??= {};
|
||||
$manifest.deploy.sync ??= {};
|
||||
$manifest.deploy.sync.secretEnv ??= {};
|
||||
|
||||
for (const [key, value] of Object.entries(layer.deploy.secretEnv)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (layer.deploy.override || $manifest.deploy.env[key] === undefined) {
|
||||
const existingValue = $manifest.deploy.env[key];
|
||||
|
||||
if (existingValue !== value) {
|
||||
$manifest.deploy.sync.secretEnv[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (layer.deploy?.secretParentEnv) {
|
||||
$manifest.deploy.env ??= {};
|
||||
$manifest.deploy.sync ??= {};
|
||||
$manifest.deploy.sync.secretParentEnv ??= {};
|
||||
|
||||
for (const [key, value] of Object.entries(layer.deploy.secretParentEnv)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (layer.deploy.override || $manifest.deploy.env[key] === undefined) {
|
||||
const existingValue = $manifest.deploy.env[key];
|
||||
|
||||
if (existingValue !== value) {
|
||||
$manifest.deploy.sync.secretParentEnv[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (layer.dependencies) {
|
||||
const externals = $manifest.externals ?? [];
|
||||
|
||||
for (const [name, version] of Object.entries(layer.dependencies)) {
|
||||
externals.push({ name, version });
|
||||
}
|
||||
|
||||
$manifest.externals = externals;
|
||||
}
|
||||
|
||||
if (layer.image) {
|
||||
$manifest.image ??= {};
|
||||
$manifest.image.instructions ??= [];
|
||||
$manifest.image.pkgs ??= [];
|
||||
|
||||
if (layer.image.instructions) {
|
||||
$manifest.image.instructions = $manifest.image.instructions.concat(layer.image.instructions);
|
||||
}
|
||||
|
||||
if (layer.image.pkgs) {
|
||||
$manifest.image.pkgs = $manifest.image.pkgs.concat(layer.image.pkgs);
|
||||
$manifest.image.pkgs = Array.from(new Set($manifest.image.pkgs));
|
||||
}
|
||||
}
|
||||
|
||||
if (layer.conditions) {
|
||||
$manifest.customConditions ??= [];
|
||||
$manifest.customConditions = $manifest.customConditions.concat(layer.conditions);
|
||||
$manifest.customConditions = Array.from(new Set($manifest.customConditions));
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
export function resolvePluginsForContext(context: InternalBuildContext): esbuild.Plugin[] {
|
||||
const registeredPlugins = context.getPlugins();
|
||||
|
||||
if (registeredPlugins.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedPlugins = [...registeredPlugins].sort((a, b) => {
|
||||
const order = { first: 0, undefined: 1, last: 2, $head: -1 };
|
||||
const aOrder = order[a.placement as keyof typeof order] ?? 1;
|
||||
const bOrder = order[b.placement as keyof typeof order] ?? 1;
|
||||
|
||||
// If the placement order is different, sort based on that
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
|
||||
// If the placement order is the same, maintain original order
|
||||
return registeredPlugins.indexOf(a) - registeredPlugins.indexOf(b);
|
||||
});
|
||||
|
||||
return sortedPlugins.map((plugin) => plugin.plugin);
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
import * as esbuild from "esbuild";
|
||||
import { makeRe } from "minimatch";
|
||||
import { access, mkdir, symlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { readPackageJSON, resolvePackageJSON } from "pkg-types";
|
||||
import nodeResolve from "resolve";
|
||||
import { BuildTarget } from "@trigger.dev/core/v3/schemas";
|
||||
import {
|
||||
alwaysExternal,
|
||||
BuildExtension,
|
||||
BuildLogger,
|
||||
ResolvedConfig,
|
||||
} from "@trigger.dev/core/v3/build";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { CliApiClient } from "../apiClient.js";
|
||||
import { resolvePathSync as esmResolveSync } from "mlly";
|
||||
import braces from "braces";
|
||||
import { builtinModules } from "node:module";
|
||||
import { tryCatch } from "@trigger.dev/core/v3";
|
||||
import { resolveModule } from "./resolveModule.js";
|
||||
|
||||
/**
|
||||
* externals in dev might not be resolvable from the worker directory
|
||||
* for example, if the external is not an immediate dependency of the project
|
||||
* and the project is not hoisting the dependency (e.g. pnpm, npm with nested)
|
||||
*
|
||||
* This function will create a symbolic link from a place where the external is resolvable
|
||||
* to the actual resolved external path
|
||||
*/
|
||||
async function linkUnresolvableExternals(
|
||||
externals: Array<CollectedExternal>,
|
||||
resolveDir: string,
|
||||
logger: BuildLogger
|
||||
) {
|
||||
for (const external of externals) {
|
||||
if (!(await isExternalResolvable(external, resolveDir, logger))) {
|
||||
await linkExternal(external, resolveDir, logger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function linkExternal(external: CollectedExternal, resolveDir: string, logger: BuildLogger) {
|
||||
const destinationPath = join(resolveDir, "node_modules");
|
||||
await mkdir(destinationPath, { recursive: true });
|
||||
|
||||
logger.debug("[externals] Make a symbolic link", {
|
||||
fromPath: external.path,
|
||||
destinationPath,
|
||||
external,
|
||||
});
|
||||
|
||||
// For scoped packages, we need to ensure the scope directory exists
|
||||
if (external.name.startsWith("@")) {
|
||||
// Get the scope part (e.g., '@huggingface')
|
||||
const scopeDir = external.name.split("/")[0];
|
||||
|
||||
if (scopeDir) {
|
||||
const scopePath = join(destinationPath, scopeDir);
|
||||
|
||||
logger.debug("[externals] Ensure scope directory exists", {
|
||||
scopeDir,
|
||||
scopePath,
|
||||
});
|
||||
|
||||
await mkdir(scopePath, { recursive: true });
|
||||
} else {
|
||||
logger.debug("[externals] Unable to get the scope directory", {
|
||||
external,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const symbolicLinkPath = join(destinationPath, external.name);
|
||||
|
||||
// Make sure the symbolic link does not exist
|
||||
try {
|
||||
await symlink(external.path, symbolicLinkPath, "dir");
|
||||
} catch (e) {
|
||||
logger.debug("[externals] Unable to create symbolic link", {
|
||||
error: e,
|
||||
fromPath: external.path,
|
||||
destinationPath,
|
||||
external,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function isExternalResolvable(
|
||||
external: CollectedExternal,
|
||||
resolveDir: string,
|
||||
logger: BuildLogger
|
||||
) {
|
||||
try {
|
||||
const resolvedPath = resolveSync(external.name, resolveDir);
|
||||
|
||||
logger.debug("[externals][isExternalResolvable] Resolved external", {
|
||||
resolveDir,
|
||||
external,
|
||||
resolvedPath,
|
||||
});
|
||||
|
||||
if (!resolvedPath.includes(external.path)) {
|
||||
logger.debug(
|
||||
"[externals][isExternalResolvable] resolvedPath does not match the external.path",
|
||||
{
|
||||
resolveDir,
|
||||
external,
|
||||
resolvedPath,
|
||||
}
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.debug("[externals][isExternalResolvable] Unable to resolve external", {
|
||||
resolveDir,
|
||||
external,
|
||||
error: e,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type CollectedExternal = {
|
||||
name: string;
|
||||
path: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type ExternalsCollector = {
|
||||
externals: Array<CollectedExternal>;
|
||||
plugin: esbuild.Plugin;
|
||||
};
|
||||
|
||||
function createExternalsCollector(
|
||||
target: BuildTarget,
|
||||
resolvedConfig: ResolvedConfig,
|
||||
forcedExternal: string[] = []
|
||||
): ExternalsCollector {
|
||||
const externals: Array<CollectedExternal> = [];
|
||||
|
||||
const maybeExternals = discoverMaybeExternals(target, resolvedConfig, forcedExternal);
|
||||
|
||||
// Cache: resolvedPath (dir) -> packageJsonPath (null = failed to resolve)
|
||||
const packageJsonCache = new Map<string, string | null>();
|
||||
// Cache: packageRoot (dir) -> boolean (true = mark as external)
|
||||
const isExternalCache = new Map<string, boolean>();
|
||||
|
||||
return {
|
||||
externals,
|
||||
plugin: {
|
||||
name: "externals",
|
||||
setup: (build) => {
|
||||
build.onStart(async () => {
|
||||
externals.splice(0);
|
||||
isExternalCache.clear();
|
||||
});
|
||||
|
||||
const autoDetectExternal =
|
||||
resolvedConfig.build?.autoDetectExternal ??
|
||||
resolvedConfig.build?.experimental_autoDetectExternal ??
|
||||
true;
|
||||
|
||||
build.onEnd(async () => {
|
||||
logger.debug("[externals][onEnd] Collected externals", {
|
||||
externals,
|
||||
maybeExternals,
|
||||
autoDetectExternal,
|
||||
packageJsonCache: packageJsonCache.size,
|
||||
isExternalCache: isExternalCache.size,
|
||||
});
|
||||
});
|
||||
|
||||
maybeExternals.forEach((external) => {
|
||||
build.onResolve({ filter: external.filter, namespace: "file" }, async (args) => {
|
||||
// Check if the external is already in the externals collection
|
||||
if (externals.find((e) => e.name === external.raw)) {
|
||||
return {
|
||||
external: true,
|
||||
};
|
||||
}
|
||||
|
||||
const packageName = packageNameForImportPath(args.path);
|
||||
|
||||
try {
|
||||
const resolvedPath = resolveSync(packageName, args.resolveDir);
|
||||
|
||||
logger.debug("[externals][onResolve] Resolved external", {
|
||||
external,
|
||||
resolvedPath,
|
||||
args,
|
||||
packageName,
|
||||
});
|
||||
|
||||
const packageJsonPath = await resolvePackageJSON(dirname(resolvedPath));
|
||||
|
||||
if (!packageJsonPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logger.debug("[externals][onResolve] Found package.json", {
|
||||
packageJsonPath,
|
||||
external,
|
||||
resolvedPath,
|
||||
args,
|
||||
packageName,
|
||||
});
|
||||
|
||||
const packageJson = await readPackageJSON(packageJsonPath);
|
||||
|
||||
if (!packageJson || !packageJson.name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!external.filter.test(packageJson.name)) {
|
||||
logger.debug("[externals][onResolve] Package name does not match", {
|
||||
external,
|
||||
packageJson,
|
||||
resolvedPath,
|
||||
packageName,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!packageJson.version) {
|
||||
logger.debug("[externals][onResolve] No version found in package.json", {
|
||||
external,
|
||||
packageJson,
|
||||
resolvedPath,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
externals.push({
|
||||
name: packageName,
|
||||
path: dirname(packageJsonPath),
|
||||
version: packageJson.version,
|
||||
});
|
||||
|
||||
logger.debug("[externals][onResolve] adding external to the externals collection", {
|
||||
external,
|
||||
resolvedPath,
|
||||
args,
|
||||
packageName,
|
||||
resolvedExternal: {
|
||||
name: packageJson.name,
|
||||
path: dirname(packageJsonPath),
|
||||
version: packageJson.version,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
external: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.debug("[externals][onResolve] Unable to resolve external", {
|
||||
external,
|
||||
error,
|
||||
args,
|
||||
packageName,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (autoDetectExternal) {
|
||||
build.onResolve(
|
||||
{ filter: /.*/, namespace: "file" },
|
||||
async (args: esbuild.OnResolveArgs): Promise<esbuild.OnResolveResult | undefined> => {
|
||||
if (!isBareModuleImport(args.path)) {
|
||||
// Not an npm package
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBuiltinModule(args.path)) {
|
||||
// Builtin module
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.path === "_sentry-debug-id-injection-stub") {
|
||||
// Ignore sentry stub
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to resolve the actual file path
|
||||
const [resolveError, resolvedPath] = await tryCatch(
|
||||
resolveModule(args.path, args.resolveDir)
|
||||
);
|
||||
|
||||
if (resolveError) {
|
||||
logger.debug("[externals][auto] Resolve module error", {
|
||||
path: args.path,
|
||||
resolveError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find nearest package.json
|
||||
const packageJsonPath = await findNearestPackageJson(resolvedPath, packageJsonCache);
|
||||
|
||||
if (!packageJsonPath) {
|
||||
logger.debug("[externals][auto] Failed to resolve package.json path", {
|
||||
path: args.path,
|
||||
resolvedPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const packageRoot = dirname(packageJsonPath);
|
||||
|
||||
// Check cache first
|
||||
if (isExternalCache.has(packageRoot)) {
|
||||
const isExternal = isExternalCache.get(packageRoot);
|
||||
|
||||
if (isExternal) {
|
||||
return { path: args.path, external: true };
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [readError, packageJson] = await tryCatch(readPackageJSON(packageRoot));
|
||||
|
||||
if (readError) {
|
||||
logger.debug("[externals][auto] Unable to read package.json", {
|
||||
error: readError,
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
isExternalCache.set(packageRoot, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageName = packageJson.name;
|
||||
const packageVersion = packageJson.version;
|
||||
|
||||
if (!packageName || !packageVersion) {
|
||||
logger.debug("[externals][auto] No package name or version found in package.json", {
|
||||
packageRoot,
|
||||
packageJson,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const markExternal = (reason: string): esbuild.OnResolveResult => {
|
||||
const detectedPackage = {
|
||||
name: packageName,
|
||||
path: packageRoot,
|
||||
version: packageVersion,
|
||||
} satisfies CollectedExternal;
|
||||
|
||||
logger.debug(`[externals][auto] Marking as external - ${reason}`, {
|
||||
detectedPackage,
|
||||
});
|
||||
|
||||
externals.push(detectedPackage);
|
||||
|
||||
// Cache the result
|
||||
isExternalCache.set(packageRoot, true);
|
||||
|
||||
return { path: args.path, external: true };
|
||||
};
|
||||
|
||||
// If the path ends with .wasm or .node, we should mark it as external
|
||||
if (resolvedPath.endsWith(".wasm") || resolvedPath.endsWith(".node")) {
|
||||
return markExternal("path ends with .wasm or .node");
|
||||
}
|
||||
|
||||
// Check files, main, module fields for native files
|
||||
const files = Array.isArray(packageJson.files) ? packageJson.files : [];
|
||||
const fields = [packageJson.main, packageJson.module, packageJson.browser].filter(
|
||||
(f): f is string => typeof f === "string"
|
||||
);
|
||||
const allFiles = files.concat(fields);
|
||||
|
||||
// We need to expand any braces in the files array, e.g. ["{js,ts}"] -> ["js", "ts"]
|
||||
const allFilesExpanded = braces(allFiles, { expand: true });
|
||||
|
||||
// Use a regexp to match native-related extensions
|
||||
const nativeExtRegexp = /\.(wasm|node|gyp|c|cc|cpp|cxx|h|hpp|hxx)$/;
|
||||
const hasNativeFile = allFilesExpanded.some((file) => nativeExtRegexp.test(file));
|
||||
|
||||
if (hasNativeFile) {
|
||||
return markExternal("has native file");
|
||||
}
|
||||
|
||||
// Check if binding.gyp exists (native addon)
|
||||
const bindingGypPath = join(packageRoot, "binding.gyp");
|
||||
|
||||
// If access succeeds, binding.gyp exists
|
||||
const [accessError] = await tryCatch(access(bindingGypPath));
|
||||
|
||||
if (!accessError) {
|
||||
return markExternal("binding.gyp exists");
|
||||
}
|
||||
|
||||
// Cache the negative result
|
||||
isExternalCache.set(packageRoot, false);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type MaybeExternal = { raw: string; filter: RegExp };
|
||||
|
||||
function discoverMaybeExternals(
|
||||
target: BuildTarget,
|
||||
config: ResolvedConfig,
|
||||
forcedExternal: string[] = []
|
||||
): Array<MaybeExternal> {
|
||||
const external: Array<MaybeExternal> = [];
|
||||
|
||||
for (const externalName of forcedExternal) {
|
||||
const externalRegex = makeRe(externalName);
|
||||
|
||||
if (!externalRegex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
external.push({
|
||||
raw: externalName,
|
||||
filter: new RegExp(`^${externalName}$|${externalRegex.source}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (config.build?.external) {
|
||||
for (const externalName of config.build?.external) {
|
||||
const externalRegex = makeExternalRegexp(externalName);
|
||||
|
||||
if (!externalRegex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
external.push({
|
||||
raw: externalName,
|
||||
filter: externalRegex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const externalName of config.instrumentedPackageNames ?? []) {
|
||||
const externalRegex = makeExternalRegexp(externalName);
|
||||
|
||||
if (!externalRegex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
external.push({
|
||||
raw: externalName,
|
||||
filter: externalRegex,
|
||||
});
|
||||
}
|
||||
|
||||
for (const buildExtension of config.build?.extensions ?? []) {
|
||||
const moduleExternals = buildExtension.externalsForTarget?.(target);
|
||||
|
||||
for (const externalName of moduleExternals ?? []) {
|
||||
const externalRegex = makeExternalRegexp(externalName);
|
||||
|
||||
if (!externalRegex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
external.push({
|
||||
raw: externalName,
|
||||
filter: externalRegex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return external;
|
||||
}
|
||||
|
||||
export function createExternalsBuildExtension(
|
||||
target: BuildTarget,
|
||||
config: ResolvedConfig,
|
||||
forcedExternal: string[] = []
|
||||
): BuildExtension {
|
||||
const { externals, plugin } = createExternalsCollector(target, config, forcedExternal);
|
||||
|
||||
return {
|
||||
name: "externals",
|
||||
onBuildStart(context) {
|
||||
context.registerPlugin(plugin, {
|
||||
target,
|
||||
// @ts-expect-error
|
||||
placement: "$head", // cheat to get to the front of the plugins
|
||||
});
|
||||
},
|
||||
onBuildComplete: async (context, manifest) => {
|
||||
if (context.target === "dev") {
|
||||
await linkUnresolvableExternals(externals, manifest.outputPath, context.logger);
|
||||
}
|
||||
|
||||
context.addLayer({
|
||||
id: "externals",
|
||||
dependencies: externals.reduce(
|
||||
(acc, external) => {
|
||||
acc[external.name] = external.version;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeExternalRegexp(packageName: string): RegExp {
|
||||
// Escape special regex characters in the package name
|
||||
const escapedPkg = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
// Create the regex pattern
|
||||
const pattern = `^${escapedPkg}(?:/[^'"]*)?$`;
|
||||
|
||||
return new RegExp(pattern);
|
||||
}
|
||||
|
||||
function packageNameForImportPath(importPath: string): string {
|
||||
// Remove any leading '@' to handle it separately
|
||||
const withoutAtSign = importPath.replace(/^@/, "");
|
||||
|
||||
// Split the path by '/'
|
||||
const parts = withoutAtSign.split("/");
|
||||
|
||||
// Handle scoped packages
|
||||
if (importPath.startsWith("@")) {
|
||||
// Return '@org/package' for scoped packages
|
||||
return "@" + parts.slice(0, 2).join("/");
|
||||
} else {
|
||||
// Return just the first part for non-scoped packages
|
||||
return parts[0] as string;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveAlwaysExternal(client: CliApiClient): Promise<string[]> {
|
||||
try {
|
||||
const response = await client.retrieveExternals();
|
||||
|
||||
if (response.success) {
|
||||
return response.data.externals;
|
||||
}
|
||||
|
||||
return alwaysExternal;
|
||||
} catch (error) {
|
||||
logger.debug("[externals][resolveAlwaysExternal] Unable to retrieve externals", {
|
||||
error,
|
||||
});
|
||||
|
||||
return alwaysExternal;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSync(id: string, resolveDir: string) {
|
||||
try {
|
||||
return nodeResolve.sync(id, { basedir: resolveDir });
|
||||
} catch (error) {
|
||||
return esmResolveSync(id, { url: resolveDir });
|
||||
}
|
||||
}
|
||||
|
||||
function isBareModuleImport(path: string): boolean {
|
||||
const excludes = [".", "/", "~", "file:", "data:"];
|
||||
return !excludes.some((exclude) => path.startsWith(exclude));
|
||||
}
|
||||
|
||||
function isBuiltinModule(path: string): boolean {
|
||||
return builtinModules.includes(path.replace("node:", ""));
|
||||
}
|
||||
|
||||
async function isMainPackageJson(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const packageJson = await readPackageJSON(filePath);
|
||||
|
||||
// Allowlist of non-informative fields that can appear with 'type: module | commonjs' in marker package.json files
|
||||
const markerFields = new Set([
|
||||
"type",
|
||||
"sideEffects",
|
||||
"browser",
|
||||
"main",
|
||||
"module",
|
||||
"react-native",
|
||||
"name",
|
||||
]);
|
||||
|
||||
if (!packageJson.type) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const keys = Object.keys(packageJson);
|
||||
if (keys.every((k) => markerFields.has(k))) {
|
||||
return false; // type marker
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
logger.debug("[externals][containsEsmTypeMarkers] Unknown error", {
|
||||
error,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ("code" in error && error.code !== "ENOENT") {
|
||||
logger.debug("[externals][containsEsmTypeMarkers] Error", {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findNearestPackageJson(
|
||||
basePath: string,
|
||||
cache: Map<string, string | null>
|
||||
): Promise<string | null> {
|
||||
const baseDir = dirname(basePath);
|
||||
|
||||
if (cache.has(baseDir)) {
|
||||
const resolvedPath = cache.get(baseDir);
|
||||
|
||||
if (!resolvedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
const [error, packageJsonPath] = await tryCatch(
|
||||
resolvePackageJSON(dirname(basePath), {
|
||||
test: isMainPackageJson,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
cache.set(baseDir, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
cache.set(baseDir, packageJsonPath);
|
||||
return packageJsonPath;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { BuildManifest } from "@trigger.dev/core/v3/schemas";
|
||||
import { cp, link, mkdir, readdir, readFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { sanitizeHashForFilename } from "../utilities/fileSystem.js";
|
||||
|
||||
export async function copyManifestToDir(
|
||||
manifest: BuildManifest,
|
||||
source: string,
|
||||
destination: string,
|
||||
storeDir?: string
|
||||
): Promise<BuildManifest> {
|
||||
// Copy the dir from source to destination
|
||||
// If storeDir is provided, create hardlinks for files that exist in the store
|
||||
if (storeDir) {
|
||||
await copyDirWithStore(source, destination, storeDir, manifest.outputHashes);
|
||||
} else {
|
||||
await cp(source, destination, { recursive: true });
|
||||
}
|
||||
|
||||
logger.debug("Copied manifest to dir", { source, destination, storeDir });
|
||||
|
||||
// Then update the manifest to point to the new workerDir
|
||||
const updatedManifest = { ...manifest };
|
||||
|
||||
updatedManifest.configPath = updatedManifest.configPath.replace(source, destination);
|
||||
updatedManifest.loaderEntryPoint = updatedManifest.loaderEntryPoint?.replace(source, destination);
|
||||
updatedManifest.runWorkerEntryPoint = updatedManifest.runWorkerEntryPoint.replace(
|
||||
source,
|
||||
destination
|
||||
);
|
||||
updatedManifest.indexWorkerEntryPoint = updatedManifest.indexWorkerEntryPoint.replace(
|
||||
source,
|
||||
destination
|
||||
);
|
||||
|
||||
updatedManifest.files = updatedManifest.files.map((file) => {
|
||||
return {
|
||||
...file,
|
||||
out: file.out.replace(source, destination),
|
||||
};
|
||||
});
|
||||
|
||||
updatedManifest.outputPath = destination;
|
||||
|
||||
return updatedManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a hash of file contents to use as content-addressable key.
|
||||
* This is a fallback for when outputHashes is not available.
|
||||
*/
|
||||
async function computeFileHash(filePath: string): Promise<string> {
|
||||
const contents = await readFile(filePath);
|
||||
return createHash("sha256")
|
||||
.update(contents as Uint8Array)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copies a directory, using hardlinks for files that exist in the store.
|
||||
* This preserves disk space savings from the content-addressable store.
|
||||
*
|
||||
* @param source - Source directory path
|
||||
* @param destination - Destination directory path
|
||||
* @param storeDir - Content-addressable store directory
|
||||
* @param outputHashes - Optional map of file paths to their content hashes (from BuildManifest)
|
||||
*/
|
||||
async function copyDirWithStore(
|
||||
source: string,
|
||||
destination: string,
|
||||
storeDir: string,
|
||||
outputHashes?: Record<string, string>
|
||||
): Promise<void> {
|
||||
await mkdir(destination, { recursive: true });
|
||||
|
||||
const entries = await readdir(source, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourcePath = join(source, entry.name);
|
||||
const destPath = join(destination, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively copy subdirectories
|
||||
await copyDirWithStore(sourcePath, destPath, storeDir, outputHashes);
|
||||
} else if (entry.isFile()) {
|
||||
// Try to get hash from manifest first, otherwise compute it
|
||||
const contentHash = outputHashes?.[sourcePath] ?? (await computeFileHash(sourcePath));
|
||||
// Sanitize hash to be filesystem-safe (base64 can contain / and +)
|
||||
const safeHash = sanitizeHashForFilename(contentHash);
|
||||
const storePath = join(storeDir, safeHash);
|
||||
|
||||
if (existsSync(storePath)) {
|
||||
// Create hardlink to store file
|
||||
// Fall back to copy if hardlink fails (e.g., on Windows or cross-device)
|
||||
try {
|
||||
await link(storePath, destPath);
|
||||
} catch (linkError) {
|
||||
try {
|
||||
await cp(storePath, destPath);
|
||||
} catch (copyError) {
|
||||
throw linkError; // Rethrow original error if copy also fails
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// File wasn't in the store - copy normally
|
||||
await cp(sourcePath, destPath);
|
||||
}
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
// Preserve symbolic links (e.g., node_modules links)
|
||||
await cp(sourcePath, destPath, { verbatimSymlinks: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { BuildTarget } from "@trigger.dev/core/v3";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { sourceDir } from "../sourceDir.js";
|
||||
import { assertExhaustive } from "../utilities/assertExhaustive.js";
|
||||
|
||||
export const devRunWorker = join(sourceDir, "entryPoints", "dev-run-worker.js");
|
||||
export const devIndexWorker = join(sourceDir, "entryPoints", "dev-index-worker.js");
|
||||
|
||||
export const managedRunController = join(sourceDir, "entryPoints", "managed-run-controller.js");
|
||||
export const managedRunWorker = join(sourceDir, "entryPoints", "managed-run-worker.js");
|
||||
export const managedIndexController = join(sourceDir, "entryPoints", "managed-index-controller.js");
|
||||
export const managedIndexWorker = join(sourceDir, "entryPoints", "managed-index-worker.js");
|
||||
|
||||
export const unmanagedRunController = join(sourceDir, "entryPoints", "unmanaged-run-controller.js");
|
||||
export const unmanagedRunWorker = join(sourceDir, "entryPoints", "unmanaged-run-worker.js");
|
||||
export const unmanagedIndexController = join(
|
||||
sourceDir,
|
||||
"entryPoints",
|
||||
"unmanaged-index-controller.js"
|
||||
);
|
||||
export const unmanagedIndexWorker = join(sourceDir, "entryPoints", "unmanaged-index-worker.js");
|
||||
|
||||
export const telemetryEntryPoint = join(sourceDir, "entryPoints", "loader.js");
|
||||
|
||||
export const devEntryPoints = [devRunWorker, devIndexWorker];
|
||||
export const managedEntryPoints = [
|
||||
managedRunController,
|
||||
managedRunWorker,
|
||||
managedIndexController,
|
||||
managedIndexWorker,
|
||||
];
|
||||
export const unmanagedEntryPoints = [
|
||||
unmanagedRunController,
|
||||
unmanagedRunWorker,
|
||||
unmanagedIndexController,
|
||||
unmanagedIndexWorker,
|
||||
];
|
||||
|
||||
export const esmShimPath = join(sourceDir, "shims", "esm.js");
|
||||
|
||||
export const shims = [esmShimPath];
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
function isDevRunWorker(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/dev-run-worker.js") ||
|
||||
entryPoint.includes("src/entryPoints/dev-run-worker.ts")
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
function isDevIndexWorker(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/dev-index-worker.js") ||
|
||||
entryPoint.includes("src/entryPoints/dev-index-worker.ts")
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
function isManagedRunController(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/managed-run-controller.js") ||
|
||||
entryPoint.includes("src/entryPoints/managed-run-controller.ts")
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
function isManagedRunWorker(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/managed-run-worker.js") ||
|
||||
entryPoint.includes("src/entryPoints/managed-run-worker.ts")
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
function isManagedIndexController(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/managed-index-controller.js") ||
|
||||
entryPoint.includes("src/entryPoints/managed-index-controller.ts")
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
function isManagedIndexWorker(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/managed-index-worker.js") ||
|
||||
entryPoint.includes("src/entryPoints/managed-index-worker.ts")
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
function isUnmanagedRunController(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/unmanaged-run-controller.js") ||
|
||||
entryPoint.includes("src/entryPoints/unmanaged-run-controller.ts")
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
function isUnmanagedRunWorker(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/unmanaged-run-worker.js") ||
|
||||
entryPoint.includes("src/entryPoints/unmanaged-run-worker.ts")
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
function isUnmanagedIndexController(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/unmanaged-index-controller.js") ||
|
||||
entryPoint.includes("src/entryPoints/unmanaged-index-controller.ts")
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
function isUnmanagedIndexWorker(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/unmanaged-index-worker.js") ||
|
||||
entryPoint.includes("src/entryPoints/unmanaged-index-worker.ts")
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: this may look like it should not work on Windows, but it does (and changing to using path.join will break stuff)
|
||||
export function isLoaderEntryPoint(entryPoint: string) {
|
||||
return (
|
||||
entryPoint.includes("dist/esm/entryPoints/loader.js") ||
|
||||
entryPoint.includes("src/entryPoints/loader.ts")
|
||||
);
|
||||
}
|
||||
|
||||
export function isRunWorkerForTarget(entryPoint: string, target: BuildTarget) {
|
||||
switch (target) {
|
||||
case "dev":
|
||||
return isDevRunWorker(entryPoint);
|
||||
case "deploy":
|
||||
return isManagedRunWorker(entryPoint);
|
||||
case "unmanaged":
|
||||
return isUnmanagedRunWorker(entryPoint);
|
||||
default:
|
||||
assertExhaustive(target);
|
||||
}
|
||||
}
|
||||
|
||||
export function getRunWorkerForTarget(target: BuildTarget) {
|
||||
switch (target) {
|
||||
case "dev":
|
||||
return devRunWorker;
|
||||
case "deploy":
|
||||
return managedRunWorker;
|
||||
case "unmanaged":
|
||||
return unmanagedRunWorker;
|
||||
default:
|
||||
assertExhaustive(target);
|
||||
}
|
||||
}
|
||||
|
||||
export function isRunControllerForTarget(entryPoint: string, target: BuildTarget) {
|
||||
switch (target) {
|
||||
case "dev":
|
||||
return false;
|
||||
case "deploy":
|
||||
return isManagedRunController(entryPoint);
|
||||
case "unmanaged":
|
||||
return isUnmanagedRunController(entryPoint);
|
||||
default:
|
||||
assertExhaustive(target);
|
||||
}
|
||||
}
|
||||
|
||||
export function getRunControllerForTarget(target: BuildTarget) {
|
||||
switch (target) {
|
||||
case "dev":
|
||||
return undefined;
|
||||
case "deploy":
|
||||
return managedRunController;
|
||||
case "unmanaged":
|
||||
return unmanagedRunController;
|
||||
default:
|
||||
assertExhaustive(target);
|
||||
}
|
||||
}
|
||||
|
||||
export function isIndexWorkerForTarget(entryPoint: string, target: BuildTarget) {
|
||||
switch (target) {
|
||||
case "dev":
|
||||
return isDevIndexWorker(entryPoint);
|
||||
case "deploy":
|
||||
return isManagedIndexWorker(entryPoint);
|
||||
case "unmanaged":
|
||||
return isUnmanagedIndexWorker(entryPoint);
|
||||
default:
|
||||
assertExhaustive(target);
|
||||
}
|
||||
}
|
||||
|
||||
export function getIndexWorkerForTarget(target: BuildTarget) {
|
||||
switch (target) {
|
||||
case "dev":
|
||||
return devIndexWorker;
|
||||
case "deploy":
|
||||
return managedIndexWorker;
|
||||
case "unmanaged":
|
||||
return unmanagedIndexWorker;
|
||||
default:
|
||||
assertExhaustive(target);
|
||||
}
|
||||
}
|
||||
|
||||
export function isIndexControllerForTarget(entryPoint: string, target: BuildTarget) {
|
||||
switch (target) {
|
||||
case "dev":
|
||||
return false;
|
||||
case "deploy":
|
||||
return isManagedIndexController(entryPoint);
|
||||
case "unmanaged":
|
||||
return isUnmanagedIndexController(entryPoint);
|
||||
default:
|
||||
assertExhaustive(target);
|
||||
}
|
||||
}
|
||||
|
||||
export function getIndexControllerForTarget(target: BuildTarget) {
|
||||
switch (target) {
|
||||
case "dev":
|
||||
return undefined;
|
||||
case "deploy":
|
||||
return managedIndexController;
|
||||
case "unmanaged":
|
||||
return unmanagedIndexController;
|
||||
default:
|
||||
assertExhaustive(target);
|
||||
}
|
||||
}
|
||||
|
||||
export function isConfigEntryPoint(entryPoint: string) {
|
||||
return entryPoint.startsWith("trigger.config.ts");
|
||||
}
|
||||
|
||||
// Check if the entry point is an init.ts file at the root of a trigger directory
|
||||
export function isInitEntryPoint(entryPoint: string, triggerDirs: string[]): boolean {
|
||||
const initFileNames = ["init.ts", "init.mts", "init.cts", "init.js", "init.mjs", "init.cjs"];
|
||||
|
||||
// Check if it's directly in one of the trigger directories
|
||||
return triggerDirs.some((dir) => {
|
||||
const normalizedDir = resolve(dir);
|
||||
const normalizedEntryDir = resolve(dirname(entryPoint));
|
||||
|
||||
if (normalizedDir !== normalizedEntryDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Strip query string suffixes (e.g., ?sentryProxyModule=true)
|
||||
const entryPointWithoutSuffix = entryPoint.split("?")[0];
|
||||
|
||||
if (!entryPointWithoutSuffix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return initFileNames.includes(basename(entryPointWithoutSuffix));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import * as esbuild from "esbuild";
|
||||
import { BuildTarget } from "@trigger.dev/core/v3/schemas";
|
||||
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
|
||||
import { configPlugin } from "../config.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { bunPlugin } from "../runtimes/bun.js";
|
||||
import { resolvePathSync as esmResolveSync } from "mlly";
|
||||
import { readPackageJSON, resolvePackageJSON } from "pkg-types";
|
||||
import { dirname } from "node:path";
|
||||
import { readJSONFile } from "../utilities/fileSystem.js";
|
||||
|
||||
export async function buildPlugins(
|
||||
target: BuildTarget,
|
||||
resolvedConfig: ResolvedConfig
|
||||
): Promise<esbuild.Plugin[]> {
|
||||
logger.debug("Building plugins for target", target);
|
||||
|
||||
const plugins: esbuild.Plugin[] = [];
|
||||
|
||||
const $configPlugin = configPlugin(resolvedConfig);
|
||||
|
||||
if ($configPlugin) {
|
||||
plugins.push($configPlugin);
|
||||
}
|
||||
|
||||
plugins.push(polyshedPlugin());
|
||||
|
||||
if (resolvedConfig.runtime === "bun") {
|
||||
plugins.push(bunPlugin());
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
export function analyzeMetadataPlugin(): esbuild.Plugin {
|
||||
return {
|
||||
name: "analyze-metafile",
|
||||
setup(build) {
|
||||
build.onEnd(async (result) => {
|
||||
if (!result.metafile) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
await esbuild.analyzeMetafile(result.metafile, {
|
||||
verbose: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const polysheds = [
|
||||
{
|
||||
moduleName: "server-only",
|
||||
code: "export default true;",
|
||||
},
|
||||
];
|
||||
|
||||
export function polyshedPlugin(): esbuild.Plugin {
|
||||
return {
|
||||
name: "polyshed",
|
||||
setup(build) {
|
||||
for (const polyshed of polysheds) {
|
||||
build.onResolve({ filter: new RegExp(`^${polyshed.moduleName}$`) }, (args) => {
|
||||
if (args.path !== polyshed.moduleName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path: args.path,
|
||||
external: false,
|
||||
namespace: `polyshed-${polyshed.moduleName}`,
|
||||
};
|
||||
});
|
||||
|
||||
build.onLoad(
|
||||
{
|
||||
filter: new RegExp(`^${polyshed.moduleName}$`),
|
||||
namespace: `polyshed-${polyshed.moduleName}`,
|
||||
},
|
||||
(args) => {
|
||||
return {
|
||||
contents: polyshed.code,
|
||||
loader: "js",
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class SdkVersionExtractor {
|
||||
private _sdkVersion: string | undefined;
|
||||
private _ranOnce = false;
|
||||
|
||||
get sdkVersion() {
|
||||
return this._sdkVersion;
|
||||
}
|
||||
|
||||
get plugin(): esbuild.Plugin {
|
||||
return {
|
||||
name: "sdk-version",
|
||||
setup: (build) => {
|
||||
build.onResolve({ filter: /^@trigger\.dev\/sdk\// }, async (args) => {
|
||||
if (this._ranOnce) {
|
||||
return undefined;
|
||||
} else {
|
||||
this._ranOnce = true;
|
||||
}
|
||||
|
||||
logger.debug("[SdkVersionExtractor] Extracting SDK version", { args });
|
||||
|
||||
try {
|
||||
const resolvedPath = esmResolveSync(args.path, {
|
||||
url: args.resolveDir,
|
||||
});
|
||||
|
||||
logger.debug("[SdkVersionExtractor] Resolved SDK module path", { resolvedPath });
|
||||
|
||||
const packageJsonPath = await resolvePackageJSON(dirname(resolvedPath), {
|
||||
test: async (filePath) => {
|
||||
try {
|
||||
const candidate = await readJSONFile(filePath);
|
||||
|
||||
// Exclude esm type markers
|
||||
return Object.keys(candidate).length > 1 || !candidate.type;
|
||||
} catch (error) {
|
||||
logger.debug("[SdkVersionExtractor] Error during package.json test", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!packageJsonPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logger.debug("[SdkVersionExtractor] Found package.json", { packageJsonPath });
|
||||
|
||||
const packageJson = await readPackageJSON(packageJsonPath);
|
||||
|
||||
if (!packageJson.name || packageJson.name !== "@trigger.dev/sdk") {
|
||||
logger.debug("[SdkVersionExtractor] No match for SDK package name", {
|
||||
packageJsonPath,
|
||||
packageJson,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!packageJson.version) {
|
||||
logger.debug("[SdkVersionExtractor] No version found in package.json", {
|
||||
packageJsonPath,
|
||||
packageJson,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this._sdkVersion = packageJson.version;
|
||||
|
||||
logger.debug("[SdkVersionExtractor] Found SDK version", {
|
||||
args,
|
||||
packageJsonPath,
|
||||
sdkVersion: this._sdkVersion,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
logger.debug("[SdkVersionExtractor] Failed to extract SDK version", { error });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function resolveModule(moduleName: string) {
|
||||
// @ts-ignore
|
||||
return require.resolve(moduleName);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// @ts-ignore
|
||||
import { resolvePath } from "mlly";
|
||||
|
||||
export function resolveModule(moduleName: string, url?: string) {
|
||||
return resolvePath(moduleName, {
|
||||
// @ts-ignore
|
||||
url: url ?? import.meta.url,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { outro } from "@clack/prompts";
|
||||
import type { Command } from "commander";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { BundleError } from "../build/bundle.js";
|
||||
import { CLOUD_API_URL } from "../consts.js";
|
||||
import { chalkError } from "../utilities/cliOutput.js";
|
||||
import { readAuthConfigCurrentProfileName } from "../utilities/configFiles.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
|
||||
export const CommonCommandOptions = z.object({
|
||||
apiUrl: z.string().optional(),
|
||||
logLevel: z.enum(["debug", "info", "log", "warn", "error", "none"]).default("log"),
|
||||
skipTelemetry: z.boolean().default(false),
|
||||
profile: z.string().default(readAuthConfigCurrentProfileName()),
|
||||
});
|
||||
|
||||
export type CommonCommandOptions = z.infer<typeof CommonCommandOptions>;
|
||||
|
||||
export function commonOptions(command: Command) {
|
||||
return command
|
||||
.option("--profile <profile>", "The login profile to use", readAuthConfigCurrentProfileName())
|
||||
.option("-a, --api-url <value>", "Override the API URL", CLOUD_API_URL)
|
||||
.option(
|
||||
"-l, --log-level <level>",
|
||||
"The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.",
|
||||
"log"
|
||||
)
|
||||
.option("--skip-telemetry", "Opt-out of sending telemetry");
|
||||
}
|
||||
|
||||
export class SkipLoggingError extends Error {}
|
||||
export class SkipCommandError extends Error {}
|
||||
export class OutroCommandError extends SkipCommandError {}
|
||||
|
||||
export async function handleTelemetry(action: () => Promise<void>) {
|
||||
try {
|
||||
await action();
|
||||
} catch (_e) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function wrapCommandAction<T extends z.AnyZodObject, TResult>(
|
||||
name: string,
|
||||
schema: T,
|
||||
options: unknown,
|
||||
action: (opts: z.output<T>) => Promise<TResult>
|
||||
): Promise<TResult | undefined> {
|
||||
try {
|
||||
const parsedOptions = schema.safeParse(options);
|
||||
|
||||
if (!parsedOptions.success) {
|
||||
throw new Error(fromZodError(parsedOptions.error).toString());
|
||||
}
|
||||
|
||||
logger.loggerLevel = parsedOptions.data.logLevel;
|
||||
|
||||
logger.debug(`Running "${name}" with the following options`, {
|
||||
options: options,
|
||||
});
|
||||
|
||||
const result = await action(parsedOptions.data);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof SkipLoggingError) {
|
||||
// do nothing
|
||||
} else if (e instanceof OutroCommandError) {
|
||||
outro(e.message ?? "Operation cancelled");
|
||||
process.exit(1);
|
||||
} else if (e instanceof SkipCommandError) {
|
||||
// do nothing
|
||||
} else if (e instanceof BundleError) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
logger.log(`${chalkError("X Error:")} ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export const tracer = trace.getTracer("trigger.dev/cli");
|
||||
|
||||
export function installExitHandler() {
|
||||
process.on("SIGINT", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Command } from "commander";
|
||||
import { configureAnalyzeCommand } from "../commands/analyze.js";
|
||||
import { configureDeployCommand } from "../commands/deploy.js";
|
||||
import { configureDevCommand } from "../commands/dev.js";
|
||||
import { configureEnvCommand } from "../commands/env.js";
|
||||
import { configureInitCommand } from "../commands/init.js";
|
||||
import { configureListProfilesCommand } from "../commands/list-profiles.js";
|
||||
import { configureLoginCommand } from "../commands/login.js";
|
||||
import { configureLogoutCommand } from "../commands/logout.js";
|
||||
import { configurePreviewCommand } from "../commands/preview.js";
|
||||
import { configurePromoteCommand } from "../commands/promote.js";
|
||||
import { configureSwitchProfilesCommand } from "../commands/switch.js";
|
||||
import { configureUpdateCommand } from "../commands/update.js";
|
||||
import { configureWhoamiCommand } from "../commands/whoami.js";
|
||||
import { configureMintTokenCommand } from "../commands/mint-token.js";
|
||||
import { configureMcpCommand } from "../commands/mcp.js";
|
||||
import { COMMAND_NAME } from "../consts.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { installExitHandler } from "./common.js";
|
||||
import { configureInstallMcpCommand } from "../commands/install-mcp.js";
|
||||
import { configureSkillsCommand } from "../commands/skills.js";
|
||||
|
||||
export const program = new Command();
|
||||
|
||||
program
|
||||
.name(COMMAND_NAME)
|
||||
.description("Create, run locally and deploy Trigger.dev background tasks.")
|
||||
.version(VERSION, "-v, --version", "Display the version number");
|
||||
|
||||
configureLoginCommand(program);
|
||||
configureInitCommand(program);
|
||||
configureDevCommand(program);
|
||||
configureEnvCommand(program);
|
||||
configureDeployCommand(program);
|
||||
configurePromoteCommand(program);
|
||||
configureWhoamiCommand(program);
|
||||
configureMintTokenCommand(program);
|
||||
configureLogoutCommand(program);
|
||||
configureListProfilesCommand(program);
|
||||
configureSwitchProfilesCommand(program);
|
||||
configureUpdateCommand(program);
|
||||
configurePreviewCommand(program);
|
||||
configureAnalyzeCommand(program);
|
||||
configureMcpCommand(program);
|
||||
configureInstallMcpCommand(program);
|
||||
configureSkillsCommand(program);
|
||||
|
||||
installExitHandler();
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { Command } from "commander";
|
||||
import { z } from "zod";
|
||||
import { CommonCommandOptions, handleTelemetry, wrapCommandAction } from "../cli/common.js";
|
||||
import { printInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { printBundleTree, printBundleSummaryTable } from "../utilities/analyze.js";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { readJSONFile } from "../utilities/fileSystem.js";
|
||||
import { WorkerManifest } from "@trigger.dev/core/v3";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
|
||||
const AnalyzeOptions = CommonCommandOptions.pick({
|
||||
logLevel: true,
|
||||
skipTelemetry: true,
|
||||
}).extend({
|
||||
verbose: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
type AnalyzeOptions = z.infer<typeof AnalyzeOptions>;
|
||||
|
||||
export function configureAnalyzeCommand(program: Command) {
|
||||
return program
|
||||
.command("analyze [dir]", { hidden: true })
|
||||
.description("Analyze your build output (bundle size, timings, etc)")
|
||||
.option(
|
||||
"-l, --log-level <level>",
|
||||
"The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.",
|
||||
"log"
|
||||
)
|
||||
.option("--skip-telemetry", "Opt-out of sending telemetry")
|
||||
.option("--verbose", "Show detailed bundle tree (do not collapse bundles)")
|
||||
.action(async (dir, options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await analyzeCommand(dir, options);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function analyzeCommand(dir: string | undefined, options: unknown) {
|
||||
return await wrapCommandAction("analyze", AnalyzeOptions, options, async (opts) => {
|
||||
await printInitialBanner(false);
|
||||
return await analyze(dir, opts);
|
||||
});
|
||||
}
|
||||
|
||||
export async function analyze(dir: string | undefined, options: AnalyzeOptions) {
|
||||
const cwd = process.cwd();
|
||||
const targetDir = dir ? path.resolve(cwd, dir) : cwd;
|
||||
const metafilePath = path.join(targetDir, "metafile.json");
|
||||
const manifestPath = path.join(targetDir, "index.json");
|
||||
|
||||
if (!fs.existsSync(metafilePath)) {
|
||||
logger.error(`Could not find metafile.json in ${targetDir}`);
|
||||
logger.info("Make sure you have built your project and metafile.json exists.");
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
logger.error(`Could not find index.json (worker manifest) in ${targetDir}`);
|
||||
logger.info("Make sure you have built your project and index.json exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
const [metafileError, metafile] = await tryCatch(readMetafile(metafilePath));
|
||||
|
||||
if (metafileError) {
|
||||
logger.error(`Failed to parse metafile.json: ${metafileError.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const [manifestError, manifest] = await tryCatch(readManifest(manifestPath));
|
||||
|
||||
if (manifestError) {
|
||||
logger.error(`Failed to parse index.json: ${manifestError.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
printBundleTree(manifest, metafile, {
|
||||
preservePath: true,
|
||||
collapseBundles: !options.verbose,
|
||||
});
|
||||
|
||||
printBundleSummaryTable(manifest, metafile, {
|
||||
preservePath: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function readMetafile(metafilePath: string): Promise<Metafile> {
|
||||
const json = await readJSONFile(metafilePath);
|
||||
const metafile = MetafileSchema.parse(json);
|
||||
return metafile;
|
||||
}
|
||||
|
||||
async function readManifest(manifestPath: string): Promise<WorkerManifest> {
|
||||
const json = await readJSONFile(manifestPath);
|
||||
const manifest = WorkerManifest.parse(json);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
const ImportKind = z.enum([
|
||||
"entry-point",
|
||||
"import-statement",
|
||||
"require-call",
|
||||
"dynamic-import",
|
||||
"require-resolve",
|
||||
"import-rule",
|
||||
"composes-from",
|
||||
"url-token",
|
||||
]);
|
||||
|
||||
const ImportSchema = z.object({
|
||||
path: z.string(),
|
||||
kind: ImportKind,
|
||||
external: z.boolean().optional(),
|
||||
original: z.string().optional(),
|
||||
with: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
const InputSchema = z.object({
|
||||
bytes: z.number(),
|
||||
imports: z.array(ImportSchema),
|
||||
format: z.enum(["cjs", "esm"]).optional(),
|
||||
with: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
const OutputImportSchema = z.object({
|
||||
path: z.string(),
|
||||
kind: z.union([ImportKind, z.literal("file-loader")]),
|
||||
external: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const OutputInputSchema = z.object({
|
||||
bytesInOutput: z.number(),
|
||||
});
|
||||
|
||||
const OutputSchema = z.object({
|
||||
bytes: z.number(),
|
||||
inputs: z.record(z.string(), OutputInputSchema),
|
||||
imports: z.array(OutputImportSchema),
|
||||
exports: z.array(z.string()),
|
||||
entryPoint: z.string().optional(),
|
||||
cssBundle: z.string().optional(),
|
||||
});
|
||||
|
||||
const MetafileSchema = z.object({
|
||||
inputs: z.record(z.string(), InputSchema),
|
||||
outputs: z.record(z.string(), OutputSchema),
|
||||
});
|
||||
|
||||
type Metafile = z.infer<typeof MetafileSchema>;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,448 @@
|
||||
import { confirm, intro, isCancel, log } from "@clack/prompts";
|
||||
import { VERSION } from "@trigger.dev/core";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { getDevBranch } from "@trigger.dev/core/v3";
|
||||
import type { ResolvedConfig } from "@trigger.dev/core/v3/build";
|
||||
import type { Command } from "commander";
|
||||
import { Option as CommandOption } from "commander";
|
||||
import { resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { CliApiClient } from "../apiClient.js";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
commonOptions,
|
||||
handleTelemetry,
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { loadConfig, watchConfig } from "../config.js";
|
||||
import type { DevSessionInstance } from "../dev/devSession.js";
|
||||
import { startDevSession } from "../dev/devSession.js";
|
||||
import { createLockFile } from "../dev/lock.js";
|
||||
import { chalkError } from "../utilities/cliOutput.js";
|
||||
import {
|
||||
readConfigHasSeenMCPInstallPrompt,
|
||||
writeConfigHasSeenMCPInstallPrompt,
|
||||
} from "../utilities/configFiles.js";
|
||||
import { printDevBanner, printStandloneInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { resolveLocalEnvVars } from "../utilities/localEnvVars.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import {
|
||||
awaitAndDisplayPlatformNotification,
|
||||
fetchPlatformNotification,
|
||||
} from "../utilities/platformNotifications.js";
|
||||
import { runtimeChecks } from "../utilities/runtimeCheck.js";
|
||||
import type { LoginResultOk } from "../utilities/session.js";
|
||||
import { getProjectClient } from "../utilities/session.js";
|
||||
import { spinner } from "../utilities/windows.js";
|
||||
import { verifyDirectory } from "./deploy.js";
|
||||
import { installMcpServer } from "./install-mcp.js";
|
||||
import { login } from "./login.js";
|
||||
import { initiateSkillsInstallWizard } from "./skills.js";
|
||||
import { updateTriggerPackages } from "./update.js";
|
||||
|
||||
const DevArchiveCommandOptions = CommonCommandOptions.extend({
|
||||
branch: z.string().optional(),
|
||||
config: z.string().optional(),
|
||||
projectRef: z.string().optional(),
|
||||
skipUpdateCheck: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type DevArchiveCommandOptions = z.infer<typeof DevArchiveCommandOptions>;
|
||||
|
||||
const DevCommandOptions = CommonCommandOptions.extend({
|
||||
debugOtel: z.boolean().default(false),
|
||||
config: z.string().optional(),
|
||||
projectRef: z.string().optional(),
|
||||
branch: z.string().optional(),
|
||||
skipUpdateCheck: z.boolean().default(false),
|
||||
skipPlatformNotifications: z.boolean().default(false),
|
||||
envFile: z.string().optional(),
|
||||
keepTmpFiles: z.boolean().default(false),
|
||||
maxConcurrentRuns: z.coerce.number().optional(),
|
||||
mcp: z.boolean().default(false),
|
||||
mcpPort: z.coerce.number().optional().default(3333),
|
||||
analyze: z.boolean().default(false),
|
||||
disableWarnings: z.boolean().default(false),
|
||||
skipMCPInstall: z.boolean().default(false),
|
||||
skipRulesInstall: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type DevCommandOptions = z.infer<typeof DevCommandOptions>;
|
||||
|
||||
export function configureDevCommand(program: Command) {
|
||||
// `dev` is the root command that defaults to the `start` subcommand,
|
||||
// maintains existing behaviour for `trigger dev` but `trigger dev --help` a bit different
|
||||
const devBase = program.command("dev").description("Run your Trigger.dev tasks locally");
|
||||
|
||||
commonOptions(
|
||||
devBase
|
||||
.command("start", { isDefault: true })
|
||||
.description("Run your Trigger.dev tasks locally")
|
||||
.option("-c, --config <config file>", "The name of the config file")
|
||||
.option(
|
||||
"-p, --project-ref <project ref>",
|
||||
"The project ref. Required if there is no config file."
|
||||
)
|
||||
.option(
|
||||
"-b, --branch <branch>",
|
||||
"The dev branch to use. If not provided, we'll use the default branch."
|
||||
)
|
||||
.option(
|
||||
"--env-file <env file>",
|
||||
"Path to the .env file to use for the dev session. Defaults to .env in the project directory."
|
||||
)
|
||||
.option(
|
||||
"--max-concurrent-runs <max concurrent runs>",
|
||||
"The maximum number of concurrent runs to allow in the dev session"
|
||||
)
|
||||
.option("--debug-otel", "Enable OpenTelemetry debugging")
|
||||
.option("--skip-update-check", "Skip checking for @trigger.dev package updates")
|
||||
.option(
|
||||
"--keep-tmp-files",
|
||||
"Keep temporary files after the dev session ends, helpful for debugging"
|
||||
)
|
||||
.option("--mcp", "Start the MCP server")
|
||||
.option("--mcp-port", "The port to run the MCP server on", "3333")
|
||||
.addOption(
|
||||
new CommandOption("--analyze", "Analyze the build output and import timings").hideHelp()
|
||||
)
|
||||
.addOption(
|
||||
new CommandOption(
|
||||
"--skip-mcp-install",
|
||||
"Skip the Trigger.dev MCP server install wizard"
|
||||
).hideHelp()
|
||||
)
|
||||
.addOption(
|
||||
new CommandOption(
|
||||
"--skip-rules-install",
|
||||
"Skip the Trigger.dev agent skills install wizard"
|
||||
).hideHelp()
|
||||
)
|
||||
.addOption(new CommandOption("--disable-warnings", "Suppress warnings output").hideHelp())
|
||||
.addOption(
|
||||
new CommandOption(
|
||||
"--skip-platform-notifications",
|
||||
"Skip showing platform notifications"
|
||||
).hideHelp()
|
||||
)
|
||||
).action(async (options) => {
|
||||
wrapCommandAction("dev", DevCommandOptions, options, async (opts) => {
|
||||
await devCommand(opts);
|
||||
});
|
||||
});
|
||||
|
||||
commonOptions(
|
||||
devBase
|
||||
.command("archive")
|
||||
.description("Archive a dev branch")
|
||||
.argument("[path]", "The path to the project", ".")
|
||||
.option(
|
||||
"-b, --branch <branch>",
|
||||
"The dev branch to archive. Defaults to the TRIGGER_DEV_BRANCH environment variable if set."
|
||||
)
|
||||
.option("--skip-update-check", "Skip checking for @trigger.dev package updates")
|
||||
.option("-c, --config <config file>", "The name of the config file, found at [path]")
|
||||
.option(
|
||||
"-p, --project-ref <project ref>",
|
||||
"The project ref. Required if there is no config file. This will override the project specified in the config file."
|
||||
)
|
||||
.option(
|
||||
"--env-file <env file>",
|
||||
"Path to the .env file to load into the CLI process. Defaults to .env in the project directory."
|
||||
)
|
||||
).action(async (path, options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await printStandloneInitialBanner(true, options.profile);
|
||||
await devArchiveCommand(path, options);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function devCommand(options: DevCommandOptions) {
|
||||
runtimeChecks();
|
||||
|
||||
// Only show these install prompts if the user is in a terminal (not in a Coding Agent)
|
||||
if (process.stdout.isTTY) {
|
||||
const skipMCPInstall = typeof options.skipMCPInstall === "boolean" && options.skipMCPInstall;
|
||||
|
||||
if (!skipMCPInstall) {
|
||||
const hasSeenMCPInstallPrompt = readConfigHasSeenMCPInstallPrompt();
|
||||
|
||||
if (!hasSeenMCPInstallPrompt) {
|
||||
const installChoice = await confirm({
|
||||
message: "Would you like to install the Trigger.dev MCP server?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
writeConfigHasSeenMCPInstallPrompt(true);
|
||||
|
||||
const skipInstall = isCancel(installChoice) || !installChoice;
|
||||
|
||||
if (!skipInstall) {
|
||||
log.step("Welcome to the Trigger.dev MCP server install wizard 🧙");
|
||||
|
||||
const [installError] = await tryCatch(
|
||||
installMcpServer({
|
||||
yolo: false,
|
||||
tag: VERSION as string,
|
||||
logLevel: options.logLevel,
|
||||
})
|
||||
);
|
||||
|
||||
if (installError) {
|
||||
log.error(`Failed to install MCP server: ${installError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const skipRulesInstall =
|
||||
typeof options.skipRulesInstall === "boolean" && options.skipRulesInstall;
|
||||
|
||||
if (!skipRulesInstall) {
|
||||
await tryCatch(initiateSkillsInstallWizard({}));
|
||||
}
|
||||
}
|
||||
|
||||
const authorization = await login({
|
||||
embedded: true,
|
||||
silent: true,
|
||||
defaultApiUrl: options.apiUrl,
|
||||
profile: options.profile,
|
||||
});
|
||||
|
||||
if (!authorization.ok) {
|
||||
if (authorization.error === "fetch failed") {
|
||||
logger.log(
|
||||
`${chalkError(
|
||||
"X Error:"
|
||||
)} Connecting to the server failed. Please check your internet connection or contact eric@trigger.dev for help.`
|
||||
);
|
||||
} else {
|
||||
logger.log(
|
||||
`${chalkError("X Error:")} You must login first. Use the \`login\` CLI command.\n\n${
|
||||
authorization.error
|
||||
}`
|
||||
);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let watcher;
|
||||
try {
|
||||
const devInstance = await startDev({ ...options, cwd: process.cwd(), login: authorization });
|
||||
watcher = devInstance.watcher;
|
||||
await devInstance.waitUntilExit();
|
||||
} finally {
|
||||
await watcher?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
type StartDevOptions = DevCommandOptions & {
|
||||
login: LoginResultOk;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
async function startDev(options: StartDevOptions) {
|
||||
logger.debug("Starting dev CLI", { options });
|
||||
|
||||
let watcher: Awaited<ReturnType<typeof watchConfig>> | undefined;
|
||||
let removeLockFile: (() => void) | undefined;
|
||||
|
||||
try {
|
||||
if (options.logLevel) {
|
||||
logger.loggerLevel = options.logLevel;
|
||||
}
|
||||
|
||||
const apiClient = new CliApiClient(options.login.auth.apiUrl, options.login.auth.accessToken);
|
||||
|
||||
const notificationPromise = options.skipPlatformNotifications
|
||||
? undefined
|
||||
: fetchPlatformNotification({
|
||||
apiClient,
|
||||
projectRef: options.projectRef,
|
||||
});
|
||||
|
||||
await printStandloneInitialBanner(true, options.profile);
|
||||
|
||||
await awaitAndDisplayPlatformNotification(notificationPromise);
|
||||
|
||||
let displayedUpdateMessage = false;
|
||||
|
||||
if (!options.skipUpdateCheck) {
|
||||
displayedUpdateMessage = await updateTriggerPackages(options.cwd, { ...options }, true, true);
|
||||
}
|
||||
|
||||
const envVars = resolveLocalEnvVars(options.envFile);
|
||||
const branch = getDevBranch({ specified: options.branch ?? envVars.TRIGGER_DEV_BRANCH });
|
||||
|
||||
removeLockFile = await createLockFile(options.cwd, branch);
|
||||
|
||||
let devInstance: DevSessionInstance | undefined;
|
||||
|
||||
printDevBanner(displayedUpdateMessage);
|
||||
|
||||
if (envVars.TRIGGER_PROJECT_REF) {
|
||||
logger.debug("Using project ref from env", { ref: envVars.TRIGGER_PROJECT_REF });
|
||||
}
|
||||
|
||||
watcher = await watchConfig({
|
||||
cwd: options.cwd,
|
||||
async onUpdate(config) {
|
||||
logger.debug("Updated config, rerendering", { config });
|
||||
|
||||
if (devInstance) {
|
||||
devInstance.stop();
|
||||
}
|
||||
|
||||
devInstance = await bootDevSession(config);
|
||||
},
|
||||
overrides: {
|
||||
project: options.projectRef ?? envVars.TRIGGER_PROJECT_REF,
|
||||
},
|
||||
configFile: options.config,
|
||||
});
|
||||
|
||||
logger.debug("Initial config", watcher.config);
|
||||
|
||||
if (branch) {
|
||||
const upsertResult = await apiClient.upsertBranch(watcher.config.project, {
|
||||
branch,
|
||||
env: "development",
|
||||
});
|
||||
|
||||
if (!upsertResult.success) {
|
||||
logger.error(`Failed to use branch "${branch}": ${upsertResult.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-inner-declarations
|
||||
async function bootDevSession(configParam: ResolvedConfig) {
|
||||
const projectClient = await getProjectClient({
|
||||
accessToken: options.login.auth.accessToken,
|
||||
apiUrl: options.login.auth.apiUrl,
|
||||
projectRef: configParam.project,
|
||||
env: "dev",
|
||||
branch,
|
||||
profile: options.profile,
|
||||
});
|
||||
|
||||
if (!projectClient) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return startDevSession({
|
||||
name: projectClient.name,
|
||||
branch,
|
||||
rawArgs: options,
|
||||
rawConfig: configParam,
|
||||
client: projectClient.client,
|
||||
initialMode: "local",
|
||||
dashboardUrl: options.login.dashboardUrl,
|
||||
showInteractiveDevSession: true,
|
||||
keepTmpFiles: options.keepTmpFiles,
|
||||
});
|
||||
}
|
||||
|
||||
devInstance = await bootDevSession(watcher.config);
|
||||
|
||||
const waitUntilExit = async () => {};
|
||||
|
||||
return {
|
||||
watcher,
|
||||
stop: async () => {
|
||||
devInstance?.stop();
|
||||
await watcher?.stop();
|
||||
removeLockFile?.();
|
||||
},
|
||||
waitUntilExit,
|
||||
};
|
||||
} catch (error) {
|
||||
removeLockFile?.();
|
||||
await watcher?.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function devArchiveCommand(dir: string, options: unknown) {
|
||||
return await wrapCommandAction(
|
||||
"devArchiveCommand",
|
||||
DevArchiveCommandOptions,
|
||||
options,
|
||||
async (opts) => {
|
||||
return await archiveDevBranchCommand(dir, opts);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function archiveDevBranchCommand(dir: string, options: DevArchiveCommandOptions) {
|
||||
intro(`Archiving dev branch`);
|
||||
|
||||
if (!options.skipUpdateCheck) {
|
||||
await updateTriggerPackages(dir, { ...options }, true, true);
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const projectPath = resolve(cwd, dir);
|
||||
|
||||
verifyDirectory(dir, projectPath);
|
||||
|
||||
const authorization = await login({
|
||||
embedded: true,
|
||||
defaultApiUrl: options.apiUrl,
|
||||
profile: options.profile,
|
||||
});
|
||||
|
||||
if (!authorization.ok) {
|
||||
if (authorization.error === "fetch failed") {
|
||||
throw new Error(
|
||||
`Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`You must login first. Use the \`login\` CLI command.\n\n${authorization.error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedConfig = await loadConfig({
|
||||
cwd: projectPath,
|
||||
overrides: { project: options.projectRef },
|
||||
configFile: options.config,
|
||||
});
|
||||
|
||||
logger.debug("Resolved config", resolvedConfig);
|
||||
|
||||
const branch = getDevBranch({ specified: options.branch });
|
||||
|
||||
// getDevBranch returns undefined for the default branch (the root dev env),
|
||||
// which can't be archived. Require the user to name a real branch instead.
|
||||
if (!branch) {
|
||||
throw new Error(
|
||||
"You need to specify which dev branch to archive (the default branch can't be archived). Use --branch <branch>."
|
||||
);
|
||||
}
|
||||
|
||||
const $buildSpinner = spinner();
|
||||
$buildSpinner.start(`Archiving "${branch}"`);
|
||||
const result = await archiveDevBranch(authorization, branch, resolvedConfig.project);
|
||||
$buildSpinner.stop(
|
||||
result ? `Successfully archived "${branch}"` : `Failed to archive "${branch}".`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function archiveDevBranch(authorization: LoginResultOk, branch: string, project: string) {
|
||||
const apiClient = new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken);
|
||||
|
||||
const result = await apiClient.archiveBranch(project, "development", branch);
|
||||
|
||||
if (result.success) {
|
||||
return true;
|
||||
} else {
|
||||
logger.error(result.error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { confirm, intro, isCancel, log, outro } from "@clack/prompts";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import chalk from "chalk";
|
||||
import Table from "cli-table3";
|
||||
import type { Command } from "commander";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
commonOptions,
|
||||
handleTelemetry,
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { printInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { getProjectClient } from "../utilities/session.js";
|
||||
import { spinner } from "../utilities/windows.js";
|
||||
import { login } from "./login.js";
|
||||
|
||||
const EnvListOptions = CommonCommandOptions.extend({
|
||||
config: z.string().optional(),
|
||||
projectRef: z.string().optional(),
|
||||
showValues: z.boolean().default(false),
|
||||
env: z.enum(["prod", "staging", "preview", "production"]).default("prod"),
|
||||
branch: z.string().optional(),
|
||||
});
|
||||
|
||||
const EnvGetOptions = CommonCommandOptions.extend({
|
||||
config: z.string().optional(),
|
||||
projectRef: z.string().optional(),
|
||||
name: z.string(),
|
||||
raw: z.boolean().default(false),
|
||||
env: z.enum(["prod", "staging", "preview", "production"]).default("prod"),
|
||||
branch: z.string().optional(),
|
||||
});
|
||||
|
||||
const EnvPullOptions = CommonCommandOptions.extend({
|
||||
config: z.string().optional(),
|
||||
projectRef: z.string().optional(),
|
||||
output: z.string().default(".env.local"),
|
||||
force: z.boolean().default(false),
|
||||
env: z.enum(["prod", "staging", "preview", "production"]).default("prod"),
|
||||
branch: z.string().optional(),
|
||||
});
|
||||
|
||||
export function configureEnvCommand(program: Command) {
|
||||
const envCommand = program
|
||||
.command("env")
|
||||
.description("Manage environment variables for your Trigger.dev project");
|
||||
|
||||
commonOptions(
|
||||
envCommand
|
||||
.command("list")
|
||||
.description("List all environment variables for your project")
|
||||
.option("-c, --config <config file>", "The name of the config file")
|
||||
.option(
|
||||
"-p, --project-ref <project ref>",
|
||||
"The project ref. Required if there is no config file."
|
||||
)
|
||||
.option(
|
||||
"-e, --env <env>",
|
||||
"The environment to list variables from (prod, staging, preview)",
|
||||
"prod"
|
||||
)
|
||||
.option("-b, --branch <branch>", "The preview branch when using --env preview")
|
||||
.option(
|
||||
"--show-values",
|
||||
"Show the actual values of environment variables, including secret values"
|
||||
)
|
||||
).action(async (options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await printInitialBanner(false, options.profile);
|
||||
await envListCommand(options);
|
||||
});
|
||||
});
|
||||
|
||||
commonOptions(
|
||||
envCommand
|
||||
.command("get <name>")
|
||||
.description("Get the value of a specific environment variable")
|
||||
.option("-c, --config <config file>", "The name of the config file")
|
||||
.option(
|
||||
"-p, --project-ref <project ref>",
|
||||
"The project ref. Required if there is no config file"
|
||||
)
|
||||
.option(
|
||||
"-e, --env <env>",
|
||||
"The environment to get the variable from (prod, staging, preview)",
|
||||
"prod"
|
||||
)
|
||||
.option("-b, --branch <branch>", "The preview branch when using --env preview")
|
||||
.option("--raw", "Only output the raw value without any formatting or additional information")
|
||||
).action(async (name, options) => {
|
||||
await handleTelemetry(async () => {
|
||||
if (!options.raw) {
|
||||
await printInitialBanner(false, options.profile);
|
||||
}
|
||||
await envGetCommand({ ...options, name });
|
||||
});
|
||||
});
|
||||
|
||||
commonOptions(
|
||||
envCommand
|
||||
.command("pull")
|
||||
.description("Pull environment variables from your project to a local file")
|
||||
.option("-c, --config <config file>", "The name of the config file")
|
||||
.option(
|
||||
"-p, --project-ref <project ref>",
|
||||
"The project ref. Required if there is no config file"
|
||||
)
|
||||
.option(
|
||||
"-e, --env <env>",
|
||||
"The environment to pull variables from (prod, staging, preview)",
|
||||
"prod"
|
||||
)
|
||||
.option("-b, --branch <branch>", "The preview branch when using --env preview")
|
||||
.option("-o, --output <file>", "Output file path", ".env.local")
|
||||
.option("--force", "Overwrite the output file if it exists")
|
||||
).action(async (options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await printInitialBanner(false, options.profile);
|
||||
await envPullCommand(options);
|
||||
});
|
||||
});
|
||||
|
||||
return envCommand;
|
||||
}
|
||||
|
||||
async function envListCommand(options: unknown) {
|
||||
return await wrapCommandAction(
|
||||
"envList",
|
||||
EnvListOptions,
|
||||
options,
|
||||
async (opts: z.infer<typeof EnvListOptions>) => {
|
||||
return await _envListCommand(opts);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function envGetCommand(options: unknown) {
|
||||
return await wrapCommandAction(
|
||||
"envGet",
|
||||
EnvGetOptions,
|
||||
options,
|
||||
async (opts: z.infer<typeof EnvGetOptions>) => {
|
||||
return await _envGetCommand(opts);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function envPullCommand(options: unknown) {
|
||||
return await wrapCommandAction(
|
||||
"envPull",
|
||||
EnvPullOptions,
|
||||
options,
|
||||
async (opts: z.infer<typeof EnvPullOptions>) => {
|
||||
return await _envPullCommand(opts);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveProjectEnv(
|
||||
options:
|
||||
| z.infer<typeof EnvListOptions>
|
||||
| z.infer<typeof EnvGetOptions>
|
||||
| z.infer<typeof EnvPullOptions>
|
||||
) {
|
||||
const authorization = await login({
|
||||
embedded: true,
|
||||
defaultApiUrl: options.apiUrl,
|
||||
profile: options.profile,
|
||||
silent: "raw" in options ? options.raw : false,
|
||||
});
|
||||
|
||||
if (!authorization.ok) {
|
||||
if (authorization.error === "fetch failed") {
|
||||
throw new Error(
|
||||
`Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`You must login first. Use the \`login\` CLI command.`);
|
||||
}
|
||||
|
||||
const resolvedConfig = await loadConfig({
|
||||
overrides: { project: options.projectRef },
|
||||
configFile: options.config,
|
||||
});
|
||||
|
||||
logger.debug("Resolved config", resolvedConfig);
|
||||
|
||||
// Coerce production to prod
|
||||
const env = options.env === "production" ? "prod" : options.env;
|
||||
|
||||
if (env === "preview" && !options.branch) {
|
||||
throw new Error("Missing branch for the preview environment.");
|
||||
}
|
||||
|
||||
const projectClient = await getProjectClient({
|
||||
accessToken: authorization.auth.accessToken,
|
||||
apiUrl: authorization.auth.apiUrl,
|
||||
projectRef: resolvedConfig.project,
|
||||
env,
|
||||
branch: options.branch,
|
||||
profile: options.profile,
|
||||
});
|
||||
|
||||
if (!projectClient) {
|
||||
throw new Error("Failed to get project client");
|
||||
}
|
||||
|
||||
return {
|
||||
projectClient,
|
||||
projectRef: resolvedConfig.project,
|
||||
env,
|
||||
branch: options.branch,
|
||||
};
|
||||
}
|
||||
|
||||
async function _envListCommand(options: z.infer<typeof EnvListOptions>) {
|
||||
intro("Environment Variables");
|
||||
|
||||
const $spinner = spinner();
|
||||
|
||||
const { projectClient, projectRef, env, branch } = await resolveProjectEnv(options);
|
||||
|
||||
$spinner.start("Loading environment variables from project");
|
||||
const envVars = await projectClient.client.getEnvironmentVariables(projectRef);
|
||||
|
||||
if (!envVars.success) {
|
||||
$spinner.stop("Failed loading environment variables");
|
||||
throw envVars.error;
|
||||
}
|
||||
|
||||
$spinner.stop("Environment variables loaded");
|
||||
|
||||
const variables = envVars.data.variables;
|
||||
|
||||
// Filter out TRIGGER_ system variables to only show user-set variables.
|
||||
// The current envvars endpoint doesn't support filtering, so we just do basic filtering on the client side.
|
||||
// We'll soon add a v2 of this endpoint which supports filtering and also includes more info about the variables.
|
||||
const userVariables = Object.entries(variables).filter(([key]) => !key.startsWith("TRIGGER_"));
|
||||
|
||||
if (userVariables.length === 0) {
|
||||
log.info("No environment variables found");
|
||||
const envInfo = branch ? `${env} (${branch})` : env;
|
||||
outro(`Project: ${projectRef} | Environment: ${envInfo}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new Table({
|
||||
head: ["Variable", options.showValues ? "Value" : "Value (hidden)"],
|
||||
style: {
|
||||
head: ["yellow"],
|
||||
},
|
||||
chars: {
|
||||
top: "",
|
||||
"top-mid": "",
|
||||
"top-left": "",
|
||||
"top-right": "",
|
||||
bottom: "",
|
||||
"bottom-mid": "",
|
||||
"bottom-left": "",
|
||||
"bottom-right": "",
|
||||
left: "",
|
||||
"left-mid": "",
|
||||
mid: "",
|
||||
"mid-mid": "",
|
||||
right: "",
|
||||
"right-mid": "",
|
||||
middle: " ",
|
||||
},
|
||||
});
|
||||
|
||||
for (const [key, value] of userVariables) {
|
||||
table.push([key, options.showValues ? value : "******"]);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(table.toString());
|
||||
console.log();
|
||||
|
||||
if (!options.showValues) {
|
||||
log.info(chalk.dim("Use --show-values to display the actual values"));
|
||||
}
|
||||
|
||||
const envInfo = branch ? `${env} (${branch})` : env;
|
||||
outro(
|
||||
`Found ${userVariables.length} environment variable${
|
||||
userVariables.length === 1 ? "" : "s"
|
||||
} | Project: ${projectRef} | Environment: ${envInfo}`
|
||||
);
|
||||
}
|
||||
|
||||
async function _envGetCommand(options: z.infer<typeof EnvGetOptions>) {
|
||||
const $spinner = options.raw ? null : spinner();
|
||||
|
||||
if (!options.raw) {
|
||||
intro(`Getting environment variable: ${options.name}`);
|
||||
}
|
||||
|
||||
const { projectClient, projectRef, env, branch } = await resolveProjectEnv(options);
|
||||
|
||||
$spinner?.start("Loading environment variables from project");
|
||||
const envVars = await projectClient.client.getEnvironmentVariables(projectRef);
|
||||
|
||||
if (!envVars.success) {
|
||||
$spinner?.stop("Failed loading environment variables");
|
||||
throw new Error(`Failed to load environment variables: ${envVars.error}`);
|
||||
}
|
||||
|
||||
$spinner?.stop("Environment variables loaded");
|
||||
|
||||
const variables = envVars.data.variables;
|
||||
|
||||
const value = variables[options.name];
|
||||
|
||||
if (value === undefined) {
|
||||
if (options.raw) {
|
||||
throw new Error(`Environment variable "${options.name}" not found`);
|
||||
}
|
||||
|
||||
log.error(chalk.red(`Environment variable '${options.name}' not found`));
|
||||
|
||||
// Suggest similar variables if any exist
|
||||
const keys = Object.keys(variables);
|
||||
const similar = keys.filter(
|
||||
(k: string) =>
|
||||
k.toLowerCase().includes(options.name.toLowerCase()) ||
|
||||
options.name.toLowerCase().includes(k.toLowerCase())
|
||||
);
|
||||
|
||||
if (similar.length > 0) {
|
||||
log.info(chalk.dim("Did you mean one of these?"));
|
||||
similar.forEach((s: string) => log.info(chalk.dim(` - ${s}`)));
|
||||
}
|
||||
|
||||
const envInfo = branch ? `${env} (${branch})` : env;
|
||||
outro(`Project: ${projectRef} | Environment: ${envInfo}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.raw) {
|
||||
console.log(value || "");
|
||||
return;
|
||||
}
|
||||
|
||||
log.success(chalk.green(`${options.name}=${value}`));
|
||||
|
||||
const envInfo = branch ? `${env} (${branch})` : env;
|
||||
outro(`Project: ${projectRef} | Environment: ${envInfo}`);
|
||||
}
|
||||
|
||||
async function _envPullCommand(options: z.infer<typeof EnvPullOptions>) {
|
||||
intro("Pull Environment Variables");
|
||||
const $spinner = spinner();
|
||||
|
||||
const { projectClient, projectRef, env, branch } = await resolveProjectEnv(options);
|
||||
|
||||
$spinner.start("Loading environment variables from project");
|
||||
|
||||
const envVars = await projectClient.client.getEnvironmentVariables(projectRef);
|
||||
|
||||
if (!envVars.success) {
|
||||
$spinner.stop("Failed loading environment variables");
|
||||
throw envVars.error;
|
||||
}
|
||||
|
||||
$spinner.stop("Environment variables loaded");
|
||||
|
||||
const variables = envVars.data.variables;
|
||||
// Filter out TRIGGER_ system variables to only show user-set variables.
|
||||
// The current envvars endpoint doesn't support filtering, so we just do basic filtering on the client side.
|
||||
// We'll soon add a v2 of this endpoint which supports filtering and also includes more info about the variables.
|
||||
const userVariables = Object.entries(variables).filter(([key]) => !key.startsWith("TRIGGER_"));
|
||||
|
||||
if (userVariables.length === 0) {
|
||||
log.info("No environment variables found");
|
||||
const envInfo = branch ? `${env} (${branch})` : env;
|
||||
outro(`Project: ${projectRef} | Environment: ${envInfo}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const outputPath = resolve(process.cwd(), options.output);
|
||||
|
||||
const [error] = await tryCatch(writeFile(outputPath, "", { flag: "wx", mode: 0o600 }));
|
||||
|
||||
if (error && "code" in error && error.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error && "code" in error && error.code === "EEXIST" && !options.force) {
|
||||
const shouldOverwrite = await confirm({
|
||||
message: `File ${options.output} already exists. Overwrite?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (isCancel(shouldOverwrite) || !shouldOverwrite) {
|
||||
outro("Cancelled");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envContent = userVariables
|
||||
.map(([key, value]) => `${key}=${serializeDotenvValue(value)}`)
|
||||
.join("\n");
|
||||
|
||||
$spinner.start(`Writing to ${options.output}`);
|
||||
const [writeError] = await tryCatch(
|
||||
writeFile(outputPath, envContent + "\n", { encoding: "utf-8", mode: 0o600 })
|
||||
);
|
||||
|
||||
if (writeError) {
|
||||
$spinner.stop(`Failed to write to ${options.output}`);
|
||||
throw writeError;
|
||||
}
|
||||
|
||||
$spinner.stop(`Written to ${options.output}`);
|
||||
|
||||
log.success(
|
||||
chalk.green(
|
||||
`Pulled ${userVariables.length} environment variable${
|
||||
userVariables.length === 1 ? "" : "s"
|
||||
} into ${options.output}`
|
||||
)
|
||||
);
|
||||
|
||||
const envInfo = branch ? `${env} (${branch})` : env;
|
||||
outro(`Project: ${projectRef} | Environment: ${envInfo}`);
|
||||
}
|
||||
|
||||
const serializeDotenvValue = (v: unknown): string => {
|
||||
if (v == null || v === undefined) return "";
|
||||
|
||||
const s = String(v);
|
||||
// Quote when unsafe chars present: whitespace, equals, newlines, comments, quotes, backslashes
|
||||
const needsQuotes = /[\s#"'`\\=\n\r]/.test(s) || s === "";
|
||||
return needsQuotes ? JSON.stringify(s) : s;
|
||||
};
|
||||
@@ -0,0 +1,825 @@
|
||||
import { intro, isCancel, log, multiselect, outro, select, text } from "@clack/prompts";
|
||||
import { context, trace } from "@opentelemetry/api";
|
||||
import type { GetProjectResponseBody, LogLevel } from "@trigger.dev/core/v3";
|
||||
import { flattenAttributes, tryCatch } from "@trigger.dev/core/v3";
|
||||
import { recordSpanException } from "@trigger.dev/core/v3/workers";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { Option as CommandOption } from "commander";
|
||||
import { applyEdits, findNodeAtLocation, getNodeValue, modify, parseTree } from "jsonc-parser";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { addDependency, addDevDependency } from "nypm";
|
||||
import { resolveTSConfig } from "pkg-types";
|
||||
import { z } from "zod";
|
||||
import { CliApiClient } from "../apiClient.js";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
OutroCommandError,
|
||||
SkipCommandError,
|
||||
SkipLoggingError,
|
||||
commonOptions,
|
||||
handleTelemetry,
|
||||
tracer,
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { cliLink } from "../utilities/cliOutput.js";
|
||||
import {
|
||||
createFileFromTemplate,
|
||||
generateTemplateUrl,
|
||||
} from "../utilities/createFileFromTemplate.js";
|
||||
import { createFile, pathExists, readFile } from "../utilities/fileSystem.js";
|
||||
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { spinner } from "../utilities/windows.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { login } from "./login.js";
|
||||
import {
|
||||
readConfigHasSeenMCPInstallPrompt,
|
||||
writeConfigHasSeenMCPInstallPrompt,
|
||||
} from "../utilities/configFiles.js";
|
||||
import { installMcpServer } from "./install-mcp.js";
|
||||
import { installSkillsFromInit, markSkillsPromptSeen } from "./skills.js";
|
||||
|
||||
const cliVersion = VERSION as string;
|
||||
const cliTag = cliVersion.includes("v4-beta") ? "v4-beta" : "latest";
|
||||
|
||||
const InitCommandOptions = CommonCommandOptions.extend({
|
||||
projectRef: z.string().optional(),
|
||||
overrideConfig: z.boolean().default(false),
|
||||
tag: z.string().default(cliVersion),
|
||||
skipPackageInstall: z.boolean().default(false),
|
||||
runtime: z.string().default("node"),
|
||||
pkgArgs: z.string().optional(),
|
||||
gitRef: z.string().default("main"),
|
||||
javascript: z.boolean().default(false),
|
||||
yes: z.boolean().default(false),
|
||||
browser: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type InitCommandOptions = z.infer<typeof InitCommandOptions>;
|
||||
|
||||
export function configureInitCommand(program: Command) {
|
||||
return commonOptions(
|
||||
program
|
||||
.command("init")
|
||||
.summary("Initialize your existing project for development with Trigger.dev")
|
||||
.description(
|
||||
`Initialize your existing project for development with Trigger.dev.
|
||||
|
||||
Examples:
|
||||
# Interactive setup
|
||||
$ trigger.dev init
|
||||
|
||||
# Non-interactive (CI / scripts)
|
||||
$ trigger.dev init --yes --project-ref proj_abc123
|
||||
|
||||
# Headless / agent (no browser)
|
||||
$ trigger.dev init --yes --project-ref proj_abc123 --no-browser
|
||||
|
||||
# Use a named profile
|
||||
$ trigger.dev init --profile staging`
|
||||
)
|
||||
.argument("[path]", "The path to the project", ".")
|
||||
.option(
|
||||
"-p, --project-ref <project ref>",
|
||||
"The project ref to use when initializing the project"
|
||||
)
|
||||
.option("--javascript", "Initialize the project with JavaScript instead of TypeScript", false)
|
||||
.option(
|
||||
"-t, --tag <package tag>",
|
||||
"The version of the @trigger.dev/sdk package to install",
|
||||
cliVersion
|
||||
)
|
||||
.option(
|
||||
"-r, --runtime <runtime>",
|
||||
"Which runtime to use for the project. Currently only supports node and bun",
|
||||
"node"
|
||||
)
|
||||
.option("--skip-package-install", "Skip installing the @trigger.dev/sdk package")
|
||||
.option("--override-config", "Override the existing config file if it exists")
|
||||
.option(
|
||||
"--pkg-args <args>",
|
||||
"Additional arguments to pass to the package manager, accepts CSV for multiple args"
|
||||
)
|
||||
.option("-y, --yes", "Skip all prompts and use defaults (requires --project-ref)")
|
||||
.option(
|
||||
"--no-browser",
|
||||
"Don't automatically open the browser during login; print the URL only"
|
||||
)
|
||||
)
|
||||
.addOption(
|
||||
new CommandOption(
|
||||
"--git-ref <git ref>",
|
||||
"The git ref to use when fetching templates from GitHub"
|
||||
).hideHelp()
|
||||
)
|
||||
.action(async (path, options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await printStandloneInitialBanner(true, options.profile);
|
||||
await initCommand(path, options);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function initCommand(dir: string, options: unknown) {
|
||||
return await wrapCommandAction("initCommand", InitCommandOptions, options, async (opts) => {
|
||||
return await _initCommand(dir, opts);
|
||||
});
|
||||
}
|
||||
|
||||
async function _initCommand(dir: string, options: InitCommandOptions) {
|
||||
const span = trace.getSpan(context.active());
|
||||
|
||||
// Validate --yes flag requirements
|
||||
if (options.yes && !options.projectRef) {
|
||||
throw new Error("--project-ref is required when using --yes flag");
|
||||
}
|
||||
|
||||
// Refuse to run interactively when stdin isn't a TTY (CI, agent harness, etc).
|
||||
// Previously this silently default-and-exited at the first prompt, leaving the
|
||||
// project half-initialized.
|
||||
if (!options.yes && !process.stdin.isTTY) {
|
||||
throw new Error(
|
||||
"Interactive prompts cannot be used in non-TTY environments. Pass --yes (and --project-ref) to run non-interactively."
|
||||
);
|
||||
}
|
||||
|
||||
const hasSeenMCPInstallPrompt = readConfigHasSeenMCPInstallPrompt();
|
||||
|
||||
// Skip the AI-tooling prompt when --yes is set: the user explicitly chose the CLI
|
||||
// scaffold by running `trigger.dev init` non-interactively, and the prompt would
|
||||
// otherwise hang on a fresh machine where `hasSeenMCPInstallPrompt` is false.
|
||||
if (!hasSeenMCPInstallPrompt && !options.yes) {
|
||||
const tooling = await multiselect({
|
||||
message: "Set up AI tooling for your coding assistant? (optional, space to toggle)",
|
||||
options: [
|
||||
{
|
||||
value: "mcp",
|
||||
label: "MCP server",
|
||||
hint: "live access to your project: trigger tasks, deploy, monitor runs",
|
||||
},
|
||||
{
|
||||
value: "skills",
|
||||
label: "Agent skills",
|
||||
hint: "teach your AI to write Trigger.dev code, version-matched to your SDK",
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
});
|
||||
|
||||
writeConfigHasSeenMCPInstallPrompt(true);
|
||||
|
||||
const selectedTooling = isCancel(tooling) ? [] : tooling;
|
||||
|
||||
// Track what actually installed (not just what was selected), so the AI hand-off is
|
||||
// only offered, and only described, in terms of tooling that really landed.
|
||||
let installedSkills = false;
|
||||
let installedMcp = false;
|
||||
|
||||
// Skills are auth-free and bundled in the CLI. The user opted in here, so install
|
||||
// straight away (no extra confirm). If they declined, still mark the prompt seen so
|
||||
// `trigger dev` doesn't ask about skills a second time.
|
||||
if (selectedTooling.includes("skills")) {
|
||||
log.step("Installing the Trigger.dev agent skills");
|
||||
const [skillsError, installed] = await tryCatch(installSkillsFromInit());
|
||||
if (skillsError) {
|
||||
log.warn(`Skipped agent skills: ${skillsError.message}`);
|
||||
} else {
|
||||
installedSkills = installed === true;
|
||||
}
|
||||
} else {
|
||||
await tryCatch(markSkillsPromptSeen());
|
||||
}
|
||||
|
||||
// The MCP server is also auth-free.
|
||||
if (selectedTooling.includes("mcp")) {
|
||||
log.step("Welcome to the Trigger.dev MCP server install wizard 🧙");
|
||||
|
||||
const [installError] = await tryCatch(
|
||||
installMcpServer({
|
||||
yolo: false,
|
||||
tag: options.tag,
|
||||
logLevel: options.logLevel,
|
||||
})
|
||||
);
|
||||
|
||||
if (installError) {
|
||||
// Don't abort init if MCP fails: skills may already be installed and the user
|
||||
// still needs the project scaffolded. Warn and carry on.
|
||||
log.warn(`Skipped MCP server: ${installError.message}`);
|
||||
} else {
|
||||
installedMcp = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Vibe path: once AI tooling is actually installed, the user can hand scaffolding to
|
||||
// their assistant instead of the CLI. Only offered when something landed, and the
|
||||
// hand-off message names only the tooling that did.
|
||||
if (installedSkills || installedMcp) {
|
||||
const setupChoice = await select({
|
||||
message: "How do you want to set up your project?",
|
||||
options: [
|
||||
{
|
||||
value: "cli",
|
||||
label: "Scaffold it now with the CLI",
|
||||
hint: "log in, create trigger.config.ts and an example task",
|
||||
},
|
||||
{
|
||||
value: "ai",
|
||||
label: "Let my AI assistant set it up",
|
||||
hint: "hand off and let your assistant bootstrap the project",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!isCancel(setupChoice) && setupChoice === "ai") {
|
||||
outro(
|
||||
installedSkills && installedMcp
|
||||
? "Your AI tooling is ready. Ask your assistant to set up Trigger.dev; it can use the trigger-getting-started skill and the MCP server to add the SDK, config, and your first task."
|
||||
: installedSkills
|
||||
? "Your AI tooling is ready. Ask your assistant to set up Trigger.dev and it will use the trigger-getting-started skill to add the SDK, config, and your first task."
|
||||
: "The MCP server is installed. Ask your assistant to set up Trigger.dev using the MCP server."
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
intro("Initializing project");
|
||||
|
||||
const cwd = resolve(process.cwd(), dir);
|
||||
|
||||
const authorization = await login({
|
||||
embedded: true,
|
||||
defaultApiUrl: options.apiUrl,
|
||||
profile: options.profile,
|
||||
browser: options.browser,
|
||||
});
|
||||
|
||||
if (!authorization.ok) {
|
||||
if (authorization.error === "fetch failed") {
|
||||
throw new Error(
|
||||
`Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`
|
||||
);
|
||||
} else {
|
||||
throw new Error("You must login first. Use `trigger.dev login` to login.");
|
||||
}
|
||||
}
|
||||
|
||||
span?.setAttributes({
|
||||
"cli.userId": authorization.userId,
|
||||
"cli.email": authorization.email,
|
||||
"cli.config.apiUrl": authorization.auth.apiUrl,
|
||||
"cli.config.profile": authorization.profile,
|
||||
});
|
||||
|
||||
const tsconfigPath = await tryResolveTsConfig(cwd);
|
||||
|
||||
if (!options.overrideConfig) {
|
||||
try {
|
||||
// check to see if there is an existing trigger.dev config file in the project directory
|
||||
const result = await loadConfig({ cwd });
|
||||
|
||||
if (result.configFile && result.configFile !== "trigger.config") {
|
||||
outro(
|
||||
result.configFile
|
||||
? `Project already initialized: Found config file at ${result.configFile}. Pass --override-config to override`
|
||||
: "Project already initialized"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (_e) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
const apiClient = new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken);
|
||||
|
||||
const selectedProject = await selectProject(
|
||||
apiClient,
|
||||
authorization.dashboardUrl,
|
||||
options.projectRef
|
||||
);
|
||||
|
||||
span?.setAttributes({
|
||||
...flattenAttributes(selectedProject, "cli.project"),
|
||||
});
|
||||
|
||||
logger.debug("Selected project", selectedProject);
|
||||
|
||||
log.step(`Configuring project "${selectedProject.name}" (${selectedProject.externalRef})`);
|
||||
|
||||
// Install @trigger.dev/sdk package
|
||||
if (!options.skipPackageInstall) {
|
||||
await installPackages(
|
||||
cwd,
|
||||
options.tag,
|
||||
new CLIInstallPackagesOutputter(options.logLevel, options.tag)
|
||||
);
|
||||
} else {
|
||||
log.info("Skipping package installation");
|
||||
}
|
||||
|
||||
const language = options.javascript ? "javascript" : "typescript";
|
||||
|
||||
// Create the trigger dir
|
||||
const triggerDir = await createTriggerDir(dir, options, language);
|
||||
|
||||
// Create the config file
|
||||
await writeConfigFile(dir, selectedProject, options, triggerDir, language);
|
||||
|
||||
// Add trigger.config.ts to tsconfig.json
|
||||
if (tsconfigPath && language === "typescript") {
|
||||
await addConfigFileToTsConfig(tsconfigPath, options);
|
||||
}
|
||||
|
||||
// Ignore .trigger dir
|
||||
await gitIgnoreDotTriggerDir(dir, options);
|
||||
|
||||
const projectDashboard = cliLink(
|
||||
"project dashboard",
|
||||
`${authorization.dashboardUrl}/projects/v3/${selectedProject.externalRef}`
|
||||
);
|
||||
|
||||
log.success("Successfully initialized your Trigger.dev project 🫡");
|
||||
log.info("Next steps:");
|
||||
log.info(
|
||||
` 1. To start developing, run ${chalk.green(
|
||||
`npx trigger.dev@${cliTag} dev${options.profile ? ` --profile ${options.profile}` : ""}`
|
||||
)} in your project directory`
|
||||
);
|
||||
log.info(` 2. Visit your ${projectDashboard} to view your newly created tasks.`);
|
||||
log.info(
|
||||
` 3. Head over to our ${cliLink("v3 docs", "https://trigger.dev/docs")} to learn more.`
|
||||
);
|
||||
log.info(
|
||||
` 4. Need help? Join our ${cliLink(
|
||||
"Discord community",
|
||||
"https://trigger.dev/discord"
|
||||
)} or email us at ${chalk.cyan("help@trigger.dev")}`
|
||||
);
|
||||
|
||||
outro(`Project initialized successfully. Happy coding!`);
|
||||
}
|
||||
|
||||
async function createTriggerDir(
|
||||
dir: string,
|
||||
options: InitCommandOptions,
|
||||
language: "typescript" | "javascript"
|
||||
) {
|
||||
return await tracer.startActiveSpan("createTriggerDir", async (span) => {
|
||||
try {
|
||||
const defaultValue = join(dir, "src", "trigger");
|
||||
|
||||
let location: string;
|
||||
let example: string;
|
||||
|
||||
if (options.yes) {
|
||||
// Use defaults when --yes flag is set
|
||||
location = defaultValue;
|
||||
example = "simple";
|
||||
} else {
|
||||
const locationPrompt = await text({
|
||||
message: "Where would you like to create the Trigger.dev directory?",
|
||||
defaultValue: defaultValue,
|
||||
placeholder: defaultValue,
|
||||
});
|
||||
|
||||
if (isCancel(locationPrompt)) {
|
||||
throw new OutroCommandError();
|
||||
}
|
||||
|
||||
location = locationPrompt;
|
||||
|
||||
const exampleSelection = await select({
|
||||
message: `Choose an example to create in the ${location} directory`,
|
||||
options: [
|
||||
{ value: "simple", label: "Simple (Hello World)" },
|
||||
{ value: "schedule", label: "Scheduled Task" },
|
||||
{
|
||||
value: "none",
|
||||
label: "None",
|
||||
hint: "skip creating an example",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (isCancel(exampleSelection)) {
|
||||
throw new OutroCommandError();
|
||||
}
|
||||
|
||||
example = exampleSelection as string;
|
||||
}
|
||||
|
||||
// Ensure that the path is always relative by stripping leading '/' if present
|
||||
const relativeLocation = location.replace(/^\//, "");
|
||||
|
||||
const triggerDir = resolve(process.cwd(), relativeLocation);
|
||||
|
||||
logger.debug({ triggerDir });
|
||||
|
||||
span.setAttributes({
|
||||
"cli.triggerDir": triggerDir,
|
||||
});
|
||||
|
||||
if (await pathExists(triggerDir)) {
|
||||
throw new Error(`Directory already exists at ${triggerDir}`);
|
||||
}
|
||||
|
||||
span.setAttributes({
|
||||
"cli.example": example,
|
||||
});
|
||||
|
||||
if (example === "none") {
|
||||
// Create a .gitkeep file in the trigger dir
|
||||
await createFile(join(triggerDir, ".gitkeep"), "");
|
||||
|
||||
log.step(`Created directory at ${location}`);
|
||||
|
||||
span.end();
|
||||
return { location, isCustomValue: location !== defaultValue };
|
||||
}
|
||||
|
||||
const templateUrl = generateTemplateUrl(
|
||||
`examples/${example}.${language === "typescript" ? "ts" : "mjs"}`,
|
||||
options.gitRef
|
||||
);
|
||||
const outputPath = join(triggerDir, `example.${language === "typescript" ? "ts" : "mjs"}`);
|
||||
|
||||
await createFileFromTemplate({
|
||||
templateUrl,
|
||||
outputPath,
|
||||
replacements: {},
|
||||
});
|
||||
|
||||
const relativeOutputPath = relative(process.cwd(), outputPath);
|
||||
|
||||
log.step(`Created example file at ${relativeOutputPath}`);
|
||||
|
||||
span.end();
|
||||
|
||||
return { location, isCustomValue: location !== defaultValue };
|
||||
} catch (e) {
|
||||
if (!(e instanceof SkipCommandError)) {
|
||||
recordSpanException(span, e);
|
||||
}
|
||||
|
||||
span.end();
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function gitIgnoreDotTriggerDir(dir: string, options: InitCommandOptions) {
|
||||
return await tracer.startActiveSpan("gitIgnoreDotTriggerDir", async (span) => {
|
||||
try {
|
||||
const projectDir = resolve(process.cwd(), dir);
|
||||
const gitIgnorePath = join(projectDir, ".gitignore");
|
||||
|
||||
span.setAttributes({
|
||||
"cli.projectDir": projectDir,
|
||||
"cli.gitIgnorePath": gitIgnorePath,
|
||||
});
|
||||
|
||||
if (!(await pathExists(gitIgnorePath))) {
|
||||
// Create .gitignore file
|
||||
await createFile(gitIgnorePath, ".trigger");
|
||||
|
||||
log.step(`Added .trigger to .gitignore`);
|
||||
|
||||
span.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if .gitignore already contains .trigger
|
||||
const gitIgnoreContent = await readFile(gitIgnorePath);
|
||||
|
||||
if (gitIgnoreContent.includes(".trigger")) {
|
||||
span.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newGitIgnoreContent = `${gitIgnoreContent}\n.trigger`;
|
||||
|
||||
await writeFile(gitIgnorePath, newGitIgnoreContent, "utf-8");
|
||||
|
||||
log.step(`Added .trigger to .gitignore`);
|
||||
|
||||
span.end();
|
||||
} catch (e) {
|
||||
if (!(e instanceof SkipCommandError)) {
|
||||
recordSpanException(span, e);
|
||||
}
|
||||
|
||||
span.end();
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function addConfigFileToTsConfig(tsconfigPath: string, options: InitCommandOptions) {
|
||||
return await tracer.startActiveSpan("addConfigFileToTsConfig", async (span) => {
|
||||
try {
|
||||
span.setAttributes({
|
||||
"cli.tsconfigPath": tsconfigPath,
|
||||
});
|
||||
|
||||
const tsconfigContent = await readFile(tsconfigPath);
|
||||
const tsconfigContentTree = parseTree(tsconfigContent, undefined);
|
||||
if (!tsconfigContentTree) {
|
||||
span.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const tsconfigIncludeOption = findNodeAtLocation(tsconfigContentTree, ["include"]);
|
||||
if (!tsconfigIncludeOption) {
|
||||
span.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const tsConfigFileName = "trigger.config.ts";
|
||||
const tsconfigIncludeOptionValue: string[] = getNodeValue(tsconfigIncludeOption);
|
||||
if (tsconfigIncludeOptionValue.includes(tsConfigFileName)) {
|
||||
span.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const edits = modify(tsconfigContent, ["include", -1], tsConfigFileName, {
|
||||
isArrayInsertion: true,
|
||||
formattingOptions: {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
eol: "\n",
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug("tsconfig.json edits", { edits });
|
||||
|
||||
const newTsconfigContent = applyEdits(tsconfigContent, edits);
|
||||
|
||||
logger.debug("new tsconfig.json content", { newTsconfigContent });
|
||||
|
||||
await writeFile(tsconfigPath, newTsconfigContent, "utf-8");
|
||||
|
||||
log.step(`Added trigger.config.ts to tsconfig.json`);
|
||||
|
||||
span.end();
|
||||
} catch (e) {
|
||||
if (!(e instanceof SkipCommandError)) {
|
||||
recordSpanException(span, e);
|
||||
}
|
||||
|
||||
span.end();
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface InstallPackagesOutputter {
|
||||
startSDK: () => void;
|
||||
installedSDK: () => void;
|
||||
startBuild: () => void;
|
||||
installedBuild: () => void;
|
||||
stoppedWithError: () => void;
|
||||
}
|
||||
|
||||
class CLIInstallPackagesOutputter implements InstallPackagesOutputter {
|
||||
private installSpinner: ReturnType<typeof spinner>;
|
||||
|
||||
constructor(
|
||||
private readonly logLevel: LogLevel,
|
||||
private readonly tag: string
|
||||
) {
|
||||
this.installSpinner = spinner();
|
||||
}
|
||||
|
||||
startSDK() {
|
||||
this.installSpinner.start(`Adding @trigger.dev/sdk@${this.tag}`);
|
||||
}
|
||||
|
||||
installedSDK() {
|
||||
this.installSpinner.stop(`@trigger.dev/sdk@${this.tag} installed`);
|
||||
}
|
||||
|
||||
startBuild() {
|
||||
this.installSpinner.start(`Adding @trigger.dev/build@${this.tag} to devDependencies`);
|
||||
}
|
||||
|
||||
installedBuild() {
|
||||
this.installSpinner.stop(`@trigger.dev/build@${this.tag} installed`);
|
||||
}
|
||||
|
||||
stoppedWithError() {
|
||||
if (this.logLevel === "debug") {
|
||||
this.installSpinner.stop(`Failed to install @trigger.dev/sdk@${this.tag}.`);
|
||||
} else {
|
||||
this.installSpinner.stop(
|
||||
`Failed to install @trigger.dev/sdk@${this.tag}. Rerun command with --log-level debug for more details.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SilentInstallPackagesOutputter implements InstallPackagesOutputter {
|
||||
startSDK() {}
|
||||
installedSDK() {}
|
||||
startBuild() {}
|
||||
installedBuild() {}
|
||||
stoppedWithError() {}
|
||||
}
|
||||
|
||||
export async function installPackages(
|
||||
projectDir: string,
|
||||
tag: string,
|
||||
outputter: InstallPackagesOutputter = new SilentInstallPackagesOutputter()
|
||||
) {
|
||||
try {
|
||||
outputter.startSDK();
|
||||
|
||||
await addDependency(`@trigger.dev/sdk@${tag}`, { cwd: projectDir, silent: true });
|
||||
|
||||
outputter.installedSDK();
|
||||
|
||||
outputter.startBuild();
|
||||
|
||||
await addDevDependency(`@trigger.dev/build@${tag}`, {
|
||||
cwd: projectDir,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
outputter.installedBuild();
|
||||
} catch (e) {
|
||||
outputter.stoppedWithError();
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConfigFile(
|
||||
dir: string,
|
||||
project: GetProjectResponseBody,
|
||||
options: InitCommandOptions,
|
||||
triggerDir: { location: string; isCustomValue: boolean },
|
||||
language: "typescript" | "javascript"
|
||||
) {
|
||||
return await tracer.startActiveSpan("writeConfigFile", async (span) => {
|
||||
try {
|
||||
const spnnr = spinner();
|
||||
spnnr.start("Creating config file");
|
||||
|
||||
const projectDir = resolve(process.cwd(), dir);
|
||||
const outputPath = join(
|
||||
projectDir,
|
||||
`trigger.config.${language === "typescript" ? "ts" : "mjs"}`
|
||||
);
|
||||
const templateUrl = generateTemplateUrl(
|
||||
`trigger.config.${language === "typescript" ? "ts" : "mjs"}`,
|
||||
options.gitRef
|
||||
);
|
||||
|
||||
span.setAttributes({
|
||||
"cli.projectDir": projectDir,
|
||||
"cli.templatePath": templateUrl,
|
||||
"cli.outputPath": outputPath,
|
||||
"cli.runtime": options.runtime,
|
||||
});
|
||||
|
||||
const result = await createFileFromTemplate({
|
||||
templateUrl,
|
||||
replacements: {
|
||||
projectRef: project.externalRef,
|
||||
runtime: options.runtime,
|
||||
triggerDirectoriesOption: triggerDir.isCustomValue
|
||||
? `\n dirs: ["${triggerDir.location}"],`
|
||||
: `\n dirs: ["./src/trigger"],`,
|
||||
},
|
||||
outputPath,
|
||||
override: options.overrideConfig,
|
||||
});
|
||||
|
||||
const relativePathToOutput = relative(process.cwd(), outputPath);
|
||||
|
||||
spnnr.stop(
|
||||
result.success
|
||||
? `Config file created at ${relativePathToOutput}`
|
||||
: `Failed to create config file: ${result.error}`
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new SkipLoggingError(result.error);
|
||||
}
|
||||
|
||||
span.end();
|
||||
|
||||
return result.success;
|
||||
} catch (e) {
|
||||
if (!(e instanceof SkipCommandError)) {
|
||||
recordSpanException(span, e);
|
||||
}
|
||||
|
||||
span.end();
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function selectProject(apiClient: CliApiClient, dashboardUrl: string, projectRef?: string) {
|
||||
return await tracer.startActiveSpan("selectProject", async (span) => {
|
||||
try {
|
||||
if (projectRef) {
|
||||
const projectResponse = await apiClient.getProject(projectRef);
|
||||
|
||||
if (!projectResponse.success) {
|
||||
log.error(
|
||||
`--project-ref ${projectRef} is not a valid project ref. Request to fetch data resulted in: ${projectResponse.error}`
|
||||
);
|
||||
|
||||
throw new SkipCommandError(projectResponse.error);
|
||||
}
|
||||
|
||||
span.setAttributes({
|
||||
...flattenAttributes(projectResponse.data, "cli.project"),
|
||||
});
|
||||
|
||||
span.end();
|
||||
|
||||
return projectResponse.data;
|
||||
}
|
||||
|
||||
const projectsResponse = await apiClient.getProjects();
|
||||
|
||||
if (!projectsResponse.success) {
|
||||
throw new Error(`Failed to get projects: ${projectsResponse.error}`);
|
||||
}
|
||||
|
||||
if (projectsResponse.data.length === 0) {
|
||||
const newProjectLink = cliLink(
|
||||
"Create new project",
|
||||
`${dashboardUrl}/projects/new?version=v3`
|
||||
);
|
||||
|
||||
outro(`You don't have any projects yet. ${newProjectLink}`);
|
||||
|
||||
throw new SkipCommandError();
|
||||
}
|
||||
|
||||
const selectedProject = await select({
|
||||
message: "Select an existing Trigger.dev project",
|
||||
options: projectsResponse.data.map((project) => ({
|
||||
value: project.externalRef,
|
||||
label: `${project.name} - ${project.externalRef}`,
|
||||
hint: project.organization.title,
|
||||
})),
|
||||
});
|
||||
|
||||
if (isCancel(selectedProject)) {
|
||||
throw new OutroCommandError();
|
||||
}
|
||||
|
||||
const projectData = projectsResponse.data.find(
|
||||
(project) => project.externalRef === selectedProject
|
||||
);
|
||||
|
||||
if (!projectData) {
|
||||
throw new Error("Invalid project ref");
|
||||
}
|
||||
|
||||
span.setAttributes({
|
||||
...flattenAttributes(projectData, "cli.project"),
|
||||
});
|
||||
|
||||
span.end();
|
||||
|
||||
return projectData;
|
||||
} catch (e) {
|
||||
if (!(e instanceof SkipCommandError)) {
|
||||
recordSpanException(span, e);
|
||||
}
|
||||
|
||||
span.end();
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function tryResolveTsConfig(cwd: string) {
|
||||
try {
|
||||
const tsconfigPath = await resolveTSConfig(cwd);
|
||||
return tsconfigPath;
|
||||
} catch (_e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
import { confirm, intro, isCancel, log, multiselect, select } from "@clack/prompts";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { extname } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { OutroCommandError, wrapCommandAction } from "../cli/common.js";
|
||||
import { cliLink } from "../utilities/cliOutput.js";
|
||||
import { writeConfigHasSeenMCPInstallPrompt } from "../utilities/configFiles.js";
|
||||
import {
|
||||
expandTilde,
|
||||
safeReadJSONCFile,
|
||||
safeReadTomlFile,
|
||||
writeJSONFile,
|
||||
writeTomlFile,
|
||||
} from "../utilities/fileSystem.js";
|
||||
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
const cliVersion = VERSION as string;
|
||||
const cliTag = cliVersion.includes("v4-beta") ? "v4-beta" : "latest";
|
||||
|
||||
const clients = [
|
||||
"claude-code",
|
||||
"cursor",
|
||||
"vscode",
|
||||
"zed",
|
||||
"windsurf",
|
||||
"gemini-cli",
|
||||
"crush",
|
||||
"cline",
|
||||
"openai-codex",
|
||||
"opencode",
|
||||
"amp",
|
||||
"ruler",
|
||||
] as const;
|
||||
const scopes = ["user", "project", "local"] as const;
|
||||
|
||||
type ClientScopes = {
|
||||
[key in (typeof clients)[number]]: {
|
||||
[key in (typeof scopes)[number]]?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ClientLabels = {
|
||||
[key in (typeof clients)[number]]: string;
|
||||
};
|
||||
|
||||
const clientScopes: ClientScopes = {
|
||||
"claude-code": {
|
||||
user: "~/.claude.json",
|
||||
project: "./.mcp.json",
|
||||
local: "~/.claude.json",
|
||||
},
|
||||
cursor: {
|
||||
user: "~/.cursor/mcp.json",
|
||||
project: "./.cursor/mcp.json",
|
||||
},
|
||||
vscode: {
|
||||
user: "~/Library/Application Support/Code/User/mcp.json",
|
||||
project: "./.vscode/mcp.json",
|
||||
},
|
||||
zed: {
|
||||
user: "~/.config/zed/settings.json",
|
||||
},
|
||||
windsurf: {
|
||||
user: "~/.codeium/windsurf/mcp_config.json",
|
||||
},
|
||||
"gemini-cli": {
|
||||
user: "~/.gemini/settings.json",
|
||||
project: "./.gemini/settings.json",
|
||||
},
|
||||
crush: {
|
||||
user: "~/.config/crush/crush.json",
|
||||
project: "./crush.json",
|
||||
local: "./.crush.json",
|
||||
},
|
||||
cline: {
|
||||
user: "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
||||
},
|
||||
amp: {
|
||||
user: "~/.config/amp/settings.json",
|
||||
},
|
||||
"openai-codex": {
|
||||
user: "~/.codex/config.toml",
|
||||
},
|
||||
opencode: {
|
||||
user: "~/.config/opencode/opencode.json",
|
||||
project: "./opencode.json",
|
||||
},
|
||||
ruler: {
|
||||
project: "./.ruler/mcp.json",
|
||||
},
|
||||
};
|
||||
|
||||
const clientLabels: ClientLabels = {
|
||||
"claude-code": "Claude Code",
|
||||
cursor: "Cursor",
|
||||
vscode: "VSCode",
|
||||
zed: "Zed",
|
||||
windsurf: "Windsurf",
|
||||
"gemini-cli": "Gemini CLI",
|
||||
crush: "Charm Crush",
|
||||
cline: "Cline",
|
||||
"openai-codex": "OpenAI Codex CLI",
|
||||
amp: "Sourcegraph AMP",
|
||||
opencode: "opencode",
|
||||
ruler: "Ruler",
|
||||
};
|
||||
|
||||
type SupportedClients = (typeof clients)[number];
|
||||
type ResolvedClients = SupportedClients | "unsupported";
|
||||
|
||||
const InstallMcpCommandOptions = z.object({
|
||||
projectRef: z.string().optional(),
|
||||
tag: z.string().default(cliTag),
|
||||
devOnly: z.boolean().optional(),
|
||||
yolo: z.boolean().default(false),
|
||||
scope: z.enum(scopes).optional(),
|
||||
client: z.enum(clients).array().optional(),
|
||||
logFile: z.string().optional(),
|
||||
apiUrl: z.string().optional(),
|
||||
logLevel: z.enum(["debug", "info", "log", "warn", "error", "none"]).default("log"),
|
||||
});
|
||||
|
||||
type InstallMcpCommandOptions = z.infer<typeof InstallMcpCommandOptions>;
|
||||
|
||||
export function configureInstallMcpCommand(program: Command) {
|
||||
return program
|
||||
.command("install-mcp")
|
||||
.description("Install the Trigger.dev MCP server")
|
||||
.option(
|
||||
"-p, --project-ref <project ref>",
|
||||
"Scope the mcp server to a specific Trigger.dev project by providing its project ref"
|
||||
)
|
||||
.option(
|
||||
"-t, --tag <package tag>",
|
||||
"The version of the trigger.dev CLI package to use for the MCP server",
|
||||
cliTag
|
||||
)
|
||||
.option("--dev-only", "Restrict the MCP server to the dev environment only")
|
||||
.option("--yolo", "Install the MCP server into all supported clients")
|
||||
.option("--scope <scope>", "Choose the scope of the MCP server, either user or project")
|
||||
.option(
|
||||
"--client <clients...>",
|
||||
"Choose the client (or clients) to install the MCP server into. We currently support: " +
|
||||
clients.join(", ")
|
||||
)
|
||||
.option("--log-file <log file>", "Configure the MCP server to write logs to a file")
|
||||
.option(
|
||||
"-a, --api-url <value>",
|
||||
"Configure the MCP server to specify a custom Trigger.dev API URL"
|
||||
)
|
||||
.option(
|
||||
"-l, --log-level <level>",
|
||||
"The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.",
|
||||
"log"
|
||||
)
|
||||
.action(async (options) => {
|
||||
await printStandloneInitialBanner(true);
|
||||
await installMcpCommand(options);
|
||||
});
|
||||
}
|
||||
|
||||
export async function installMcpCommand(options: unknown) {
|
||||
return await wrapCommandAction(
|
||||
"installMcpCommand",
|
||||
InstallMcpCommandOptions,
|
||||
options,
|
||||
async (opts) => {
|
||||
return await _installMcpCommand(opts);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function _installMcpCommand(options: InstallMcpCommandOptions) {
|
||||
intro("Welcome to the Trigger.dev MCP server install wizard 🧙");
|
||||
|
||||
await installMcpServer(options);
|
||||
}
|
||||
|
||||
type InstallMcpServerResults = Array<InstallMcpServerResult>;
|
||||
|
||||
type InstallMcpServerResult = {
|
||||
configPath: string;
|
||||
clientName: (typeof clients)[number];
|
||||
scope: McpServerScope;
|
||||
};
|
||||
|
||||
export async function installMcpServer(
|
||||
options: InstallMcpCommandOptions
|
||||
): Promise<InstallMcpServerResults> {
|
||||
const opts = InstallMcpCommandOptions.parse(options);
|
||||
|
||||
writeConfigHasSeenMCPInstallPrompt(true);
|
||||
|
||||
const devOnly = await resolveDevOnly(opts);
|
||||
|
||||
opts.devOnly = devOnly;
|
||||
|
||||
const clientNames = await resolveClients(opts);
|
||||
|
||||
if (clientNames.length === 1 && clientNames.includes("unsupported")) {
|
||||
return handleUnsupportedClientOnly(opts);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const clientName of clientNames) {
|
||||
const result = await installMcpServerForClient(clientName, opts);
|
||||
|
||||
if (result) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
log.step("Installed to:");
|
||||
for (const r of results) {
|
||||
const scopeLabel = `${r.scope.scope}`;
|
||||
log.message(` • ${r.clientName} (${scopeLabel}) → ${chalk.gray(r.configPath)}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Next steps:");
|
||||
log.message(" 1. Restart your MCP client(s) to load the new configuration.");
|
||||
log.message(
|
||||
' 2. In your client, look for a server named "trigger". It should connect automatically.'
|
||||
);
|
||||
log.message(" 3. Get started with Trigger.dev");
|
||||
log.message(
|
||||
` Try asking your vibe-coding friend to ${chalk.green("Add trigger.dev to my project")}`
|
||||
);
|
||||
|
||||
log.info("More examples:");
|
||||
log.message(` • ${chalk.green('"Trigger the hello-world task"')}`);
|
||||
log.message(` • ${chalk.green('"Can you help me debug the prod run run_1234"')}`);
|
||||
log.message(` • ${chalk.green('"Deploy my trigger project to staging"')}`);
|
||||
log.message(` • ${chalk.green('"What trigger task handles uploading files to S3?"')}`);
|
||||
log.message(` • ${chalk.green('"How do I create a scheduled task in Trigger.dev?"')}`);
|
||||
log.message(` • ${chalk.green('"Search Trigger.dev docs for ffmpeg examples"')}`);
|
||||
|
||||
log.info("Helpful links:");
|
||||
log.message(` • ${cliLink("Trigger.dev docs", "https://trigger.dev/docs")}`);
|
||||
log.message(` • ${cliLink("MCP docs", "https://trigger.dev/docs/mcp")}`);
|
||||
log.message(
|
||||
` • Need help? ${cliLink(
|
||||
"Join our Discord",
|
||||
"https://trigger.dev/discord"
|
||||
)} or email help@trigger.dev`
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function handleUnsupportedClientOnly(options: InstallMcpCommandOptions): InstallMcpServerResults {
|
||||
log.info("Manual MCP server configuration");
|
||||
|
||||
const args = [`trigger.dev@${options.tag}`, "mcp"];
|
||||
|
||||
if (options.logFile) {
|
||||
args.push("--log-file", options.logFile);
|
||||
}
|
||||
|
||||
if (options.apiUrl) {
|
||||
args.push("--api-url", options.apiUrl);
|
||||
}
|
||||
|
||||
if (options.devOnly) {
|
||||
args.push("--dev-only");
|
||||
}
|
||||
|
||||
if (options.projectRef) {
|
||||
args.push("--project-ref", options.projectRef);
|
||||
}
|
||||
|
||||
if (options.logLevel && options.logLevel !== "log") {
|
||||
args.push("--log-level", options.logLevel);
|
||||
}
|
||||
|
||||
log.message(
|
||||
"Since your client isn't directly supported yet, you'll need to configure it manually:"
|
||||
);
|
||||
log.message("");
|
||||
log.message(`${chalk.yellow("Command:")} ${chalk.green("npx")}`);
|
||||
log.message(`${chalk.yellow("Arguments:")} ${chalk.green(args.join(" "))}`);
|
||||
log.message("");
|
||||
log.message("Add this MCP server configuration to your client's settings:");
|
||||
log.message(` • ${chalk.cyan("Server name:")} trigger`);
|
||||
log.message(` • ${chalk.cyan("Command:")} npx`);
|
||||
log.message(` • ${chalk.cyan("Args:")} ${args.map((arg) => `"${arg}"`).join(", ")}`);
|
||||
log.message("");
|
||||
log.message("Most MCP clients use a JSON configuration format like:");
|
||||
log.message(
|
||||
chalk.dim(`{
|
||||
"mcpServers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": [${args.map((arg) => `"${arg}"`).join(", ")}]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function installMcpServerForClient(
|
||||
clientName: ResolvedClients,
|
||||
options: InstallMcpCommandOptions
|
||||
) {
|
||||
if (clientName === "unsupported") {
|
||||
// This should not happen as unsupported clients are handled separately
|
||||
// but if it does, provide helpful output
|
||||
log.message(
|
||||
`${chalk.yellow("⚠")} Skipping unsupported client - see manual configuration above`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = await resolveScopeForClient(clientName, options);
|
||||
|
||||
// clientSpinner.message(`Installing in ${scope.scope} scope at ${scope.location}`);
|
||||
|
||||
const configPath = await performInstallForClient(clientName, scope, options);
|
||||
|
||||
// clientSpinner.stop(`Successfully installed in ${clientName} (${configPath})`);
|
||||
|
||||
return { configPath, clientName, scope };
|
||||
}
|
||||
|
||||
type McpServerConfig = Record<string, string | Array<string> | boolean | number | undefined>;
|
||||
type McpServerScope = {
|
||||
scope: (typeof scopes)[number];
|
||||
location: string;
|
||||
};
|
||||
|
||||
async function performInstallForClient(
|
||||
clientName: (typeof clients)[number],
|
||||
scope: McpServerScope,
|
||||
options: InstallMcpCommandOptions
|
||||
) {
|
||||
const config = resolveMcpServerConfig(clientName, options);
|
||||
const pathComponents = resolveMcpServerConfigJsonPath(clientName, scope);
|
||||
|
||||
return await writeMcpServerConfig(scope.location, pathComponents, config);
|
||||
}
|
||||
|
||||
async function writeMcpServerConfig(
|
||||
location: string,
|
||||
pathComponents: string[],
|
||||
config: McpServerConfig
|
||||
) {
|
||||
const fullPath = expandTilde(location);
|
||||
|
||||
const extension = extname(fullPath);
|
||||
|
||||
switch (extension) {
|
||||
case ".json": {
|
||||
let existingConfig = await safeReadJSONCFile(fullPath);
|
||||
|
||||
if (!existingConfig) {
|
||||
existingConfig = {};
|
||||
}
|
||||
|
||||
const newConfig = applyConfigToExistingConfig(existingConfig, pathComponents, config);
|
||||
|
||||
await writeJSONFile(fullPath, newConfig, true);
|
||||
break;
|
||||
}
|
||||
case ".toml": {
|
||||
let existingConfig = await safeReadTomlFile(fullPath);
|
||||
|
||||
if (!existingConfig) {
|
||||
existingConfig = {};
|
||||
}
|
||||
|
||||
const newConfig = applyConfigToExistingConfig(existingConfig, pathComponents, config);
|
||||
|
||||
await writeTomlFile(fullPath, newConfig);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
function applyConfigToExistingConfig(
|
||||
existingConfig: any,
|
||||
pathComponents: string[],
|
||||
config: McpServerConfig
|
||||
) {
|
||||
const clonedConfig = structuredClone(existingConfig);
|
||||
|
||||
let currentValueAtPath = clonedConfig;
|
||||
|
||||
for (let i = 0; i < pathComponents.length; i++) {
|
||||
const currentPathSegment = pathComponents[i];
|
||||
|
||||
if (!currentPathSegment) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (i === pathComponents.length - 1) {
|
||||
currentValueAtPath[currentPathSegment] = config;
|
||||
break;
|
||||
} else {
|
||||
currentValueAtPath[currentPathSegment] = currentValueAtPath[currentPathSegment] || {};
|
||||
currentValueAtPath = currentValueAtPath[currentPathSegment];
|
||||
}
|
||||
}
|
||||
|
||||
return clonedConfig;
|
||||
}
|
||||
|
||||
function resolveMcpServerConfigJsonPath(
|
||||
clientName: (typeof clients)[number],
|
||||
scope: McpServerScope
|
||||
) {
|
||||
switch (clientName) {
|
||||
case "cursor": {
|
||||
return ["mcpServers", "trigger"];
|
||||
}
|
||||
case "vscode": {
|
||||
return ["servers", "trigger"];
|
||||
}
|
||||
case "crush": {
|
||||
return ["mcp", "trigger"];
|
||||
}
|
||||
case "windsurf": {
|
||||
return ["mcpServers", "trigger"];
|
||||
}
|
||||
case "gemini-cli": {
|
||||
return ["mcpServers", "trigger"];
|
||||
}
|
||||
case "cline": {
|
||||
return ["mcpServers", "trigger"];
|
||||
}
|
||||
case "amp": {
|
||||
return ["amp.mcpServers", "trigger"];
|
||||
}
|
||||
case "zed": {
|
||||
return ["context_servers", "trigger"];
|
||||
}
|
||||
case "claude-code": {
|
||||
if (scope.scope === "local") {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
return ["projects", projectPath, "mcpServers", "trigger"];
|
||||
} else {
|
||||
return ["mcpServers", "trigger"];
|
||||
}
|
||||
}
|
||||
case "openai-codex": {
|
||||
return ["mcp_servers", "trigger"];
|
||||
}
|
||||
case "opencode": {
|
||||
return ["mcp", "trigger"];
|
||||
}
|
||||
case "ruler": {
|
||||
return ["mcpServers", "trigger"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMcpServerConfig(
|
||||
clientName: (typeof clients)[number],
|
||||
options: InstallMcpCommandOptions
|
||||
): McpServerConfig {
|
||||
const args = [`trigger.dev@${options.tag}`, "mcp"];
|
||||
|
||||
if (options.logFile) {
|
||||
args.push("--log-file", options.logFile);
|
||||
}
|
||||
|
||||
if (options.apiUrl) {
|
||||
args.push("--api-url", options.apiUrl);
|
||||
}
|
||||
|
||||
if (options.devOnly) {
|
||||
args.push("--dev-only");
|
||||
}
|
||||
|
||||
if (options.projectRef) {
|
||||
args.push("--project-ref", options.projectRef);
|
||||
}
|
||||
|
||||
switch (clientName) {
|
||||
case "claude-code": {
|
||||
return {
|
||||
command: "npx",
|
||||
args,
|
||||
};
|
||||
}
|
||||
case "cursor": {
|
||||
return {
|
||||
command: "npx",
|
||||
args,
|
||||
};
|
||||
}
|
||||
case "vscode": {
|
||||
return {
|
||||
command: "npx",
|
||||
args,
|
||||
};
|
||||
}
|
||||
case "crush": {
|
||||
return {
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args,
|
||||
};
|
||||
}
|
||||
case "windsurf": {
|
||||
return {
|
||||
command: "npx",
|
||||
args,
|
||||
};
|
||||
}
|
||||
case "gemini-cli": {
|
||||
return {
|
||||
command: "npx",
|
||||
args,
|
||||
};
|
||||
}
|
||||
case "cline": {
|
||||
return {
|
||||
command: "npx",
|
||||
args,
|
||||
};
|
||||
}
|
||||
case "amp": {
|
||||
return {
|
||||
command: "npx",
|
||||
args,
|
||||
};
|
||||
}
|
||||
case "openai-codex": {
|
||||
return {
|
||||
command: "npx",
|
||||
args,
|
||||
startup_timeout_sec: 30,
|
||||
};
|
||||
}
|
||||
case "zed": {
|
||||
return {
|
||||
source: "custom",
|
||||
command: "npx",
|
||||
args,
|
||||
};
|
||||
}
|
||||
case "opencode": {
|
||||
return {
|
||||
type: "local",
|
||||
command: ["npx", ...args],
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
case "ruler": {
|
||||
return {
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveScopeForClient(
|
||||
clientName: (typeof clients)[number],
|
||||
options: InstallMcpCommandOptions
|
||||
) {
|
||||
if (options.scope) {
|
||||
const location = clientScopes[clientName][options.scope];
|
||||
|
||||
if (!location) {
|
||||
throw new OutroCommandError(
|
||||
`The ${clientName} client does not support the ${
|
||||
options.scope
|
||||
} scope, it only supports ${Object.keys(clientScopes[clientName]).join(", ")} scopes`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
scope: options.scope,
|
||||
location,
|
||||
};
|
||||
}
|
||||
|
||||
const scopeOptions = resolveScopeOptionsForClient(clientName);
|
||||
|
||||
if (scopeOptions.length === 1) {
|
||||
return {
|
||||
scope: scopeOptions[0]!.value.scope,
|
||||
location: scopeOptions[0]!.value.location,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedScope = await select({
|
||||
message: `Where should the MCP server for ${clientName} be installed?`,
|
||||
options: scopeOptions,
|
||||
});
|
||||
|
||||
if (isCancel(selectedScope)) {
|
||||
throw new OutroCommandError("No scope selected");
|
||||
}
|
||||
|
||||
return selectedScope;
|
||||
}
|
||||
|
||||
function resolveScopeOptionsForClient(clientName: (typeof clients)[number]): Array<{
|
||||
value: { location: string; scope: (typeof scopes)[number] };
|
||||
label: string;
|
||||
hint: string;
|
||||
}> {
|
||||
const $clientScopes = clientScopes[clientName];
|
||||
|
||||
const options = Object.entries($clientScopes).map(([scope, location]) => ({
|
||||
value: { location, scope: scope as (typeof scopes)[number] },
|
||||
label: scope,
|
||||
hint: scopeHint(scope as (typeof scopes)[number], location),
|
||||
}));
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function scopeHint(scope: (typeof scopes)[number], location: string) {
|
||||
switch (scope) {
|
||||
case "user": {
|
||||
return `Install for your user account on your machine (${location})`;
|
||||
}
|
||||
case "project": {
|
||||
return `Install in the current project shared with your team (${location})`;
|
||||
}
|
||||
case "local": {
|
||||
return `Install in the current project, local to you only (${location})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveClients(options: InstallMcpCommandOptions): Promise<ResolvedClients[]> {
|
||||
if (options.client) {
|
||||
return options.client;
|
||||
}
|
||||
|
||||
if (options.yolo) {
|
||||
return [...clients];
|
||||
}
|
||||
|
||||
const selectOptions: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}> = clients.map((client) => ({
|
||||
value: client,
|
||||
label: clientLabels[client],
|
||||
}));
|
||||
|
||||
selectOptions.push({
|
||||
value: "unsupported",
|
||||
label: "Unsupported client",
|
||||
hint: "We don't support this client yet, but you can still install the MCP server manually.",
|
||||
});
|
||||
|
||||
const $selectOptions = selectOptions as Array<{
|
||||
value: ResolvedClients;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
|
||||
const selectedClients = await multiselect({
|
||||
message: "Select one or more clients to install the MCP server into",
|
||||
options: $selectOptions,
|
||||
required: true,
|
||||
});
|
||||
|
||||
if (isCancel(selectedClients)) {
|
||||
throw new OutroCommandError("No clients selected");
|
||||
}
|
||||
|
||||
return selectedClients;
|
||||
}
|
||||
|
||||
async function resolveDevOnly(options: InstallMcpCommandOptions) {
|
||||
if (typeof options.devOnly === "boolean") {
|
||||
return options.devOnly;
|
||||
}
|
||||
|
||||
const devOnly = await confirm({
|
||||
message: "Restrict the MCP server to the dev environment only?",
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (isCancel(devOnly)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return devOnly;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { log, outro } from "@clack/prompts";
|
||||
import type { Command } from "commander";
|
||||
import type { z } from "zod";
|
||||
import { CommonCommandOptions, handleTelemetry, wrapCommandAction } from "../cli/common.js";
|
||||
import { chalkGrey } from "../utilities/cliOutput.js";
|
||||
import { readAuthConfigFile } from "../utilities/configFiles.js";
|
||||
import { printInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
|
||||
const ListProfilesOptions = CommonCommandOptions.pick({
|
||||
logLevel: true,
|
||||
skipTelemetry: true,
|
||||
});
|
||||
|
||||
type ListProfilesOptions = z.infer<typeof ListProfilesOptions>;
|
||||
|
||||
export function configureListProfilesCommand(program: Command) {
|
||||
return program
|
||||
.command("list-profiles")
|
||||
.description("List all of your CLI profiles")
|
||||
.option(
|
||||
"-l, --log-level <level>",
|
||||
"The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.",
|
||||
"log"
|
||||
)
|
||||
.option("--skip-telemetry", "Opt-out of sending telemetry")
|
||||
.action(async (options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await listProfilesCommand(options);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function listProfilesCommand(options: unknown) {
|
||||
return await wrapCommandAction("listProfiles", ListProfilesOptions, options, async (opts) => {
|
||||
await printInitialBanner(false);
|
||||
return await listProfiles(opts);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listProfiles(options: ListProfilesOptions) {
|
||||
const authConfig = readAuthConfigFile();
|
||||
|
||||
if (!authConfig) {
|
||||
logger.info("No profiles found");
|
||||
return;
|
||||
}
|
||||
|
||||
const profileNames = Object.keys(authConfig.profiles);
|
||||
|
||||
log.message("Profiles:");
|
||||
|
||||
for (const profile of profileNames) {
|
||||
const profileConfig = authConfig.profiles[profile];
|
||||
|
||||
log.info(`${profile}${profileConfig?.apiUrl ? ` - ${chalkGrey(profileConfig.apiUrl)}` : ""}`);
|
||||
}
|
||||
|
||||
outro("Retrieve account info by running whoami --profile <profile>");
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { intro, log, outro, select } from "@clack/prompts";
|
||||
import { recordSpanException } from "@trigger.dev/core/v3/workers";
|
||||
import type { Command } from "commander";
|
||||
import open from "open";
|
||||
import pRetry, { AbortError } from "p-retry";
|
||||
import { z } from "zod";
|
||||
import { CliApiClient } from "../apiClient.js";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
SkipLoggingError,
|
||||
commonOptions,
|
||||
handleTelemetry,
|
||||
tracer,
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { chalkLink, prettyError } from "../utilities/cliOutput.js";
|
||||
import {
|
||||
readAuthConfigProfile,
|
||||
writeAuthConfigProfile,
|
||||
writeAuthConfigCurrentProfileName,
|
||||
} from "../utilities/configFiles.js";
|
||||
import { printInitialBanner } from "../utilities/initialBanner.js";
|
||||
import {
|
||||
awaitAndDisplayPlatformNotification,
|
||||
fetchPlatformNotification,
|
||||
} from "../utilities/platformNotifications.js";
|
||||
import type { LoginResult } from "../utilities/session.js";
|
||||
import { whoAmI } from "./whoami.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { spinner } from "../utilities/windows.js";
|
||||
import { isLinuxServer } from "../utilities/linux.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { env, isCI } from "std-env";
|
||||
import { CLOUD_API_URL } from "../consts.js";
|
||||
import {
|
||||
validateAccessToken,
|
||||
NotPersonalAccessTokenError,
|
||||
NotAccessTokenError,
|
||||
} from "../utilities/accessTokens.js";
|
||||
import { links } from "@trigger.dev/core/v3";
|
||||
|
||||
export const LoginCommandOptions = CommonCommandOptions.extend({
|
||||
apiUrl: z.string(),
|
||||
browser: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type LoginCommandOptions = z.infer<typeof LoginCommandOptions>;
|
||||
|
||||
export function configureLoginCommand(program: Command) {
|
||||
return commonOptions(
|
||||
program
|
||||
.command("login")
|
||||
.summary("Login with Trigger.dev so you can perform authenticated actions")
|
||||
.description(
|
||||
`Login with Trigger.dev so you can perform authenticated actions.
|
||||
|
||||
Examples:
|
||||
# Interactive login (opens browser)
|
||||
$ trigger.dev login
|
||||
|
||||
# Headless / agent (print URL only)
|
||||
$ trigger.dev login --no-browser
|
||||
|
||||
# Login to a named profile
|
||||
$ trigger.dev login --profile staging`
|
||||
)
|
||||
.option("--no-browser", "Don't automatically open the browser; print the URL only")
|
||||
)
|
||||
.version(VERSION, "-v, --version", "Display the version number")
|
||||
.action(async (options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await printInitialBanner(false, options.profile);
|
||||
await loginCommand(options);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginCommand(options: unknown) {
|
||||
return await wrapCommandAction("loginCommand", LoginCommandOptions, options, async (opts) => {
|
||||
return await _loginCommand(opts);
|
||||
});
|
||||
}
|
||||
|
||||
async function _loginCommand(options: LoginCommandOptions) {
|
||||
return login({
|
||||
defaultApiUrl: options.apiUrl,
|
||||
embedded: false,
|
||||
profile: options.profile,
|
||||
browser: options.browser,
|
||||
});
|
||||
}
|
||||
|
||||
export type LoginOptions = {
|
||||
defaultApiUrl?: string;
|
||||
embedded?: boolean;
|
||||
profile?: string;
|
||||
silent?: boolean;
|
||||
browser?: boolean;
|
||||
};
|
||||
|
||||
export async function login(options?: LoginOptions): Promise<LoginResult> {
|
||||
return await tracer.startActiveSpan("login", async (span) => {
|
||||
try {
|
||||
const opts = {
|
||||
defaultApiUrl: CLOUD_API_URL,
|
||||
embedded: false,
|
||||
silent: false,
|
||||
...options,
|
||||
};
|
||||
|
||||
span.setAttributes({
|
||||
"cli.config.apiUrl": opts.defaultApiUrl,
|
||||
"cli.options.profile": opts.profile,
|
||||
});
|
||||
|
||||
if (!opts.embedded) {
|
||||
intro("Logging in to Trigger.dev");
|
||||
}
|
||||
|
||||
const accessTokenFromEnv = env.TRIGGER_ACCESS_TOKEN;
|
||||
|
||||
if (accessTokenFromEnv) {
|
||||
const validationResult = validateAccessToken(accessTokenFromEnv);
|
||||
|
||||
if (!validationResult.success) {
|
||||
// We deliberately don't surface the existence of organization access tokens to the user for now, as they're only used internally.
|
||||
// Once we expose them in the application, we should also communicate that option here.
|
||||
throw new NotAccessTokenError(
|
||||
"Your TRIGGER_ACCESS_TOKEN is not a Personal Access Token, they start with 'tr_pat_'. You can generate one here: https://cloud.trigger.dev/account/tokens"
|
||||
);
|
||||
}
|
||||
|
||||
const auth = {
|
||||
accessToken: accessTokenFromEnv,
|
||||
apiUrl: env.TRIGGER_API_URL ?? opts.defaultApiUrl ?? CLOUD_API_URL,
|
||||
};
|
||||
const apiClient = new CliApiClient(auth.apiUrl, auth.accessToken);
|
||||
const userData = await apiClient.whoAmI();
|
||||
|
||||
if (!userData.success) {
|
||||
throw new Error(userData.error);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
profile: options?.profile ?? "default",
|
||||
userId: userData.data.userId,
|
||||
email: userData.data.email,
|
||||
dashboardUrl: userData.data.dashboardUrl,
|
||||
auth: {
|
||||
accessToken: auth.accessToken,
|
||||
tokenType: validationResult.type,
|
||||
apiUrl: auth.apiUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const authConfig = readAuthConfigProfile(options?.profile);
|
||||
|
||||
if (authConfig && authConfig.accessToken) {
|
||||
const whoAmIResult = await whoAmI(
|
||||
{
|
||||
profile: options?.profile ?? "default",
|
||||
skipTelemetry: !span.isRecording(),
|
||||
logLevel: logger.loggerLevel,
|
||||
},
|
||||
true,
|
||||
opts.silent
|
||||
);
|
||||
|
||||
if (!whoAmIResult.success) {
|
||||
prettyError("Unable to validate existing personal access token", whoAmIResult.error);
|
||||
|
||||
if (!opts.embedded) {
|
||||
outro(
|
||||
`Login failed using stored token. To fix, first logout using \`trigger.dev logout${
|
||||
options?.profile ? ` --profile ${options.profile}` : ""
|
||||
}\` and then try again.`
|
||||
);
|
||||
|
||||
throw new SkipLoggingError(whoAmIResult.error);
|
||||
} else {
|
||||
throw new Error(whoAmIResult.error);
|
||||
}
|
||||
} else {
|
||||
if (!opts.embedded) {
|
||||
const continueOption = await select({
|
||||
message: "You are already logged in.",
|
||||
options: [
|
||||
{
|
||||
value: false,
|
||||
label: "Exit",
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
label: "Login with a different account",
|
||||
},
|
||||
],
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (continueOption !== true) {
|
||||
outro("Already logged in");
|
||||
|
||||
span.setAttributes({
|
||||
"cli.userId": whoAmIResult.data.userId,
|
||||
"cli.email": whoAmIResult.data.email,
|
||||
"cli.config.apiUrl": authConfig.apiUrl ?? opts.defaultApiUrl,
|
||||
});
|
||||
|
||||
span.end();
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
profile: options?.profile ?? "default",
|
||||
userId: whoAmIResult.data.userId,
|
||||
email: whoAmIResult.data.email,
|
||||
dashboardUrl: whoAmIResult.data.dashboardUrl,
|
||||
auth: {
|
||||
accessToken: authConfig.accessToken,
|
||||
apiUrl: authConfig.apiUrl ?? opts.defaultApiUrl,
|
||||
tokenType: "personal" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
span.setAttributes({
|
||||
"cli.userId": whoAmIResult.data.userId,
|
||||
"cli.email": whoAmIResult.data.email,
|
||||
"cli.config.apiUrl": authConfig.apiUrl ?? opts.defaultApiUrl,
|
||||
});
|
||||
|
||||
span.end();
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
profile: options?.profile ?? "default",
|
||||
userId: whoAmIResult.data.userId,
|
||||
email: whoAmIResult.data.email,
|
||||
dashboardUrl: whoAmIResult.data.dashboardUrl,
|
||||
auth: {
|
||||
accessToken: authConfig.accessToken,
|
||||
apiUrl: authConfig.apiUrl ?? opts.defaultApiUrl,
|
||||
tokenType: "personal" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isCI) {
|
||||
const apiUrl =
|
||||
env.TRIGGER_API_URL ?? authConfig?.apiUrl ?? opts.defaultApiUrl ?? CLOUD_API_URL;
|
||||
|
||||
const isSelfHosted = apiUrl !== CLOUD_API_URL;
|
||||
|
||||
// This is fine, as the api URL will generally be the same as the dashboard URL for self-hosted instances
|
||||
const dashboardUrl = isSelfHosted ? apiUrl : "https://cloud.trigger.dev";
|
||||
|
||||
throw new Error(
|
||||
`Authentication required in CI environment. Please set the TRIGGER_ACCESS_TOKEN environment variable with a Personal Access Token.
|
||||
|
||||
- You can generate one here: ${dashboardUrl}/account/tokens
|
||||
|
||||
- For more information, see: ${links.docs.gitHubActions.personalAccessToken}`
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.embedded) {
|
||||
log.step("You must login to continue.");
|
||||
}
|
||||
|
||||
const apiClient = new CliApiClient(authConfig?.apiUrl ?? opts.defaultApiUrl);
|
||||
|
||||
//generate authorization code
|
||||
const authorizationCodeResult = await createAuthorizationCode(apiClient);
|
||||
|
||||
//Link the user to the authorization code
|
||||
log.step(
|
||||
`Please visit the following URL to login:\n${chalkLink(authorizationCodeResult.url)}`
|
||||
);
|
||||
|
||||
if (opts.browser === false) {
|
||||
log.message("Browser auto-open disabled. Visit the URL above to login.");
|
||||
} else if (await isLinuxServer()) {
|
||||
log.message("Please install `xdg-utils` to automatically open the login URL.");
|
||||
} else {
|
||||
await open(authorizationCodeResult.url);
|
||||
}
|
||||
|
||||
//poll for personal access token (we need to poll for it)
|
||||
const getPersonalAccessTokenSpinner = spinner();
|
||||
getPersonalAccessTokenSpinner.start("Waiting for you to login");
|
||||
try {
|
||||
const indexResult = await pRetry(
|
||||
() => getPersonalAccessToken(apiClient, authorizationCodeResult.authorizationCode),
|
||||
{
|
||||
//this means we're polling, same distance between each attempt
|
||||
factor: 1,
|
||||
retries: 60,
|
||||
minTimeout: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
getPersonalAccessTokenSpinner.stop(`Logged in with token ${indexResult.obfuscatedToken}`);
|
||||
|
||||
writeAuthConfigProfile(
|
||||
{
|
||||
accessToken: indexResult.token,
|
||||
apiUrl: opts.defaultApiUrl,
|
||||
},
|
||||
options?.profile
|
||||
);
|
||||
|
||||
// Only fetch notifications for standalone login, not when embedded in dev
|
||||
// (dev.ts handles its own notification fetch to avoid double counting)
|
||||
const notificationPromise = opts.embedded
|
||||
? undefined
|
||||
: fetchPlatformNotification({
|
||||
apiClient: new CliApiClient(
|
||||
authConfig?.apiUrl ?? opts.defaultApiUrl,
|
||||
indexResult.token
|
||||
),
|
||||
});
|
||||
|
||||
const whoAmIResult = await whoAmI(
|
||||
{
|
||||
profile: options?.profile ?? "default",
|
||||
skipTelemetry: !span.isRecording(),
|
||||
logLevel: logger.loggerLevel,
|
||||
},
|
||||
opts.embedded
|
||||
);
|
||||
|
||||
if (!whoAmIResult.success) {
|
||||
throw new Error(whoAmIResult.error);
|
||||
}
|
||||
|
||||
const profileName = options?.profile ?? "default";
|
||||
|
||||
// Set this profile as the current default
|
||||
writeAuthConfigCurrentProfileName(profileName);
|
||||
|
||||
if (opts.embedded) {
|
||||
log.step("Logged in successfully");
|
||||
} else {
|
||||
outro("Logged in successfully");
|
||||
}
|
||||
|
||||
await awaitAndDisplayPlatformNotification(notificationPromise);
|
||||
|
||||
span.end();
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
profile: profileName,
|
||||
userId: whoAmIResult.data.userId,
|
||||
email: whoAmIResult.data.email,
|
||||
dashboardUrl: whoAmIResult.data.dashboardUrl,
|
||||
auth: {
|
||||
accessToken: indexResult.token,
|
||||
apiUrl: authConfig?.apiUrl ?? opts.defaultApiUrl,
|
||||
tokenType: "personal" as const,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
getPersonalAccessTokenSpinner.stop(`Failed to get access token`);
|
||||
|
||||
if (e instanceof AbortError) {
|
||||
log.error(e.message);
|
||||
}
|
||||
|
||||
recordSpanException(span, e);
|
||||
span.end();
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
recordSpanException(span, e);
|
||||
span.end();
|
||||
|
||||
if (options?.embedded) {
|
||||
if (e instanceof NotPersonalAccessTokenError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPersonalAccessToken(apiClient: CliApiClient, authorizationCode: string) {
|
||||
return await tracer.startActiveSpan("getPersonalAccessToken", async (span) => {
|
||||
try {
|
||||
const token = await apiClient.getPersonalAccessToken(authorizationCode);
|
||||
|
||||
if (!token.success) {
|
||||
throw new AbortError(token.error);
|
||||
}
|
||||
|
||||
if (!token.data.token) {
|
||||
throw new Error("No token found yet");
|
||||
}
|
||||
|
||||
span.end();
|
||||
|
||||
return {
|
||||
token: token.data.token.token,
|
||||
obfuscatedToken: token.data.token.obfuscatedToken,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof AbortError) {
|
||||
recordSpanException(span, e);
|
||||
}
|
||||
|
||||
span.end();
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createAuthorizationCode(apiClient: CliApiClient) {
|
||||
return await tracer.startActiveSpan("createAuthorizationCode", async (span) => {
|
||||
try {
|
||||
//generate authorization code
|
||||
const createAuthCodeSpinner = spinner();
|
||||
createAuthCodeSpinner.start("Creating authorization code");
|
||||
const authorizationCodeResult = await apiClient.createAuthorizationCode();
|
||||
|
||||
if (!authorizationCodeResult.success) {
|
||||
createAuthCodeSpinner.stop(
|
||||
`Failed to create authorization code\n${authorizationCodeResult.error}`
|
||||
);
|
||||
|
||||
throw new SkipLoggingError(
|
||||
`Failed to create authorization code\n${authorizationCodeResult.error}`
|
||||
);
|
||||
}
|
||||
|
||||
createAuthCodeSpinner.stop("Created authorization code");
|
||||
|
||||
span.end();
|
||||
|
||||
return authorizationCodeResult.data;
|
||||
} catch (e) {
|
||||
recordSpanException(span, e);
|
||||
|
||||
span.end();
|
||||
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Command } from "commander";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
commonOptions,
|
||||
handleTelemetry,
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { deleteAuthConfigProfile, readAuthConfigProfile } from "../utilities/configFiles.js";
|
||||
import { printInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
|
||||
const LogoutCommandOptions = CommonCommandOptions;
|
||||
|
||||
type LogoutCommandOptions = z.infer<typeof LogoutCommandOptions>;
|
||||
|
||||
export function configureLogoutCommand(program: Command) {
|
||||
return commonOptions(program.command("logout").description("Logout of Trigger.dev")).action(
|
||||
async (options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await printInitialBanner(false, options.profile);
|
||||
await logoutCommand(options);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function logoutCommand(options: unknown) {
|
||||
return await wrapCommandAction("logoutCommand", LogoutCommandOptions, options, async (opts) => {
|
||||
return await logout(opts);
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(options: LogoutCommandOptions) {
|
||||
const config = readAuthConfigProfile(options.profile);
|
||||
|
||||
if (!config?.accessToken) {
|
||||
logger.info(`You are already logged out [${options.profile}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteAuthConfigProfile(options.profile);
|
||||
|
||||
logger.info(`Logged out of Trigger.dev [${options.profile}]`);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { intro, outro } from "@clack/prompts";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { VERSION } from "@trigger.dev/core";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import type { Command } from "commander";
|
||||
import { z } from "zod";
|
||||
import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js";
|
||||
import { serverMetadata } from "../mcp/config.js";
|
||||
import { McpContext } from "../mcp/context.js";
|
||||
import { toMcpContextOptions } from "../mcp/contextOptions.js";
|
||||
import { FileLogger } from "../mcp/logger.js";
|
||||
import { registerTools } from "../mcp/tools.js";
|
||||
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { installMcpServer } from "./install-mcp.js";
|
||||
import { initiateSkillsInstallWizard } from "./skills.js";
|
||||
|
||||
const McpCommandOptions = CommonCommandOptions.extend({
|
||||
projectRef: z.string().optional(),
|
||||
logFile: z.string().optional(),
|
||||
devOnly: z.boolean().default(false),
|
||||
readonly: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type McpCommandOptions = z.infer<typeof McpCommandOptions>;
|
||||
|
||||
export function configureMcpCommand(program: Command) {
|
||||
return commonOptions(
|
||||
program
|
||||
.command("mcp")
|
||||
.description("Run the MCP server")
|
||||
.option("-p, --project-ref <project ref>", "The project ref to use")
|
||||
.option(
|
||||
"--dev-only",
|
||||
"Only run the MCP server for the dev environment. Attempts to access other environments will fail."
|
||||
)
|
||||
.option(
|
||||
"--readonly",
|
||||
"Run in read-only mode. Write tools (deploy, trigger_task, cancel_run) are hidden from the AI."
|
||||
)
|
||||
.option("--log-file <log file>", "The file to log to")
|
||||
).action(async (options) => {
|
||||
wrapCommandAction("mcp", McpCommandOptions, options, async (opts) => {
|
||||
await mcpCommand(opts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function mcpCommand(options: McpCommandOptions) {
|
||||
if (process.stdout.isTTY) {
|
||||
await printStandloneInitialBanner(true, options.profile);
|
||||
|
||||
intro("Welcome to the Trigger.dev MCP server install wizard 🧙");
|
||||
|
||||
const [installError] = await tryCatch(
|
||||
installMcpServer({
|
||||
yolo: false,
|
||||
tag: VERSION as string,
|
||||
logLevel: "log",
|
||||
})
|
||||
);
|
||||
|
||||
if (installError) {
|
||||
outro(`Failed to install MCP server: ${installError.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await initiateSkillsInstallWizard({});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.loggerLevel = "none";
|
||||
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: serverMetadata.name,
|
||||
version: serverMetadata.version,
|
||||
},
|
||||
{
|
||||
instructions: serverMetadata.instructions,
|
||||
}
|
||||
);
|
||||
|
||||
server.server.oninitialized = async () => {
|
||||
fileLogger?.log("initialized mcp command", { options, argv: process.argv });
|
||||
await context.loadProjectProfile();
|
||||
};
|
||||
|
||||
// Start receiving messages on stdin and sending messages on stdout
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
const fileLogger: FileLogger | undefined = options.logFile
|
||||
? new FileLogger(options.logFile, server)
|
||||
: undefined;
|
||||
|
||||
const context = new McpContext(server, toMcpContextOptions(options, fileLogger));
|
||||
|
||||
registerTools(context);
|
||||
|
||||
await server.connect(transport);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Command } from "commander";
|
||||
import { z } from "zod";
|
||||
import { CliApiClient } from "../apiClient.js";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
commonOptions,
|
||||
handleTelemetry,
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { isLoggedIn } from "../utilities/session.js";
|
||||
|
||||
const MintTokenCommandOptions = CommonCommandOptions.extend({
|
||||
ttl: z.coerce.number().int().positive().optional(),
|
||||
cap: z.string().optional(),
|
||||
client: z.string().optional(),
|
||||
});
|
||||
|
||||
type MintTokenCommandOptions = z.infer<typeof MintTokenCommandOptions>;
|
||||
|
||||
export function configureMintTokenCommand(program: Command) {
|
||||
return commonOptions(
|
||||
program
|
||||
.command("mint-token")
|
||||
.description(
|
||||
"Mint a short-lived token (tr_uat_) that authenticates as you, from your stored personal access token"
|
||||
)
|
||||
.option("--ttl <seconds>", "Token lifetime in seconds (default 3600, max 31536000)")
|
||||
.option(
|
||||
"--cap <scopes>",
|
||||
"Comma-separated scope cap, e.g. read:runs,read:tasks (defaults to your full role)"
|
||||
)
|
||||
.option("--client <label>", "Attribution label recorded in the token (default: cli)")
|
||||
).action(async (options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await mintTokenCommand(options);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function mintTokenCommand(options: unknown) {
|
||||
return await wrapCommandAction(
|
||||
"mintTokenCommand",
|
||||
MintTokenCommandOptions,
|
||||
options,
|
||||
async (opts) => {
|
||||
return await mintToken(opts);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function mintToken(options: MintTokenCommandOptions) {
|
||||
const authentication = await isLoggedIn(options.profile);
|
||||
|
||||
if (!authentication.ok) {
|
||||
throw new Error(
|
||||
authentication.error === "fetch failed"
|
||||
? "Fetch failed. Platform down?"
|
||||
: `You must login first. Use \`trigger.dev login --profile ${options.profile}\` to login.`
|
||||
);
|
||||
}
|
||||
|
||||
const apiClient = new CliApiClient(authentication.auth.apiUrl, authentication.auth.accessToken);
|
||||
|
||||
const cap = options.cap
|
||||
?.split(",")
|
||||
.map((scope) => scope.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const result = await apiClient.mintUserActorToken({
|
||||
ttlSeconds: options.ttl,
|
||||
cap: cap && cap.length > 0 ? cap : undefined,
|
||||
client: options.client ?? "cli",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to mint token: ${result.error}`);
|
||||
}
|
||||
|
||||
// The token alone goes to stdout so it can be captured
|
||||
// (e.g. `UAT=$(trigger.dev mint-token)`); status goes to stderr.
|
||||
process.stderr.write(
|
||||
`Minted token for ${authentication.email} (expires in ${result.data.expiresInSeconds}s)\n`
|
||||
);
|
||||
console.log(result.data.token);
|
||||
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { intro } from "@clack/prompts";
|
||||
import { getBranch } from "@trigger.dev/core/v3";
|
||||
import type { Command } from "commander";
|
||||
import { resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { CliApiClient } from "../apiClient.js";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
commonOptions,
|
||||
handleTelemetry,
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { createGitMeta } from "../utilities/gitMeta.js";
|
||||
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import type { LoginResultOk } from "../utilities/session.js";
|
||||
import { spinner } from "../utilities/windows.js";
|
||||
import { verifyDirectory } from "./deploy.js";
|
||||
import { login } from "./login.js";
|
||||
import { updateTriggerPackages } from "./update.js";
|
||||
|
||||
const PreviewCommandOptions = CommonCommandOptions.extend({
|
||||
branch: z.string().optional(),
|
||||
config: z.string().optional(),
|
||||
projectRef: z.string().optional(),
|
||||
skipUpdateCheck: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type PreviewCommandOptions = z.infer<typeof PreviewCommandOptions>;
|
||||
|
||||
export function configurePreviewCommand(program: Command) {
|
||||
const preview = program.command("preview").description("Modify preview branches");
|
||||
|
||||
commonOptions(
|
||||
preview
|
||||
.command("archive")
|
||||
.description("Archive a preview branch")
|
||||
.argument("[path]", "The path to the project", ".")
|
||||
.option(
|
||||
"-b, --branch <branch>",
|
||||
"The preview branch to archive. If not provided, we'll detect your local git branch."
|
||||
)
|
||||
.option("--skip-update-check", "Skip checking for @trigger.dev package updates")
|
||||
.option("-c, --config <config file>", "The name of the config file, found at [path]")
|
||||
.option(
|
||||
"-p, --project-ref <project ref>",
|
||||
"The project ref. Required if there is no config file. This will override the project specified in the config file."
|
||||
)
|
||||
.option(
|
||||
"--env-file <env file>",
|
||||
"Path to the .env file to load into the CLI process. Defaults to .env in the project directory."
|
||||
)
|
||||
).action(async (path, options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await printStandloneInitialBanner(true, options.profile);
|
||||
await previewArchiveCommand(path, options);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewArchiveCommand(dir: string, options: unknown) {
|
||||
return await wrapCommandAction(
|
||||
"previewArchiveCommand",
|
||||
PreviewCommandOptions,
|
||||
options,
|
||||
async (opts) => {
|
||||
return await _previewArchiveCommand(dir, opts);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function _previewArchiveCommand(dir: string, options: PreviewCommandOptions) {
|
||||
intro(`Archiving preview branch`);
|
||||
|
||||
if (!options.skipUpdateCheck) {
|
||||
await updateTriggerPackages(dir, { ...options }, true, true);
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const projectPath = resolve(cwd, dir);
|
||||
|
||||
verifyDirectory(dir, projectPath);
|
||||
|
||||
const authorization = await login({
|
||||
embedded: true,
|
||||
defaultApiUrl: options.apiUrl,
|
||||
profile: options.profile,
|
||||
});
|
||||
|
||||
if (!authorization.ok) {
|
||||
if (authorization.error === "fetch failed") {
|
||||
throw new Error(
|
||||
`Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`You must login first. Use the \`login\` CLI command.\n\n${authorization.error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedConfig = await loadConfig({
|
||||
cwd: projectPath,
|
||||
overrides: { project: options.projectRef },
|
||||
configFile: options.config,
|
||||
});
|
||||
|
||||
logger.debug("Resolved config", resolvedConfig);
|
||||
|
||||
const gitMeta = await createGitMeta(resolvedConfig.workspaceDir);
|
||||
logger.debug("gitMeta", gitMeta);
|
||||
|
||||
const branch = getBranch({ specified: options.branch, gitMeta });
|
||||
|
||||
if (!branch) {
|
||||
throw new Error(
|
||||
"Didn't auto-detect branch, so you need to specify a preview branch. Use --branch <branch>."
|
||||
);
|
||||
}
|
||||
|
||||
const $buildSpinner = spinner();
|
||||
$buildSpinner.start(`Archiving "${branch}"`);
|
||||
const result = await archivePreviewBranch(authorization, branch, resolvedConfig.project);
|
||||
$buildSpinner.stop(
|
||||
result ? `Successfully archived "${branch}"` : `Failed to archive "${branch}".`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function archivePreviewBranch(
|
||||
authorization: LoginResultOk,
|
||||
branch: string,
|
||||
project: string
|
||||
) {
|
||||
const apiClient = new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken);
|
||||
|
||||
const result = await apiClient.archiveBranch(project, "preview", branch);
|
||||
|
||||
if (result.success) {
|
||||
return true;
|
||||
} else {
|
||||
logger.error(result.error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { intro, outro } from "@clack/prompts";
|
||||
import type { Command } from "commander";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
commonOptions,
|
||||
handleTelemetry,
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { getProjectClient } from "../utilities/session.js";
|
||||
import { login } from "./login.js";
|
||||
import { createGitMeta } from "../utilities/gitMeta.js";
|
||||
import { getBranch } from "@trigger.dev/core/v3";
|
||||
|
||||
const PromoteCommandOptions = CommonCommandOptions.extend({
|
||||
projectRef: z.string().optional(),
|
||||
apiUrl: z.string().optional(),
|
||||
skipUpdateCheck: z.boolean().default(false),
|
||||
config: z.string().optional(),
|
||||
env: z.enum(["prod", "staging", "preview"]),
|
||||
branch: z.string().optional(),
|
||||
});
|
||||
|
||||
type PromoteCommandOptions = z.infer<typeof PromoteCommandOptions>;
|
||||
|
||||
export function configurePromoteCommand(program: Command) {
|
||||
return commonOptions(
|
||||
program
|
||||
.command("promote")
|
||||
.description("Promote a previously deployed version to the current deployment")
|
||||
.argument("[version]", "The version to promote")
|
||||
.option("-c, --config <config file>", "The name of the config file, found at [path]")
|
||||
.option(
|
||||
"-e, --env <env>",
|
||||
"Deploy to a specific environment (currently only prod and staging are supported)",
|
||||
"prod"
|
||||
)
|
||||
.option(
|
||||
"-b, --branch <branch>",
|
||||
"The preview branch to promote when passing --env preview. If not provided, we'll detect your git branch."
|
||||
)
|
||||
.option("--skip-update-check", "Skip checking for @trigger.dev package updates")
|
||||
.option(
|
||||
"-p, --project-ref <project ref>",
|
||||
"The project ref. Required if there is no config file. This will override the project specified in the config file."
|
||||
)
|
||||
).action(async (version, options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await printStandloneInitialBanner(true, options.profile);
|
||||
await promoteCommand(version, options);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function promoteCommand(version: string, options: unknown) {
|
||||
return await wrapCommandAction("promoteCommand", PromoteCommandOptions, options, async (opts) => {
|
||||
return await _promoteCommand(version, opts);
|
||||
});
|
||||
}
|
||||
|
||||
async function _promoteCommand(version: string, options: PromoteCommandOptions) {
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
"You must provide a version to promote like so: `npx trigger.dev@latest promote 20250208.1`"
|
||||
);
|
||||
}
|
||||
|
||||
intro(`Promoting version ${version}`);
|
||||
|
||||
const authorization = await login({
|
||||
embedded: true,
|
||||
defaultApiUrl: options.apiUrl,
|
||||
profile: options.profile,
|
||||
});
|
||||
|
||||
if (!authorization.ok) {
|
||||
if (authorization.error === "fetch failed") {
|
||||
throw new Error(
|
||||
`Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`You must login first. Use the \`login\` CLI command.\n\n${authorization.error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedConfig = await loadConfig({
|
||||
overrides: { project: options.projectRef },
|
||||
configFile: options.config,
|
||||
});
|
||||
|
||||
logger.debug("Resolved config", resolvedConfig);
|
||||
|
||||
const gitMeta = await createGitMeta(resolvedConfig.workspaceDir);
|
||||
logger.debug("gitMeta", gitMeta);
|
||||
|
||||
const branch =
|
||||
options.env === "preview" ? getBranch({ specified: options.branch, gitMeta }) : undefined;
|
||||
if (options.env === "preview" && !branch) {
|
||||
throw new Error(
|
||||
"Didn't auto-detect preview branch, so you need to specify one. Pass --branch <branch>."
|
||||
);
|
||||
}
|
||||
|
||||
const projectClient = await getProjectClient({
|
||||
accessToken: authorization.auth.accessToken,
|
||||
apiUrl: authorization.auth.apiUrl,
|
||||
projectRef: resolvedConfig.project,
|
||||
env: options.env,
|
||||
branch,
|
||||
profile: options.profile,
|
||||
});
|
||||
|
||||
if (!projectClient) {
|
||||
throw new Error("Failed to get project client");
|
||||
}
|
||||
|
||||
const promotion = await projectClient.client.promoteDeployment(version);
|
||||
|
||||
if (!promotion.success) {
|
||||
throw new Error(promotion.error);
|
||||
}
|
||||
|
||||
outro(`Promoted version ${version}`);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveBundledPackageJSON } from "./skills.js";
|
||||
|
||||
/**
|
||||
* Reproduces the published layout: tshy emits a dialect stub `package.json`
|
||||
* ({"type":"module"}) in `dist/esm`, which shadows the real package root when resolving
|
||||
* from the bundled code. The skills dir ships at the package root. Resolution must skip
|
||||
* the stub and land on the root, otherwise `trigger skills` silently finds no skills.
|
||||
*/
|
||||
async function makeBundledPackage(): Promise<{ root: string; distEsm: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), "bundled-cli-"));
|
||||
|
||||
await writeFile(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ name: "trigger.dev", version: "9.9.9-test.1" })
|
||||
);
|
||||
|
||||
await mkdir(join(root, "skills", "authoring-tasks"), { recursive: true });
|
||||
await writeFile(join(root, "skills", "authoring-tasks", "SKILL.md"), "# Authoring");
|
||||
|
||||
const distEsm = join(root, "dist", "esm");
|
||||
await mkdir(distEsm, { recursive: true });
|
||||
// The tshy dialect stub that caused the bug.
|
||||
await writeFile(join(distEsm, "package.json"), JSON.stringify({ type: "module" }));
|
||||
|
||||
return { root, distEsm };
|
||||
}
|
||||
|
||||
describe("resolveBundledPackageJSON", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(roots.map((d) => rm(d, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("skips the tshy dist/esm dialect stub and resolves the package root", async () => {
|
||||
const { root, distEsm } = await makeBundledPackage();
|
||||
roots.push(root);
|
||||
|
||||
const resolved = await resolveBundledPackageJSON(distEsm);
|
||||
|
||||
// Must be the root package.json (which has `skills/` beside it), not the dist/esm stub.
|
||||
expect(resolved).toBe(join(root, "package.json"));
|
||||
expect(dirname(resolved!)).toBe(root);
|
||||
});
|
||||
|
||||
it("resolves directly when started from a dir under the named package root (source/tsx path)", async () => {
|
||||
const { root } = await makeBundledPackage();
|
||||
roots.push(root);
|
||||
|
||||
const srcDir = join(root, "src");
|
||||
await mkdir(srcDir, { recursive: true });
|
||||
|
||||
const resolved = await resolveBundledPackageJSON(srcDir);
|
||||
|
||||
expect(resolved).toBe(join(root, "package.json"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,583 @@
|
||||
import { confirm, intro, isCancel, log, multiselect, outro } from "@clack/prompts";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { Option as CommandOption } from "commander";
|
||||
import { dirname, join } from "node:path";
|
||||
import { readPackageJSON, resolvePackageJSON } from "pkg-types";
|
||||
import * as semver from "semver";
|
||||
import { z } from "zod";
|
||||
import { OutroCommandError, wrapCommandAction } from "../cli/common.js";
|
||||
import type {
|
||||
ManifestVersion,
|
||||
RulesManifest,
|
||||
RulesManifestVersionOption,
|
||||
} from "../rules/manifest.js";
|
||||
import { BundledSkillsLoader, loadRulesManifest } from "../rules/manifest.js";
|
||||
import { sourceDir } from "../sourceDir.js";
|
||||
import { cliLink } from "../utilities/cliOutput.js";
|
||||
import {
|
||||
readConfigHasSeenRulesInstallPrompt,
|
||||
readConfigLastRulesInstallPromptVersion,
|
||||
writeConfigHasSeenRulesInstallPrompt,
|
||||
writeConfigLastRulesInstallPromptVersion,
|
||||
} from "../utilities/configFiles.js";
|
||||
import { pathExists, readFile, safeWriteFile } from "../utilities/fileSystem.js";
|
||||
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
|
||||
// Only tools with a native agent-skills directory. Rules-file-only tools (windsurf,
|
||||
// gemini-cli, cline, amp, kilo, ruler) don't support the Agent Skills format yet, so
|
||||
// they fall under the "Unsupported target" manual path rather than silently no-op.
|
||||
const targets = ["claude-code", "cursor", "vscode", "agents.md"] as const;
|
||||
|
||||
type TargetLabels = {
|
||||
[key in (typeof targets)[number]]: string;
|
||||
};
|
||||
|
||||
const targetLabels: TargetLabels = {
|
||||
"claude-code": "Claude Code",
|
||||
cursor: "Cursor",
|
||||
vscode: "VSCode (Copilot)",
|
||||
"agents.md": "AGENTS.md (OpenAI Codex CLI, Jules, OpenCode)",
|
||||
};
|
||||
|
||||
type SupportedTargets = (typeof targets)[number];
|
||||
type ResolvedTargets = SupportedTargets | "unsupported";
|
||||
|
||||
const SkillsCommandOptions = z.object({
|
||||
target: z.enum(targets).array().optional(),
|
||||
yes: z.boolean().optional(),
|
||||
logLevel: z.enum(["debug", "info", "log", "warn", "error", "none"]).optional(),
|
||||
forceWizard: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type SkillsCommandOptions = z.infer<typeof SkillsCommandOptions>;
|
||||
|
||||
export function configureSkillsCommand(program: Command) {
|
||||
return program
|
||||
.command("skills")
|
||||
.alias("install-rules")
|
||||
.description("Install the Trigger.dev agent skills into your coding agent")
|
||||
.option(
|
||||
"--target <targets...>",
|
||||
"Choose the target (or targets) to install the Trigger.dev skills into. Native install is supported for: " +
|
||||
targets.join(", ")
|
||||
)
|
||||
.option("-y, --yes", "Install all available skills for the selected targets without prompting")
|
||||
.option(
|
||||
"-l, --log-level <level>",
|
||||
"The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.",
|
||||
"log"
|
||||
)
|
||||
.addOption(
|
||||
new CommandOption(
|
||||
"--force-wizard",
|
||||
"Force the skills install wizard to run even if the skills have already been installed."
|
||||
).hideHelp()
|
||||
)
|
||||
.action(async (options) => {
|
||||
await printStandloneInitialBanner(true);
|
||||
await installSkillsCommand(options);
|
||||
});
|
||||
}
|
||||
|
||||
export async function installSkillsCommand(options: unknown) {
|
||||
return await wrapCommandAction(
|
||||
"installSkillsCommand",
|
||||
SkillsCommandOptions,
|
||||
options,
|
||||
async (opts) => {
|
||||
if (opts.logLevel) {
|
||||
logger.loggerLevel = opts.logLevel;
|
||||
}
|
||||
|
||||
return await _installSkillsCommand(opts);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the agent skills bundled in this CLI (`<cli>/skills`, shipped via `files[]`).
|
||||
* The skills dir and version are resolved from the CLI's own package.json, anchored at
|
||||
* `sourceDir` (the CLI's location) rather than the user's cwd. The CLI is the only source
|
||||
* of skills (there is no remote fallback), so this only returns null in the unexpected
|
||||
* case that the CLI ships without any skills.
|
||||
*
|
||||
* tshy emits a dialect stub `package.json` ({"type":"module"}) in `dist/esm`, so the
|
||||
* package.json nearest the bundled code is NOT the package root and has no `skills/`
|
||||
* beside it. We walk up to the first package.json that has a `name` (the real root);
|
||||
* that resolves correctly both when bundled (`<root>/dist/esm`) and from source
|
||||
* (`<root>/src`, run via tsx in dev/tests).
|
||||
*/
|
||||
export async function resolveBundledPackageJSON(
|
||||
startDir: string = sourceDir
|
||||
): Promise<string | null> {
|
||||
let searchDir = startDir;
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = await resolvePackageJSON(searchDir);
|
||||
const pkg = await readPackageJSON(candidate);
|
||||
|
||||
if (pkg.name) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// Climb above this (stub) package.json and keep looking for the real root.
|
||||
const above = dirname(dirname(candidate));
|
||||
if (above === searchDir) {
|
||||
return null;
|
||||
}
|
||||
searchDir = above;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSkillsManifest(): Promise<RulesManifest | null> {
|
||||
try {
|
||||
const packageJsonPath = await resolveBundledPackageJSON();
|
||||
|
||||
if (!packageJsonPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pkg = await readPackageJSON(packageJsonPath);
|
||||
const skillsDir = join(dirname(packageJsonPath), "skills");
|
||||
const version = typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
||||
|
||||
return await loadRulesManifest(new BundledSkillsLoader(skillsDir, version));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function _installSkillsCommand(options: SkillsCommandOptions) {
|
||||
if (options.forceWizard) {
|
||||
await initiateSkillsInstallWizard(options);
|
||||
return;
|
||||
}
|
||||
|
||||
intro("Welcome to the Trigger.dev agent skills installer ");
|
||||
|
||||
const manifest = await loadSkillsManifest();
|
||||
|
||||
if (!manifest) {
|
||||
log.warn("No Trigger.dev agent skills were found in this CLI build.");
|
||||
outro("Nothing to install.");
|
||||
return;
|
||||
}
|
||||
|
||||
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
|
||||
writeConfigHasSeenRulesInstallPrompt(true);
|
||||
|
||||
await installSkills(manifest, options);
|
||||
|
||||
outro("You're all set! ");
|
||||
}
|
||||
|
||||
export type SkillsWizardOptions = {
|
||||
target?: Array<(typeof targets)[number]>;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
export async function initiateSkillsInstallWizard(options: SkillsWizardOptions) {
|
||||
const manifest = await loadSkillsManifest();
|
||||
|
||||
// The CLI couldn't load its own bundled skills (unexpected); nothing to offer.
|
||||
if (!manifest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSeenPrompt = readConfigHasSeenRulesInstallPrompt();
|
||||
|
||||
if (!hasSeenPrompt) {
|
||||
writeConfigHasSeenRulesInstallPrompt(true);
|
||||
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
|
||||
|
||||
const installChoice = await confirm({
|
||||
message: "Would you like to install the Trigger.dev agent skills?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (isCancel(installChoice) || !installChoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
await installSkills(manifest, options);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastVersion = readConfigLastRulesInstallPromptVersion();
|
||||
|
||||
if (!lastVersion) {
|
||||
writeConfigHasSeenRulesInstallPrompt(true);
|
||||
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
|
||||
|
||||
const installChoice = await confirm({
|
||||
message: `A new version of the Trigger.dev agent skills is available (${manifest.currentVersion}). Do you want to install it?`,
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (isCancel(installChoice) || !installChoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
await installSkills(manifest, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (semver.gt(manifest.currentVersion, lastVersion)) {
|
||||
writeConfigHasSeenRulesInstallPrompt(true);
|
||||
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
|
||||
|
||||
const confirmed = await confirm({
|
||||
message: `A new version of the Trigger.dev agent skills is available (${lastVersion} → ${chalk.greenBright(
|
||||
manifest.currentVersion
|
||||
)}). Do you want to install it?`,
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (isCancel(confirmed) || !confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await installSkills(manifest, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the agent-skills install prompt as already seen at the current skills version.
|
||||
* `trigger init` calls this after offering skills in its AI-tooling step (whether or not
|
||||
* the user installs them) so `trigger dev` doesn't ask about skills again. Returns false
|
||||
* if the CLI ships without bundled skills.
|
||||
*/
|
||||
export async function markSkillsPromptSeen(): Promise<boolean> {
|
||||
const manifest = await loadSkillsManifest();
|
||||
|
||||
if (!manifest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
writeConfigHasSeenRulesInstallPrompt(true);
|
||||
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install skills as part of `trigger init`. The user already opted in via init's AI-tooling
|
||||
* prompt, so this skips the extra confirm and goes straight to target/skill selection, then
|
||||
* marks the prompt seen so `trigger dev` won't re-prompt. Returns false if the CLI ships
|
||||
* without bundled skills.
|
||||
*/
|
||||
export async function installSkillsFromInit(opts: SkillsWizardOptions = {}): Promise<boolean> {
|
||||
const manifest = await loadSkillsManifest();
|
||||
|
||||
if (!manifest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
writeConfigHasSeenRulesInstallPrompt(true);
|
||||
writeConfigLastRulesInstallPromptVersion(manifest.currentVersion);
|
||||
|
||||
// Returns true only if skills were actually written (false e.g. when the only target
|
||||
// chosen is "unsupported"), so callers like `trigger init` don't claim skills are ready
|
||||
// when nothing landed.
|
||||
return await installSkills(manifest, opts);
|
||||
}
|
||||
|
||||
async function installSkills(manifest: RulesManifest, opts: SkillsWizardOptions): Promise<boolean> {
|
||||
const currentVersion = await manifest.getCurrentVersion();
|
||||
|
||||
const targetNames = await resolveTargets(opts);
|
||||
|
||||
if (targetNames.length === 1 && targetNames.includes("unsupported")) {
|
||||
handleUnsupportedTargetOnly();
|
||||
return false;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const targetName of targetNames) {
|
||||
const result = await installSkillsForTarget(targetName, currentVersion, opts);
|
||||
|
||||
if (result) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
const installedAny = results.some((r) => r.installations.length > 0 || r.pointer);
|
||||
|
||||
if (installedAny) {
|
||||
log.step("Installed the following skills:");
|
||||
|
||||
for (const r of results) {
|
||||
for (const installation of r.installations) {
|
||||
log.info(chalk.greenBright(installation.location));
|
||||
}
|
||||
if (r.pointer) {
|
||||
log.info(`${chalk.greenBright(r.pointer)} ${chalk.dim("(always-on pointer)")}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
`${cliLink("Learn how to use Trigger.dev skills", "https://trigger.dev/docs/agents/rules/overview")}`
|
||||
);
|
||||
}
|
||||
|
||||
return installedAny;
|
||||
}
|
||||
|
||||
function handleUnsupportedTargetOnly() {
|
||||
log.info(
|
||||
`${cliLink("Install the skills manually", "https://trigger.dev/docs/agents/rules/overview")}`
|
||||
);
|
||||
}
|
||||
|
||||
async function installSkillsForTarget(
|
||||
targetName: ResolvedTargets,
|
||||
currentVersion: ManifestVersion,
|
||||
opts: SkillsWizardOptions
|
||||
) {
|
||||
if (targetName === "unsupported") {
|
||||
// This should not happen as unsupported targets are handled separately,
|
||||
// but if it does, provide helpful output.
|
||||
log.message(
|
||||
`${chalk.yellow("⚠")} Skipping unsupported target - see manual configuration above`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = await resolveOptionsForTarget(targetName, currentVersion, opts);
|
||||
|
||||
const installations = [];
|
||||
|
||||
for (const option of options) {
|
||||
const installation = await performInstallSkillsOptionForTarget(targetName, option);
|
||||
|
||||
if (installation) {
|
||||
installations.push(installation);
|
||||
}
|
||||
}
|
||||
|
||||
// Skills load on demand, so drop one always-on pointer into the tool's primary
|
||||
// instructions file announcing what's installed (decision 7). Body lives in the
|
||||
// on-demand skills; only this one-liner is always in context.
|
||||
let pointer: string | undefined;
|
||||
const skillsDir = resolveSkillsDirForTarget(targetName);
|
||||
if (installations.length > 0 && skillsDir) {
|
||||
pointer = await writeSkillsPointer(
|
||||
targetName,
|
||||
skillsDir,
|
||||
installations.map((i) => i.option.name)
|
||||
);
|
||||
}
|
||||
|
||||
return { targetName, installations, pointer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills are whole folders (SKILL.md + optional references). We write the SKILL.md into
|
||||
* the target tool's native skills directory under the skill's own folder so the tool
|
||||
* discovers it. Targets without a native skills dir are skipped with a notice.
|
||||
*/
|
||||
async function performInstallSkillsOptionForTarget(
|
||||
targetName: (typeof targets)[number],
|
||||
option: RulesManifestVersionOption
|
||||
) {
|
||||
const skillsDir = resolveSkillsDirForTarget(targetName);
|
||||
|
||||
if (!skillsDir) {
|
||||
log.message(
|
||||
`${chalk.yellow("⚠")} ${targetLabels[targetName]} doesn't support agent skills yet, skipping "${option.name}".`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const location = join(skillsDir, option.name, "SKILL.md");
|
||||
|
||||
await safeWriteFile(join(process.cwd(), location), option.contents);
|
||||
|
||||
return { option, location };
|
||||
}
|
||||
|
||||
function resolveSkillsDirForTarget(targetName: (typeof targets)[number]): string | undefined {
|
||||
switch (targetName) {
|
||||
case "claude-code": {
|
||||
return ".claude/skills";
|
||||
}
|
||||
case "cursor": {
|
||||
return ".cursor/skills";
|
||||
}
|
||||
case "vscode": {
|
||||
return ".github/skills";
|
||||
}
|
||||
case "agents.md": {
|
||||
return ".agents/skills";
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const POINTER_START = "<!-- TRIGGER.DEV SKILLS START -->";
|
||||
const POINTER_END = "<!-- TRIGGER.DEV SKILLS END -->";
|
||||
|
||||
type SkillsPointer = { file: string; mode: "region" | "dedicated" };
|
||||
|
||||
/**
|
||||
* The always-on instructions file for each skills-capable target. "region" files are
|
||||
* shared (a marked block is upserted so we never clobber other content); "dedicated"
|
||||
* files are ours to own and overwrite.
|
||||
*/
|
||||
function resolveSkillsPointerForTarget(
|
||||
targetName: (typeof targets)[number]
|
||||
): SkillsPointer | undefined {
|
||||
switch (targetName) {
|
||||
case "claude-code": {
|
||||
return { file: "CLAUDE.md", mode: "region" };
|
||||
}
|
||||
case "cursor": {
|
||||
return { file: ".cursor/rules/trigger-skills.mdc", mode: "dedicated" };
|
||||
}
|
||||
case "vscode": {
|
||||
return { file: ".github/copilot-instructions.md", mode: "region" };
|
||||
}
|
||||
case "agents.md": {
|
||||
return { file: "AGENTS.md", mode: "region" };
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSkillsPointerBody(skillsDir: string, skillNames: string[]): string {
|
||||
const list = skillNames.map((n) => `\`${n}\``).join(", ");
|
||||
return [
|
||||
"## Trigger.dev agent skills",
|
||||
"",
|
||||
`This project has Trigger.dev agent skills installed in \`${skillsDir}/\`. Before writing or changing Trigger.dev code (background tasks, scheduled tasks, realtime, or chat.agent AI agents), load the most relevant skill: ${list}.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes/updates the one-line always-on pointer for a target. Idempotent: region files
|
||||
* replace the marked block (or append it once); the dedicated Cursor rule is overwritten.
|
||||
* Returns the written path, or undefined for targets without a pointer location.
|
||||
*/
|
||||
async function writeSkillsPointer(
|
||||
targetName: (typeof targets)[number],
|
||||
skillsDir: string,
|
||||
skillNames: string[]
|
||||
): Promise<string | undefined> {
|
||||
const pointer = resolveSkillsPointerForTarget(targetName);
|
||||
if (!pointer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const body = buildSkillsPointerBody(skillsDir, skillNames);
|
||||
const absolutePath = join(process.cwd(), pointer.file);
|
||||
|
||||
if (pointer.mode === "dedicated") {
|
||||
// Cursor: a dedicated always-apply rule file we own outright.
|
||||
const contents = [
|
||||
"---",
|
||||
"description: Trigger.dev agent skills are installed in this repo",
|
||||
"alwaysApply: true",
|
||||
"---",
|
||||
"",
|
||||
body,
|
||||
"",
|
||||
].join("\n");
|
||||
await safeWriteFile(absolutePath, contents);
|
||||
return pointer.file;
|
||||
}
|
||||
|
||||
const block = `${POINTER_START}\n${body}\n${POINTER_END}`;
|
||||
|
||||
if (!(await pathExists(absolutePath))) {
|
||||
await safeWriteFile(absolutePath, `${block}\n`);
|
||||
return pointer.file;
|
||||
}
|
||||
|
||||
const existing = await readFile(absolutePath);
|
||||
const pattern = new RegExp(`${POINTER_START}.*?${POINTER_END}`, "s");
|
||||
const next = pattern.test(existing)
|
||||
? existing.replace(pattern, block)
|
||||
: `${existing.trimEnd()}\n\n${block}\n`;
|
||||
|
||||
await safeWriteFile(absolutePath, next);
|
||||
return pointer.file;
|
||||
}
|
||||
|
||||
async function resolveOptionsForTarget(
|
||||
targetName: (typeof targets)[number],
|
||||
currentVersion: ManifestVersion,
|
||||
opts: SkillsWizardOptions
|
||||
) {
|
||||
const possibleOptions = currentVersion.options.filter(
|
||||
(option) => !option.client || option.client === targetName
|
||||
);
|
||||
|
||||
// Non-interactive: install everything available for this target.
|
||||
if (opts.yes) {
|
||||
return possibleOptions;
|
||||
}
|
||||
|
||||
const selectedOptions = await multiselect({
|
||||
message: `Choose the skills you want to install for ${targetLabels[targetName]}`,
|
||||
options: possibleOptions.map((option) => ({
|
||||
value: option,
|
||||
label: option.title,
|
||||
hint: `${option.label} [~${option.tokens} tokens]`,
|
||||
})),
|
||||
required: true,
|
||||
});
|
||||
|
||||
if (isCancel(selectedOptions)) {
|
||||
throw new OutroCommandError("No options selected");
|
||||
}
|
||||
|
||||
return selectedOptions;
|
||||
}
|
||||
|
||||
async function resolveTargets(options: SkillsWizardOptions): Promise<ResolvedTargets[]> {
|
||||
if (options.target) {
|
||||
return options.target;
|
||||
}
|
||||
|
||||
const selectOptions: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}> = targets.map((target) => ({
|
||||
value: target,
|
||||
label: targetLabels[target],
|
||||
}));
|
||||
|
||||
selectOptions.push({
|
||||
value: "unsupported",
|
||||
label: "Unsupported target",
|
||||
hint: "We don't support this target yet, but you can still install the skills manually.",
|
||||
});
|
||||
|
||||
const $selectOptions = selectOptions as Array<{
|
||||
value: ResolvedTargets;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
|
||||
const selectedTargets = await multiselect({
|
||||
message: "Select one or more targets to install the skills into",
|
||||
options: $selectOptions,
|
||||
required: true,
|
||||
});
|
||||
|
||||
if (isCancel(selectedTargets)) {
|
||||
throw new OutroCommandError("No targets selected");
|
||||
}
|
||||
|
||||
return selectedTargets;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { intro, isCancel, log, outro, select } from "@clack/prompts";
|
||||
import type { Command } from "commander";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
handleTelemetry,
|
||||
OutroCommandError,
|
||||
wrapCommandAction,
|
||||
} from "../cli/common.js";
|
||||
import { chalkGrey } from "../utilities/cliOutput.js";
|
||||
import { readAuthConfigFile, writeAuthConfigCurrentProfileName } from "../utilities/configFiles.js";
|
||||
import { printInitialBanner } from "../utilities/initialBanner.js";
|
||||
import { CLOUD_API_URL } from "../consts.js";
|
||||
import { hasTTY, isCI } from "std-env";
|
||||
|
||||
const SwitchProfilesOptions = CommonCommandOptions.pick({
|
||||
logLevel: true,
|
||||
skipTelemetry: true,
|
||||
});
|
||||
|
||||
type SwitchProfilesOptions = z.infer<typeof SwitchProfilesOptions>;
|
||||
|
||||
export function configureSwitchProfilesCommand(program: Command) {
|
||||
return program
|
||||
.command("switch")
|
||||
.description("Set your default CLI profile")
|
||||
.argument("[profile]", "The profile to switch to. Use interactive mode if not provided.")
|
||||
.option(
|
||||
"-l, --log-level <level>",
|
||||
"The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.",
|
||||
"log"
|
||||
)
|
||||
.option("--skip-telemetry", "Opt-out of sending telemetry")
|
||||
.action(async (profile, options) => {
|
||||
await handleTelemetry(async () => {
|
||||
await switchProfilesCommand(profile, options);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function switchProfilesCommand(profile: string | undefined, options: unknown) {
|
||||
return await wrapCommandAction("switch", SwitchProfilesOptions, options, async (opts) => {
|
||||
await printInitialBanner(false);
|
||||
return await switchProfiles(profile, opts);
|
||||
});
|
||||
}
|
||||
|
||||
export async function switchProfiles(profile: string | undefined, options: SwitchProfilesOptions) {
|
||||
intro("Switch profiles");
|
||||
|
||||
const authConfig = readAuthConfigFile();
|
||||
|
||||
if (!authConfig) {
|
||||
outro("Failed to switch profiles");
|
||||
throw new Error("No profiles found. Run `login` to create a profile.");
|
||||
}
|
||||
|
||||
const profileNames = Object.keys(authConfig.profiles).sort((a, b) => {
|
||||
// Default profile should always be first
|
||||
if (a === authConfig.currentProfile) return -1;
|
||||
if (b === authConfig.currentProfile) return 1;
|
||||
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
if (profile) {
|
||||
if (!authConfig.profiles[profile]) {
|
||||
if (isCI || !hasTTY) {
|
||||
outro("Failed to switch profiles");
|
||||
throw new Error(`Profile "${profile}" not found. Run \`login\` to create a profile.`);
|
||||
} else {
|
||||
log.message(`Profile "${profile}" not found, showing available profiles`);
|
||||
}
|
||||
} else {
|
||||
if (authConfig.currentProfile === profile) {
|
||||
outro(`Already using ${profile}`);
|
||||
return;
|
||||
}
|
||||
|
||||
writeAuthConfigCurrentProfileName(profile);
|
||||
outro(`Switched to ${profile}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const profileSelection = await select({
|
||||
message: "Select a new default profile",
|
||||
initialValue: authConfig.currentProfile,
|
||||
options: profileNames.map((profile) => ({
|
||||
value: profile,
|
||||
hint: authConfig.profiles[profile]?.apiUrl
|
||||
? authConfig.profiles[profile].apiUrl === CLOUD_API_URL
|
||||
? undefined
|
||||
: chalkGrey(authConfig.profiles[profile].apiUrl)
|
||||
: undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
if (isCancel(profileSelection)) {
|
||||
throw new OutroCommandError();
|
||||
}
|
||||
|
||||
if (authConfig.currentProfile === profileSelection) {
|
||||
outro(`Already using ${profileSelection}`);
|
||||
return;
|
||||
}
|
||||
|
||||
writeAuthConfigCurrentProfileName(profileSelection);
|
||||
|
||||
if (profileSelection === authConfig.currentProfile) {
|
||||
outro(`No change made`);
|
||||
} else {
|
||||
outro(`Switched to ${profileSelection}`);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user