diff --git a/docs/config/extensions/additionalFiles.mdx b/docs/config/extensions/additionalFiles.mdx index 038bf185a..5503b641e 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/src/extensions/core/additionalFiles.ts b/packages/build/src/extensions/core/additionalFiles.ts index cc2a04e0e..a71f42287 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 a815b53c9..b4f0a618f 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 } 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,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 { const result: MatchedStaticAssets = []; @@ -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; - const pathInsideDestinationDir = relative(options?.cwd ?? process.cwd(), file) - .split(posix.sep) - .filter((p) => p !== "..") - .join(posix.sep); + 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); + + // 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);