Skip to content

Commit 130ce28

Browse files
vividvioletelanalynn
authored andcommitted
Allow preserving file paths when including assets from config key entry
1 parent 2cf0f84 commit 130ce28

3 files changed

Lines changed: 130 additions & 7 deletions

File tree

packages/app/src/cli/services/build/steps/include-assets-step.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const ConfigKeyEntrySchema = z.object({
5555
destination: z.string().optional(),
5656
anchor: z.string().optional(),
5757
groupBy: z.string().optional(),
58+
preserveFilePaths: z.boolean().default(false),
5859
})
5960

6061
const InclusionEntrySchema = z.discriminatedUnion('type', [PatternEntrySchema, StaticEntrySchema, ConfigKeyEntrySchema])
@@ -126,9 +127,10 @@ export async function executeIncludeAssetsStep(
126127
const outputDir = resolveOutputDir(extension.outputPath)
127128

128129
const aggregatedPathMap = new Map<string, string | string[]>()
129-
// Track basenames written across all configKey entries in this build to detect
130-
// true conflicts (different sources, same basename) while allowing overwrites
131-
// from previous builds.
130+
// Track basenames written across all configKey entries in this build. In
131+
// default mode `uniqueBasename` uses this to rename basename collisions; in
132+
// `preserveFilePaths` mode it's used to detect cross-entry collisions
133+
// (different sources writing the same basename to the bundle).
132134
const usedBasenames = new Set<string>()
133135

134136
// configKey entries run sequentially to avoid filesystem race conditions
@@ -147,6 +149,7 @@ export async function executeIncludeAssetsStep(
147149
context,
148150
destination: sanitizedDest,
149151
usedBasenames,
152+
preserveFilePaths: entry.preserveFilePaths,
150153
})
151154
result.pathMap.forEach((val, key) => aggregatedPathMap.set(key, val))
152155
configKeyCount += result.filesCopied

packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,93 @@ describe('copyConfigKeyEntry', () => {
206206
})
207207
})
208208

209+
describe('preserveFilePaths', () => {
210+
test('throws when two directory sources produce the same output-relative file', async () => {
211+
await inTemporaryDirectory(async (tmpDir) => {
212+
const dirA = joinPath(tmpDir, 'a-assets')
213+
const dirB = joinPath(tmpDir, 'b-assets')
214+
await mkdir(dirA)
215+
await mkdir(dirB)
216+
await writeFile(joinPath(dirA, 'logo.png'), 'a')
217+
await writeFile(joinPath(dirB, 'logo.png'), 'b')
218+
219+
const outDir = joinPath(tmpDir, 'out')
220+
await mkdir(outDir)
221+
222+
const usedBasenames = new Set<string>()
223+
const contextA = makeContext({dir: 'a-assets'}, mockStdout)
224+
await copyConfigKeyEntry({
225+
key: 'dir',
226+
baseDir: tmpDir,
227+
outputDir: outDir,
228+
context: contextA,
229+
usedBasenames,
230+
preserveFilePaths: true,
231+
})
232+
233+
const contextB = makeContext({dir: 'b-assets'}, mockStdout)
234+
await expect(
235+
copyConfigKeyEntry({
236+
key: 'dir',
237+
baseDir: tmpDir,
238+
outputDir: outDir,
239+
context: contextB,
240+
usedBasenames,
241+
preserveFilePaths: true,
242+
}),
243+
).rejects.toThrow(/File collision with preserveFilePaths enabled: 'logo\.png'/)
244+
})
245+
})
246+
247+
test('throws when two single-file sources share a basename (no renaming)', async () => {
248+
await inTemporaryDirectory(async (tmpDir) => {
249+
const dirA = joinPath(tmpDir, 'a')
250+
const dirB = joinPath(tmpDir, 'b')
251+
await mkdir(dirA)
252+
await mkdir(dirB)
253+
await writeFile(joinPath(dirA, 'schema.json'), '{"a": true}')
254+
await writeFile(joinPath(dirB, 'schema.json'), '{"b": true}')
255+
256+
const outDir = joinPath(tmpDir, 'out')
257+
await mkdir(outDir)
258+
259+
const context = makeContext({files: ['a/schema.json', 'b/schema.json']}, mockStdout)
260+
await expect(
261+
copyConfigKeyEntry({
262+
key: 'files',
263+
baseDir: tmpDir,
264+
outputDir: outDir,
265+
context,
266+
preserveFilePaths: true,
267+
}),
268+
).rejects.toThrow(/File collision with preserveFilePaths enabled: 'schema\.json'/)
269+
})
270+
})
271+
272+
test('does not throw when the same directory source is referenced twice (deduped within one call)', async () => {
273+
await inTemporaryDirectory(async (tmpDir) => {
274+
const srcDir = joinPath(tmpDir, 'assets')
275+
await mkdir(srcDir)
276+
await writeFile(joinPath(srcDir, 'foo.json'), '{}')
277+
278+
const outDir = joinPath(tmpDir, 'out')
279+
await mkdir(outDir)
280+
281+
const context = makeContext({extensions: [{targeting: [{assets: 'assets'}, {assets: 'assets'}]}]}, mockStdout)
282+
const result = await copyConfigKeyEntry({
283+
key: 'extensions[].targeting[].assets',
284+
baseDir: tmpDir,
285+
outputDir: outDir,
286+
context,
287+
preserveFilePaths: true,
288+
})
289+
290+
expect(result.filesCopied).toBe(1)
291+
await expect(fileExists(joinPath(outDir, 'foo.json'))).resolves.toBe(true)
292+
})
293+
})
294+
})
295+
209296
test('deduplicates repeated source paths — copies each unique path only once', async () => {
210297
await inTemporaryDirectory(async (tmpDir) => {
211298
await writeFile(joinPath(tmpDir, 'tools.json'), '{}')

packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,17 @@ export async function copyConfigKeyEntry(config: {
2525
context: BuildContext
2626
destination?: string
2727
usedBasenames?: Set<string>
28+
preserveFilePaths?: boolean
2829
}): Promise<{filesCopied: number; pathMap: Map<string, string | string[]>}> {
29-
const {key, baseDir, outputDir, context, destination, usedBasenames = new Set()} = config
30+
const {
31+
key,
32+
baseDir,
33+
outputDir,
34+
context,
35+
destination,
36+
usedBasenames = new Set<string>(),
37+
preserveFilePaths = false,
38+
} = config
3039
const {stdout} = context.options
3140
const value = getNestedValue(context.extension.configuration, key)
3241
let paths: string[]
@@ -75,19 +84,31 @@ export async function copyConfigKeyEntry(config: {
7584
// that may no longer exist in the source, inflating the file count and producing
7685
// stale entries in the manifest's pathMap.
7786
const sourceFiles = await glob(['**/*'], {cwd: fullPath, absolute: false})
87+
const relFiles = sourceFiles.map((file) => relativePath(outputDir, joinPath(destDir, file)))
88+
if (preserveFilePaths) {
89+
for (const file of sourceFiles) assertNoCollision(basename(file), sourcePath, usedBasenames)
90+
}
7891
await copyDirectoryContents(fullPath, destDir)
7992
stdout.write(`Included '${sourcePath}'\n`)
80-
const relFiles = sourceFiles.map((file) => relativePath(outputDir, joinPath(destDir, file)))
93+
for (const file of sourceFiles) usedBasenames.add(basename(file))
8194
pathMap.set(sourcePath, relFiles)
8295
filesCopied += sourceFiles.length
8396
} else {
8497
await mkdir(destDir)
8598
const filename = basename(fullPath)
86-
const destFilename = uniqueBasename(filename, usedBasenames)
99+
let destFilename: string
100+
if (preserveFilePaths) {
101+
// Strict mode: any prior entry that wrote the same basename is a collision.
102+
assertNoCollision(filename, sourcePath, usedBasenames)
103+
destFilename = filename
104+
} else {
105+
// Default mode: flat basename uniqueness across all destinations.
106+
destFilename = uniqueBasename(filename, usedBasenames)
107+
}
87108
usedBasenames.add(destFilename)
109+
const outputRelative = relativePath(outputDir, joinPath(destDir, destFilename))
88110
const destPath = joinPath(destDir, destFilename)
89111
await copyFile(fullPath, destPath)
90-
const outputRelative = relativePath(outputDir, destPath)
91112
stdout.write(`Included '${sourcePath}'\n`)
92113
pathMap.set(sourcePath, outputRelative)
93114
filesCopied += 1
@@ -98,6 +119,18 @@ export async function copyConfigKeyEntry(config: {
98119
return {filesCopied, pathMap}
99120
}
100121

122+
/**
123+
* Throws if `path` is already present in `used`. Shared between directory and
124+
* single-file branches so the error message and check shape stay in one place.
125+
*/
126+
function assertNoCollision(path: string, sourcePath: string, used: Set<string>): void {
127+
if (used.has(path)) {
128+
throw new Error(
129+
`File collision with preserveFilePaths enabled: '${path}' from '${sourcePath}' would overwrite a file copied from a different source. Rename or relocate the conflicting file.`,
130+
)
131+
}
132+
}
133+
101134
/**
102135
* Returns a unique filename given the set of basenames already used in this
103136
* build. If `filename` hasn't been used, returns it as-is. Otherwise appends

0 commit comments

Comments
 (0)