11import { joinPath , basename , relativePath , extname } from '@shopify/cli-kit/node/path'
22import { glob , copyFile , copyDirectoryContents , fileExists , mkdir , isDirectory } from '@shopify/cli-kit/node/fs'
33import { outputContent , outputDebug , outputToken } from '@shopify/cli-kit/node/output'
4+ import { AbortError } from '@shopify/cli-kit/node/error'
45import type { BuildContext } from '../../client-steps.js'
56
67/**
@@ -25,8 +26,17 @@ export async function copyConfigKeyEntry(config: {
2526 context : BuildContext
2627 destination ?: string
2728 usedBasenames ?: Set < string >
29+ preserveFilePaths ?: boolean
2830} ) : Promise < { filesCopied : number ; pathMap : Map < string , string | string [ ] > } > {
29- const { key, baseDir, outputDir, context, destination, usedBasenames = new Set ( ) } = config
31+ const {
32+ key,
33+ baseDir,
34+ outputDir,
35+ context,
36+ destination,
37+ usedBasenames = new Set < string > ( ) ,
38+ preserveFilePaths = false ,
39+ } = config
3040 const { stdout} = context . options
3141 const value = getNestedValue ( context . extension . configuration , key )
3242 let paths : string [ ]
@@ -75,19 +85,31 @@ export async function copyConfigKeyEntry(config: {
7585 // that may no longer exist in the source, inflating the file count and producing
7686 // stale entries in the manifest's pathMap.
7787 const sourceFiles = await glob ( [ '**/*' ] , { cwd : fullPath , absolute : false } )
88+ const relFiles = sourceFiles . map ( ( file ) => relativePath ( outputDir , joinPath ( destDir , file ) ) )
89+ if ( preserveFilePaths ) {
90+ for ( const file of sourceFiles ) assertNoCollision ( basename ( file ) , sourcePath , usedBasenames )
91+ }
7892 await copyDirectoryContents ( fullPath , destDir )
7993 stdout . write ( `Included '${ sourcePath } '\n` )
80- const relFiles = sourceFiles . map ( ( file ) => relativePath ( outputDir , joinPath ( destDir , file ) ) )
94+ for ( const file of sourceFiles ) usedBasenames . add ( basename ( file ) )
8195 pathMap . set ( sourcePath , relFiles )
8296 filesCopied += sourceFiles . length
8397 } else {
8498 await mkdir ( destDir )
8599 const filename = basename ( fullPath )
86- const destFilename = uniqueBasename ( filename , usedBasenames )
100+ let destFilename : string
101+ if ( preserveFilePaths ) {
102+ // Strict mode: any prior entry that wrote the same basename is a collision.
103+ assertNoCollision ( filename , sourcePath , usedBasenames )
104+ destFilename = filename
105+ } else {
106+ // Default mode: flat basename uniqueness across all destinations.
107+ destFilename = uniqueBasename ( filename , usedBasenames )
108+ }
87109 usedBasenames . add ( destFilename )
110+ const outputRelative = relativePath ( outputDir , joinPath ( destDir , destFilename ) )
88111 const destPath = joinPath ( destDir , destFilename )
89112 await copyFile ( fullPath , destPath )
90- const outputRelative = relativePath ( outputDir , destPath )
91113 stdout . write ( `Included '${ sourcePath } '\n` )
92114 pathMap . set ( sourcePath , outputRelative )
93115 filesCopied += 1
@@ -98,6 +120,18 @@ export async function copyConfigKeyEntry(config: {
98120 return { filesCopied, pathMap}
99121}
100122
123+ /**
124+ * Throws if `path` is already present in `used`. Shared between directory and
125+ * single-file branches so the error message and check shape stay in one place.
126+ */
127+ function assertNoCollision ( path : string , sourcePath : string , used : Set < string > ) : void {
128+ if ( used . has ( path ) ) {
129+ throw new AbortError (
130+ `File collision: '${ path } ' from '${ sourcePath } ' would overwrite a file copied from a different source. Rename or relocate the conflicting file.` ,
131+ )
132+ }
133+ }
134+
101135/**
102136 * Returns a unique filename given the set of basenames already used in this
103137 * build. If `filename` hasn't been used, returns it as-is. Otherwise appends
0 commit comments