diff --git a/.changeset/polite-lobsters-double.md b/.changeset/polite-lobsters-double.md
new file mode 100644
index 0000000000..75f78dfea1
--- /dev/null
+++ b/.changeset/polite-lobsters-double.md
@@ -0,0 +1,7 @@
+---
+"@trigger.dev/build": patch
+---
+
+fix(build): add destination option to additionalFiles extension
+
+When using glob patterns with parent directory references (../), the default behavior strips ".." segments resulting in unexpected paths. This adds an optional "destination" parameter that allows users to explicitly specify where matched files should be placed, which is useful in monorepo setups where files need to maintain their structure.
diff --git a/docs/config/extensions/additionalFiles.mdx b/docs/config/extensions/additionalFiles.mdx
index 038bf185ab..5503b641e6 100644
--- a/docs/config/extensions/additionalFiles.mdx
+++ b/docs/config/extensions/additionalFiles.mdx
@@ -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:
@@ -36,3 +36,73 @@ const interRegularFont = path.join(process.cwd(), "assets/Inter-Regular.ttf");
```
The root of the project is the directory that contains the trigger.config.ts file
+
+## 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: "",
+ 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.
+
+
+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`.
+
+
+### 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: "",
+ build: {
+ extensions: [
+ additionalFiles({
+ files: ["../shared/**"],
+ destination: "libs/shared",
+ }),
+ additionalFiles({
+ files: ["../templates/**"],
+ destination: "assets/templates",
+ }),
+ ],
+ },
+});
+```
diff --git a/packages/build/package.json b/packages/build/package.json
index d5ec39213f..7790be9259 100644
--- a/packages/build/package.json
+++ b/packages/build/package.json
@@ -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",
@@ -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": "3.1.4"
},
"engines": {
"node": ">=18.20.0"
diff --git a/packages/build/src/extensions/core/additionalFiles.ts b/packages/build/src/extensions/core/additionalFiles.ts
index cc2a04e0e0..a71f42287f 100644
--- a/packages/build/src/extensions/core/additionalFiles.ts
+++ b/packages/build/src/extensions/core/additionalFiles.ts
@@ -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 {
diff --git a/packages/build/src/internal/additionalFiles.ts b/packages/build/src/internal/additionalFiles.ts
index a815b53c9a..c92b848dc8 100644
--- a/packages/build/src/internal/additionalFiles.ts
+++ b/packages/build/src/internal/additionalFiles.ts
@@ -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(
@@ -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) {
@@ -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 {
const result: FoundStaticAssetFiles = [];
@@ -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 {
const result: MatchedStaticAssets = [];
@@ -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);
diff --git a/packages/build/test/additionalFiles.test.ts b/packages/build/test/additionalFiles.test.ts
new file mode 100644
index 0000000000..b8fea20d90
--- /dev/null
+++ b/packages/build/test/additionalFiles.test.ts
@@ -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("..");
+ });
+ });
+});
diff --git a/packages/build/vitest.config.ts b/packages/build/vitest.config.ts
new file mode 100644
index 0000000000..7f850a5445
--- /dev/null
+++ b/packages/build/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["test/**/*.test.ts", "src/**/*.test.ts"],
+ globals: true,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8202f55d87..d5f6640e4d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1072,7 +1072,7 @@ importers:
version: 18.3.1
react-email:
specifier: ^2.1.1
- version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0)
+ version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0)
resend:
specifier: ^3.2.0
version: 3.2.0
@@ -1367,6 +1367,9 @@ importers:
tsx:
specifier: 4.17.0
version: 4.17.0
+ vitest:
+ specifier: 3.1.4
+ version: 3.1.4(@types/debug@4.1.12)(@types/node@22.13.9)(lightningcss@1.29.2)(terser@5.44.1)
packages/cli-v3:
dependencies:
@@ -37178,7 +37181,7 @@ snapshots:
nano-css@5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
- '@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/sourcemap-codec': 1.5.5
css-tree: 1.1.3
csstype: 3.2.0
fastest-stable-stringify: 2.0.2
@@ -38832,7 +38835,7 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
- react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0):
+ react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0):
dependencies:
'@babel/parser': 7.24.1
'@radix-ui/colors': 1.0.1
@@ -38869,8 +38872,8 @@ snapshots:
react: 18.3.1
react-dom: 18.2.0(react@18.3.1)
shelljs: 0.8.5
- socket.io: 4.7.3(bufferutil@4.0.9)
- socket.io-client: 4.7.3(bufferutil@4.0.9)
+ socket.io: 4.7.3
+ socket.io-client: 4.7.3
sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1)
source-map-js: 1.0.2
stacktrace-parser: 0.1.10
@@ -40015,7 +40018,7 @@ snapshots:
- supports-color
- utf-8-validate
- socket.io-client@4.7.3(bufferutil@4.0.9):
+ socket.io-client@4.7.3:
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.7(supports-color@10.0.0)
@@ -40044,7 +40047,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- socket.io@4.7.3(bufferutil@4.0.9):
+ socket.io@4.7.3:
dependencies:
accepts: 1.3.8
base64id: 2.0.0
@@ -41646,6 +41649,24 @@ snapshots:
- supports-color
- terser
+ vite-node@3.1.4(@types/node@22.13.9)(lightningcss@1.29.2)(terser@5.44.1):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.1(supports-color@10.0.0)
+ es-module-lexer: 1.7.0
+ pathe: 2.0.3
+ vite: 5.4.21(@types/node@22.13.9)(lightningcss@1.29.2)(terser@5.44.1)
+ transitivePeerDependencies:
+ - '@types/node'
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vite-tsconfig-paths@4.0.5(typescript@5.5.4):
dependencies:
debug: 4.3.7(supports-color@10.0.0)
@@ -41677,6 +41698,17 @@ snapshots:
lightningcss: 1.29.2
terser: 5.44.1
+ vite@5.4.21(@types/node@22.13.9)(lightningcss@1.29.2)(terser@5.44.1):
+ dependencies:
+ esbuild: 0.21.5
+ postcss: 8.5.6
+ rollup: 4.36.0
+ optionalDependencies:
+ '@types/node': 22.13.9
+ fsevents: 2.3.3
+ lightningcss: 1.29.2
+ terser: 5.44.1
+
vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1):
dependencies:
'@vitest/expect': 3.1.4
@@ -41714,6 +41746,43 @@ snapshots:
- supports-color
- terser
+ vitest@3.1.4(@types/debug@4.1.12)(@types/node@22.13.9)(lightningcss@1.29.2)(terser@5.44.1):
+ dependencies:
+ '@vitest/expect': 3.1.4
+ '@vitest/mocker': 3.1.4(vite@5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))
+ '@vitest/pretty-format': 3.1.4
+ '@vitest/runner': 3.1.4
+ '@vitest/snapshot': 3.1.4
+ '@vitest/spy': 3.1.4
+ '@vitest/utils': 3.1.4
+ chai: 5.2.0
+ debug: 4.4.0
+ expect-type: 1.2.1
+ magic-string: 0.30.17
+ pathe: 2.0.3
+ std-env: 3.9.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinyglobby: 0.2.13
+ tinypool: 1.0.2
+ tinyrainbow: 2.0.0
+ vite: 5.4.21(@types/node@22.13.9)(lightningcss@1.29.2)(terser@5.44.1)
+ vite-node: 3.1.4(@types/node@22.13.9)(lightningcss@1.29.2)(terser@5.44.1)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/debug': 4.1.12
+ '@types/node': 22.13.9
+ transitivePeerDependencies:
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vscode-jsonrpc@8.2.0: {}
vscode-languageserver-protocol@3.17.5: