Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion docs/config/extensions/additionalFiles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default defineConfig({

This will copy the files specified in the `files` array to the build directory. The `files` array can contain globs. The output paths will match the path of the file, relative to the root of the project.

This extension effects both the `dev` and the `deploy` commands, and the resulting paths will be the same for both.
This extension affects both the `dev` and the `deploy` commands, and the resulting paths will be the same for both.

If you use `legacyDevProcessCwdBehaviour: false`, you can then do this:

Expand All @@ -36,3 +36,73 @@ const interRegularFont = path.join(process.cwd(), "assets/Inter-Regular.ttf");
```

<Note>The root of the project is the directory that contains the trigger.config.ts file</Note>

## Copying files from parent directories (monorepos)

When copying files from parent directories using `..` in your glob patterns, the default behavior strips the `..` segments from the destination path. This can lead to unexpected results in monorepo setups.

For example, if your monorepo structure looks like this:

```
monorepo/
├── apps/
│ ├── trigger/ # Contains trigger.config.ts
│ │ └── trigger.config.ts
│ └── shared/ # Directory you want to copy
│ └── utils.ts
```

Using `additionalFiles({ files: ["../shared/**"] })` would copy `utils.ts` to `shared/utils.ts` in the build directory (not `apps/shared/utils.ts`), because the `..` segment is stripped.

### Using the `destination` option

To control exactly where files are placed, use the `destination` option:

```ts
import { defineConfig } from "@trigger.dev/sdk";
import { additionalFiles } from "@trigger.dev/build/extensions/core";

export default defineConfig({
project: "<project ref>",
build: {
extensions: [
additionalFiles({
files: ["../shared/**"],
destination: "apps/shared", // Files will be placed under apps/shared/
}),
],
},
});
```

With this configuration, `../shared/utils.ts` will be copied to `apps/shared/utils.ts` in the build directory.

<Note>
When using `destination`, the file structure relative to the glob pattern's base directory is preserved.
For example, `../shared/nested/file.ts` with `destination: "libs"` will be copied to `libs/nested/file.ts`.
</Note>

### Multiple directories with different destinations

If you need to copy multiple directories to different locations, use multiple `additionalFiles` extensions:

```ts
import { defineConfig } from "@trigger.dev/sdk";
import { additionalFiles } from "@trigger.dev/build/extensions/core";

export default defineConfig({
project: "<project ref>",
build: {
extensions: [
additionalFiles({
files: ["../shared/**"],
destination: "libs/shared",
}),
additionalFiles({
files: ["../templates/**"],
destination: "assets/templates",
}),
],
},
});
```
7 changes: 5 additions & 2 deletions packages/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@
"dev": "tshy --watch",
"typecheck": "tsc --noEmit -p tsconfig.src.json",
"update-version": "tsx ../../scripts/updateVersion.ts",
"check-exports": "attw --pack ."
"check-exports": "attw --pack .",
"test": "vitest run",
"test:dev": "vitest"
},
"dependencies": {
"@prisma/config": "^6.10.0",
Expand All @@ -91,7 +93,8 @@
"esbuild": "^0.23.0",
"rimraf": "6.0.1",
"tshy": "^3.0.2",
"tsx": "4.17.0"
"tsx": "4.17.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=18.20.0"
Expand Down
19 changes: 19 additions & 0 deletions packages/build/src/extensions/core/additionalFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ import { addAdditionalFilesToBuild } from "../../internal/additionalFiles.js";

export type AdditionalFilesOptions = {
files: string[];
/**
* Optional destination directory for the matched files.
*
* When specified, files will be placed under this directory while preserving
* their structure relative to the glob pattern's base directory.
*
* This is useful when including files from parent directories (using `..` in the glob pattern),
* as the default behavior strips `..` segments which can result in unexpected destination paths.
*
* @example
* // In a monorepo with structure: apps/trigger, apps/shared
* // From apps/trigger/trigger.config.ts:
* additionalFiles({
* files: ["../shared/**"],
* destination: "apps/shared"
* })
* // Files from ../shared/utils.ts will be copied to apps/shared/utils.ts
*/
destination?: string;
};

export function additionalFiles(options: AdditionalFilesOptions): BuildExtension {
Expand Down
78 changes: 69 additions & 9 deletions packages/build/src/internal/additionalFiles.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { BuildManifest } from "@trigger.dev/core/v3";
import { BuildContext } from "@trigger.dev/core/v3/build";
import { copyFile, mkdir } from "node:fs/promises";
import { dirname, join, posix, relative } from "node:path";
import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
import { glob } from "tinyglobby";

export type AdditionalFilesOptions = {
files: string[];
/**
* Optional destination directory for the matched files.
*
* When specified, files will be placed under this directory while preserving
* their structure relative to the glob pattern's base directory.
*
* This is useful when including files from parent directories (using `..` in the glob pattern),
* as the default behavior strips `..` segments which can result in unexpected destination paths.
*
* @example
* // In a monorepo with structure: apps/trigger, apps/shared
* // From apps/trigger/trigger.config.ts:
* additionalFiles({
* files: ["../shared/**"],
* destination: "apps/shared"
* })
* // Files from ../shared/utils.ts will be copied to apps/shared/utils.ts
*/
destination?: string;
};

export async function addAdditionalFilesToBuild(
Expand All @@ -17,6 +36,7 @@ export async function addAdditionalFilesToBuild(
// Copy any static assets to the destination
const staticAssets = await findStaticAssetFiles(options.files ?? [], manifest.outputPath, {
cwd: context.workingDir,
destination: options.destination,
});

for (const { assets, matcher } of staticAssets) {
Expand All @@ -40,7 +60,7 @@ type FoundStaticAssetFiles = Array<{
async function findStaticAssetFiles(
matchers: string[],
destinationPath: string,
options?: { cwd?: string; ignore?: string[] }
options?: { cwd?: string; ignore?: string[]; destination?: string }
): Promise<FoundStaticAssetFiles> {
const result: FoundStaticAssetFiles = [];

Expand All @@ -53,10 +73,38 @@ async function findStaticAssetFiles(
return result;
}

// Extracts the base directory from a glob pattern (the non-wildcard prefix).
// For example: "../shared/**" -> "../shared", "./assets/*.txt" -> "./assets"
// For specific files without globs: "./config/settings.json" -> "./config" (parent dir)
// For single-part patterns: "file.txt" -> "." (current dir)
export function getGlobBase(pattern: string): string {
const parts = pattern.split(/[/\\]/);
const baseParts: string[] = [];
let hasGlobCharacters = false;

for (const part of parts) {
// Stop at the first part that contains glob characters
if (part.includes("*") || part.includes("?") || part.includes("[") || part.includes("{")) {
hasGlobCharacters = true;
break;
}
baseParts.push(part);
}

// If no glob characters were found, the pattern is a specific file path.
// Return the parent directory so that relative() preserves the filename.
// For single-part patterns (just a filename), return "." to indicate current directory.
if (!hasGlobCharacters) {
baseParts.pop(); // Remove the filename, keep the directory (or empty for single-part)
}

return baseParts.length > 0 ? baseParts.join(posix.sep) : ".";
}

async function findStaticAssetsForMatcher(
matcher: string,
destinationPath: string,
options?: { cwd?: string; ignore?: string[] }
options?: { cwd?: string; ignore?: string[]; destination?: string }
): Promise<MatchedStaticAssets> {
const result: MatchedStaticAssets = [];

Expand All @@ -68,15 +116,27 @@ async function findStaticAssetsForMatcher(
absolute: true,
});

let matches = 0;
const cwd = options?.cwd ?? process.cwd();

for (const file of files) {
matches++;
let pathInsideDestinationDir: string;

if (options?.destination) {
// When destination is specified, compute path relative to the glob pattern's base directory
const globBase = getGlobBase(matcher);
const absoluteGlobBase = isAbsolute(globBase) ? globBase : resolve(cwd, globBase);
const relativeToGlobBase = relative(absoluteGlobBase, file);

const pathInsideDestinationDir = relative(options?.cwd ?? process.cwd(), file)
.split(posix.sep)
.filter((p) => p !== "..")
.join(posix.sep);
// Place files under the specified destination directory
pathInsideDestinationDir = join(options.destination, relativeToGlobBase);
} else {
// Default behavior: compute relative path from cwd and strip ".." segments
// Use platform-specific separator for splitting since path.relative() returns platform separators
pathInsideDestinationDir = relative(cwd, file)
.split(sep)
.filter((p) => p !== "..")
.join(posix.sep);
}

const relativeDestinationPath = join(destinationPath, pathInsideDestinationDir);

Expand Down
94 changes: 94 additions & 0 deletions packages/build/test/additionalFiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { getGlobBase } from "../src/internal/additionalFiles.js";

describe("getGlobBase", () => {
describe("glob patterns with wildcards", () => {
it("extracts base from parent directory glob pattern", () => {
expect(getGlobBase("../shared/**")).toBe("../shared");
});

it("extracts base from relative directory glob pattern", () => {
expect(getGlobBase("./assets/*.txt")).toBe("./assets");
});

it("extracts base from nested directory glob pattern", () => {
expect(getGlobBase("files/nested/**/*.js")).toBe("files/nested");
});

it("returns current directory for top-level glob", () => {
expect(getGlobBase("**/*.js")).toBe(".");
});

it("returns current directory for star pattern", () => {
expect(getGlobBase("*.js")).toBe(".");
});

it("handles question mark wildcard", () => {
expect(getGlobBase("./src/?/*.ts")).toBe("./src");
});

it("handles bracket patterns", () => {
expect(getGlobBase("./src/[abc]/*.ts")).toBe("./src");
});

it("handles brace expansion patterns", () => {
expect(getGlobBase("./src/{a,b}/*.ts")).toBe("./src");
});

it("handles deeply nested patterns", () => {
expect(getGlobBase("a/b/c/d/**")).toBe("a/b/c/d");
});
});

describe("specific file paths without globs", () => {
it("returns parent directory for file in subdirectory", () => {
expect(getGlobBase("./config/settings.json")).toBe("./config");
});

it("returns parent directory for file in nested subdirectory", () => {
expect(getGlobBase("../shared/utils/helpers.ts")).toBe("../shared/utils");
});

it("returns current directory for single-part filename", () => {
expect(getGlobBase("file.txt")).toBe(".");
});

it("returns current directory for filename starting with dot", () => {
expect(getGlobBase(".env")).toBe(".");
});

it("returns parent directory for explicit relative path to file", () => {
expect(getGlobBase("./file.txt")).toBe(".");
});

it("returns parent directories for parent reference to file", () => {
expect(getGlobBase("../file.txt")).toBe("..");
});

it("handles multiple parent references", () => {
expect(getGlobBase("../../config/app.json")).toBe("../../config");
});
});

describe("edge cases", () => {
it("returns current directory for empty string", () => {
expect(getGlobBase("")).toBe(".");
});

it("handles Windows-style backslashes", () => {
expect(getGlobBase("..\\shared\\**")).toBe("../shared");
});

it("handles mixed forward and back slashes", () => {
expect(getGlobBase("../shared\\nested/**")).toBe("../shared/nested");
});

it("handles patterns with only dots", () => {
expect(getGlobBase("./")).toBe(".");
});

it("handles parent directory reference only", () => {
expect(getGlobBase("../")).toBe("..");
});
});
});
8 changes: 8 additions & 0 deletions packages/build/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
include: ["test/**/*.test.ts", "src/**/*.test.ts"],
globals: true,
},
});
Loading