Skip to content
Open
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",
}),
],
},
});
```
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
66 changes: 57 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 } 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,27 @@ async function findStaticAssetFiles(
return result;
}

// Extracts the base directory from a glob pattern (the non-wildcard prefix).
// For example: "../shared/**" -> "../shared", "./assets/*.txt" -> "./assets"
function getGlobBase(pattern: string): string {
const parts = pattern.split(/[/\\]/);
const baseParts: string[] = [];

for (const part of parts) {
// Stop at the first part that contains glob characters
if (part.includes("*") || part.includes("?") || part.includes("[") || part.includes("{")) {
break;
}
baseParts.push(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 +105,26 @@ 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
pathInsideDestinationDir = relative(cwd, file)
.split(posix.sep)
.filter((p) => p !== "..")
.join(posix.sep);
}

const relativeDestinationPath = join(destinationPath, pathInsideDestinationDir);

Expand Down
Loading