diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts index e2ed2d24e8..b88ef0af99 100644 --- a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts @@ -337,9 +337,11 @@ describe('executeIncludeAssetsStep', () => { } as unknown as ExtensionInstance, } - vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.copyDirectoryContents).mockResolvedValue() - vi.mocked(fs.glob).mockResolvedValue(['file.js']) + vi.mocked(fs.fileExists).mockImplementation( + async (path) => typeof path === 'string' && path.startsWith('/test/extension'), + ) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() const step: LifecycleStep = { id: 'copy-tools', @@ -353,10 +355,10 @@ describe('executeIncludeAssetsStep', () => { // When await executeIncludeAssetsStep(step, contextWithNestedConfig) - // Then — all three tools paths resolved and copied - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/tools-a.js', '/test/output') - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/tools-b.js', '/test/output') - expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/tools-c.js', '/test/output') + // Then — all three tools paths resolved and copied (file paths → copyFile) + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools-a.js', '/test/output/tools-a.js') + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools-b.js', '/test/output/tools-b.js') + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools-c.js', '/test/output/tools-c.js') }) test('skips silently when [] flatten key resolves to a non-array', async () => { @@ -603,4 +605,556 @@ describe('executeIncludeAssetsStep', () => { expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/theme', '/test/output') }) }) + + describe('manifest generation', () => { + beforeEach(() => { + vi.mocked(fs.writeFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + // Source files exist; destination paths don't yet (so findUniqueDestPath + // resolves on the first candidate without looping). Individual tests can + // override for specific scenarios. + vi.mocked(fs.fileExists).mockImplementation( + async (path) => typeof path === 'string' && path.startsWith('/test/extension'), + ) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue([]) + }) + + test('writes manifest.json with a single configKey inclusion using anchor and groupBy', async () => { + // Given + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [{target: 'admin.app.intent.link', tools: './tools.json', url: '/editor'}], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + expect(fs.writeFile).toHaveBeenCalledOnce() + const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! + expect(writeFileCall[0]).toBe('/test/output/manifest.json') + const manifestContent = JSON.parse(writeFileCall[1] as string) + expect(manifestContent).toEqual({ + 'admin.app.intent.link': { + tools: 'tools.json', + }, + }) + }) + + test('merges multiple inclusions per target when they share the same anchor and groupBy', async () => { + // Given + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [ + { + target: 'admin.app.intent.link', + tools: './tools.json', + instructions: './instructions.md', + url: '/editor', + intents: [{type: 'application/email', action: 'open', schema: './email-schema.json'}], + }, + ], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + { + type: 'configKey', + key: 'extensions[].targeting[].instructions', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + { + type: 'configKey', + key: 'extensions[].targeting[].intents[].schema', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — url is NOT in the manifest because no inclusion references it + expect(fs.writeFile).toHaveBeenCalledOnce() + const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! + const manifestContent = JSON.parse(writeFileCall[1] as string) + expect(manifestContent).toEqual({ + 'admin.app.intent.link': { + tools: 'tools.json', + instructions: 'instructions.md', + intents: [{schema: 'email-schema.json'}], + }, + }) + }) + + test('produces one manifest key per targeting entry when multiple entries exist', async () => { + // Given + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [ + {target: 'admin.intent.link', tools: './tools-a.js', intents: [{schema: './schema1.json'}]}, + {target: 'admin.other.target', tools: './tools-b.js', intents: [{schema: './schema2.json'}]}, + ], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + { + type: 'configKey', + key: 'extensions[].targeting[].intents[].schema', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — two top-level keys, one per targeting entry + expect(fs.writeFile).toHaveBeenCalledOnce() + const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! + const manifestContent = JSON.parse(writeFileCall[1] as string) + expect(manifestContent).toEqual({ + 'admin.intent.link': { + tools: 'tools-a.js', + intents: [{schema: 'schema1.json'}], + }, + 'admin.other.target': { + tools: 'tools-b.js', + intents: [{schema: 'schema2.json'}], + }, + }) + }) + + test('does NOT write manifest.json when generateManifest is false (default)', async () => { + // Given + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [{target: 'admin.intent.link', tools: './tools.json'}], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockResolvedValue(false) + + // No generateManifest field — defaults to false + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + test('does NOT write manifest.json when generateManifest is true but all inclusions are pattern/static', async () => { + // Given — pattern and static entries never contribute to the manifest + vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/logo.png']) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [{type: 'pattern', baseDir: 'public', include: ['**/*']}], + }, + } + + // When + await executeIncludeAssetsStep(step, mockContext) + + // Then — no configKey inclusions → no manifest written + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + test('writes root-level manifest entry from non-anchored configKey inclusion', async () => { + // Given — configKey without anchor/groupBy contributes at manifest root + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {targeting: {tools: './tools.json', instructions: './instructions.md'}}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [ + {type: 'configKey', key: 'targeting.tools'}, + {type: 'configKey', key: 'targeting.instructions'}, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — root-level keys use last path segment; values are output-relative paths + expect(fs.writeFile).toHaveBeenCalledOnce() + const manifestContent = JSON.parse(vi.mocked(fs.writeFile).mock.calls[0]![1] as string) + expect(manifestContent).toEqual({ + tools: 'tools.json', + instructions: 'instructions.md', + }) + }) + + test('logs a warning and treats inclusion as root-level when only anchor is set (no groupBy)', async () => { + // Given — inclusion has anchor but no groupBy + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {targeting: {tools: './tools.json'}}, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockResolvedValue(false) + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [{type: 'configKey', key: 'targeting.tools', anchor: 'targeting'}], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — warning logged, inclusion treated as root entry + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('anchor without groupBy (or vice versa)')) + const manifestContent = JSON.parse(vi.mocked(fs.writeFile).mock.calls[0]![1] as string) + expect(manifestContent).toHaveProperty('tools') + }) + + test('writes an empty manifest when anchor resolves to a non-array value', async () => { + // Given — "extensions" is a plain string, not an array; the [] flatten marker + // returns undefined, so the anchor group is skipped and the manifest is empty + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: 'not-an-array', + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — the anchor group was skipped; manifest.json is written but contains no entries + expect(fs.writeFile).toHaveBeenCalledOnce() + const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! + const manifestContent = JSON.parse(writeFileCall[1] as string) + expect(manifestContent).toEqual({}) + }) + + test('skips items whose groupBy field is not a string', async () => { + // Given — one entry has a numeric target, the other has a valid string target + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [ + {target: 42, tools: './tools-bad.js'}, + {target: 'admin.link', tools: './tools-good.js'}, + ], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — only the string-keyed entry appears + expect(fs.writeFile).toHaveBeenCalledOnce() + const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! + const manifestContent = JSON.parse(writeFileCall[1] as string) + expect(manifestContent).toEqual({ + 'admin.link': {tools: 'tools-good.js'}, + }) + expect(manifestContent).not.toHaveProperty('42') + }) + + test('writes manifest.json to outputDir derived from extension.outputPath', async () => { + // Given — outputPath is a file, so outputDir is its dirname (/test/output) + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + outputPath: '/test/output/extension.js', + configuration: { + extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], + }, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockResolvedValue(false) + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — manifest is placed under /test/output, which is dirname of extension.js + expect(fs.writeFile).toHaveBeenCalledOnce() + const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! + expect(writeFileCall[0]).toBe('/test/output/manifest.json') + }) + + test('still copies files AND writes manifest when generateManifest is true', async () => { + // Given + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], + }, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.glob).mockResolvedValue([]) + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [ + { + type: 'configKey', + key: 'extensions[].targeting[].tools', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — file copying happened AND manifest was written + // joinPath normalises './tools.json' → 'tools.json', so the resolved source path has no leading './' + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools.json', '/test/output/tools.json') + expect(fs.writeFile).toHaveBeenCalledOnce() + const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! + const manifestContent = JSON.parse(writeFileCall[1] as string) + expect(manifestContent).toEqual({ + 'admin.intent.link': {tools: 'tools.json'}, + }) + }) + + test('includes the full item when anchor equals key (relPath is empty string)', async () => { + // Given — anchor === key, so stripAnchorPrefix returns "" and buildRelativeEntry returns the whole item + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + { + targeting: [{target: 'admin.intent.link', tools: './tools.json', url: '/editor'}], + }, + ], + }, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockResolvedValue(false) + + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [ + { + type: 'configKey', + // anchor === key → the whole targeting item becomes the manifest value + key: 'extensions[].targeting[]', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + }, + ], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — manifest value is the full targeting object (including url) + expect(fs.writeFile).toHaveBeenCalledOnce() + const writeFileCall = vi.mocked(fs.writeFile).mock.calls[0]! + const manifestContent = JSON.parse(writeFileCall[1] as string) + expect(manifestContent).toEqual({ + 'admin.intent.link': { + target: 'admin.intent.link', + tools: './tools.json', + url: '/editor', + }, + }) + }) + }) }) diff --git a/packages/app/src/cli/services/build/steps/include_assets_step.ts b/packages/app/src/cli/services/build/steps/include_assets_step.ts index e185eee9e0..a7de569622 100644 --- a/packages/app/src/cli/services/build/steps/include_assets_step.ts +++ b/packages/app/src/cli/services/build/steps/include_assets_step.ts @@ -1,5 +1,6 @@ import {joinPath, dirname, extname, relativePath, basename} from '@shopify/cli-kit/node/path' -import {glob, copyFile, copyDirectoryContents, fileExists, mkdir} from '@shopify/cli-kit/node/fs' +import {glob, copyFile, copyDirectoryContents, fileExists, mkdir, writeFile} from '@shopify/cli-kit/node/fs' + import {z} from 'zod' import type {LifecycleStep, BuildContext} from '../client-steps.js' @@ -42,12 +43,17 @@ const StaticEntrySchema = z.object({ * copies the directory contents into the output. Silently skipped when the * key is absent. Respects `preserveStructure` and `destination` the same way * as the static entry. + * + * `anchor` and `groupBy` are optional fields used for manifest generation. + * When both are present, this entry participates in `generateManifestFile`. */ const ConfigKeyEntrySchema = z.object({ type: z.literal('configKey'), key: z.string(), destination: z.string().optional(), preserveStructure: z.boolean().default(false), + anchor: z.string().optional(), + groupBy: z.string().optional(), }) const InclusionEntrySchema = z.discriminatedUnion('type', [PatternEntrySchema, StaticEntrySchema, ConfigKeyEntrySchema]) @@ -57,9 +63,14 @@ const InclusionEntrySchema = z.discriminatedUnion('type', [PatternEntrySchema, S * * `inclusions` is a flat array of entries, each with a `type` discriminant * (`'files'` or `'pattern'`). All entries are processed in parallel. + * + * When `generateManifest` is `true`, a `manifest.json` file is written to the + * output directory after all inclusions complete. Only `configKey` entries + * that have both `anchor` and `groupBy` set participate in manifest generation. */ const IncludeAssetsConfigSchema = z.object({ inclusions: z.array(InclusionEntrySchema), + generateManifest: z.boolean().default(false), }) /** @@ -108,6 +119,8 @@ export async function executeIncludeAssetsStep( // parent. When outputPath has no extension, it IS the output directory. const outputDir = extname(extension.outputPath) ? dirname(extension.outputPath) : extension.outputPath + const aggregatedPathMap = new Map() + const counts = await Promise.all( config.inclusions.map(async (entry) => { const warn = (msg: string) => options.stdout.write(msg) @@ -128,7 +141,7 @@ export async function executeIncludeAssetsStep( } if (entry.type === 'configKey') { - return copyConfigKeyEntry( + const result = await copyConfigKeyEntry( entry.key, extension.directory, outputDir, @@ -137,6 +150,8 @@ export async function executeIncludeAssetsStep( entry.preserveStructure, sanitizedDest, ) + result.pathMap.forEach((val, key) => aggregatedPathMap.set(key, val)) + return result.filesCopied } return copySourceEntry( @@ -150,6 +165,10 @@ export async function executeIncludeAssetsStep( }), ) + if (config.generateManifest) { + await generateManifestFile(config, context, outputDir, aggregatedPathMap) + } + return {filesCopied: counts.reduce((sum, count) => sum + count, 0)} } @@ -192,6 +211,29 @@ async function copySourceEntry( return copied.length } +/** + * Returns a destination path for `filename` inside `dir` that does not already + * exist. If `dir/filename` is free, returns it as-is. Otherwise appends a + * counter before the extension: `name-1.ext`, `name-2.ext`, … + */ +async function findUniqueDestPath(dir: string, filename: string): Promise { + const candidate = joinPath(dir, filename) + if (!(await fileExists(candidate))) return candidate + + const ext = extname(filename) + const base = ext ? filename.slice(0, -ext.length) : filename + let counter = 1 + // Sequential loop is intentional: each iteration must check the previous + // result before proceeding to avoid race conditions on concurrent copies. + + while (true) { + const next = joinPath(dir, `${base}-${counter}${ext}`) + // eslint-disable-next-line no-await-in-loop + if (!(await fileExists(next))) return next + counter++ + } +} + /** * Handles a `{configKey}` files entry. * @@ -199,6 +241,16 @@ async function copySourceEntry( * arrays are each used as source paths. Unresolved keys and missing paths are * skipped silently with a log message. When `destination` is given, the * resolved directory is placed under `outputDir/destination`. + * + * File sources are copied with `copyFile` using a unique destination name + * (via `findUniqueDestPath`) to prevent overwrites when multiple config values + * resolve to files with the same basename. Directory sources use + * `copyDirectoryContents` (existing behavior). + * + * Returns `{filesCopied, pathMap}` where `pathMap` maps each raw config path + * value (e.g. `"./tools.json"`) to its actual output-relative path (e.g. + * `"subdir/tools.json"` or `"subdir/tools-1.json"` if renamed to avoid a + * collision). Only successfully copied paths appear in the map. */ async function copyConfigKeyEntry( key: string, @@ -208,7 +260,7 @@ async function copyConfigKeyEntry( options: {stdout: NodeJS.WritableStream}, preserveStructure: boolean, destination?: string, -): Promise { +): Promise<{filesCopied: number; pathMap: Map}> { const value = getNestedValue(context.extension.configuration, key) let paths: string[] if (typeof value === 'string') { @@ -221,30 +273,58 @@ async function copyConfigKeyEntry( if (paths.length === 0) { options.stdout.write(`No value for configKey '${key}', skipping\n`) - return 0 + return {filesCopied: 0, pathMap: new Map()} } const effectiveOutputDir = destination ? joinPath(outputDir, destination) : outputDir - const counts = await Promise.all( - paths.map(async (sourcePath) => { - const fullPath = joinPath(baseDir, sourcePath) - const exists = await fileExists(fullPath) - if (!exists) { - options.stdout.write(`Warning: path '${sourcePath}' does not exist, skipping\n`) - return 0 - } - const destDir = preserveStructure ? joinPath(effectiveOutputDir, basename(fullPath)) : effectiveOutputDir + // Deduplicate: the same source path (e.g. shared tools.json across targets) + // should only be copied once. The pathMap entry is reused for all references. + const uniquePaths = [...new Set(paths)] + + // Process sequentially — findUniqueDestPath relies on filesystem state that + // would race if multiple copies ran in parallel against the same output dir. + const pathMap = new Map() + let filesCopied = 0 + + /* eslint-disable no-await-in-loop */ + for (const sourcePath of uniquePaths) { + const fullPath = joinPath(baseDir, sourcePath) + const exists = await fileExists(fullPath) + if (!exists) { + options.stdout.write(`Warning: path '${sourcePath}' does not exist, skipping\n`) + continue + } + + const destDir = preserveStructure ? joinPath(effectiveOutputDir, basename(fullPath)) : effectiveOutputDir + + // Heuristic: a path with a file extension is treated as a file; without one + // it is treated as a directory. This covers the common cases (e.g. + // `./tools.json` is a file, `public` is a directory) without a stat() call. + const isFile = extname(basename(fullPath)) !== '' + + if (isFile) { + await mkdir(destDir) + const uniqueDestPath = await findUniqueDestPath(destDir, basename(fullPath)) + await copyFile(fullPath, uniqueDestPath) + const outputRelative = relativePath(outputDir, uniqueDestPath) + options.stdout.write(`Copied '${sourcePath}' to ${outputRelative}\n`) + pathMap.set(sourcePath, outputRelative) + filesCopied += 1 + } else { await copyDirectoryContents(fullPath, destDir) const copied = await glob(['**/*'], {cwd: destDir, absolute: false}) const msg = preserveStructure ? `Copied '${sourcePath}' to ${basename(fullPath)}\n` : `Copied contents of '${sourcePath}' to output root\n` options.stdout.write(msg) - return copied.length - }), - ) - return counts.reduce((sum, count) => sum + count, 0) + pathMap.set(sourcePath, relativePath(outputDir, destDir)) + filesCopied += copied.length + } + } + /* eslint-enable no-await-in-loop */ + + return {filesCopied, pathMap} } /** @@ -292,6 +372,213 @@ async function copyByPattern( return {filesCopied: files.length} } +/** + * Strips the anchor prefix (plus trailing dot separator) from a config key path. + * + * Examples: + * anchor = "extensions[].targeting[]", key = "extensions[].targeting[].tools" + * → "tools" + * anchor === key → "" (include the whole item) + * key does not start with anchor → key returned as-is + */ +function stripAnchorPrefix(key: string, anchor: string): string { + if (anchor === key) return '' + const prefix = `${anchor}.` + if (key.startsWith(prefix)) return key.slice(prefix.length) + return key +} + +/** + * Builds a partial manifest object from an item and a relative path string. + * + * - `""` → returns the item itself + * - `"tools"` → `{tools: item.tools}` + * - `"intents[].schema"` → `{intents: item.intents.map(el => buildRelativeEntry(el, "schema"))}` + * + * Uses `tokenizePath` to walk one token at a time recursively. + */ +function buildRelativeEntry(item: {[key: string]: unknown}, relPath: string): {[key: string]: unknown} { + if (relPath === '') return item + + const tokens = tokenizePath(relPath) + const [head, ...rest] = tokens + if (!head) return item + const restPath = rest.map((t) => `${t.name}${t.flatten ? '[]' : ''}`).join('.') + + const value = item[head.name] + + if (head.flatten) { + // Array segment: map over each element with the remaining path + if (!Array.isArray(value)) return {[head.name]: value} + const mapped = (value as {[key: string]: unknown}[]).map((el) => (restPath ? buildRelativeEntry(el, restPath) : el)) + return {[head.name]: mapped} + } + + // Plain segment — recurse if there are more tokens + if (restPath && value !== null && value !== undefined && typeof value === 'object' && !Array.isArray(value)) { + return {[head.name]: buildRelativeEntry(value as {[key: string]: unknown}, restPath)} + } + + return {[head.name]: value} +} + +/** + * Merges multiple partial objects into one (shallow / top-level keys). + * + * Top-level keys are guaranteed not to conflict across inclusions in the same + * anchor group, so a simple `Object.assign` is sufficient. + */ +function deepMerge(objects: {[key: string]: unknown}[]): {[key: string]: unknown} { + return Object.assign({}, ...objects) +} + +/** + * Resolves raw config path values to their output-relative paths using the + * copy-tracked path map. Strings found in the map are replaced with their + * output-relative path; strings not in the map (URLs, type strings, etc.) + * are left unchanged. Walks objects and arrays recursively. + */ +function resolveManifestPaths(value: unknown, pathMap: Map): unknown { + if (typeof value === 'string') return pathMap.get(value) ?? value + if (Array.isArray(value)) return value.map((el) => resolveManifestPaths(el, pathMap)) + if (value !== null && typeof value === 'object') { + const result: {[key: string]: unknown} = {} + for (const [key, val] of Object.entries(value as {[key: string]: unknown})) { + result[key] = resolveManifestPaths(val, pathMap) + } + return result + } + return value +} + +/** + * Returns the last dot-separated segment of a key path, stripping any `[]` suffix. + * + * Examples: + * `"tools"` → `"tools"` + * `"targeting.tools"` → `"tools"` + * `"extensions[].targeting[].tools"` → `"tools"` + */ +function lastKeySegment(key: string): string { + const last = key.split('.').at(-1) ?? key + return last.endsWith('[]') ? last.slice(0, -2) : last +} + +/** + * Generates a `manifest.json` file in `outputDir` from `configKey` inclusions. + * + * Algorithm: + * 1. Partition all `configKey` inclusions into three categories: + * - `partialAnchor`: exactly one of `anchor` / `groupBy` is set → warn and + * treat as root-level (no grouping). + * - `anchoredIncs`: both `anchor` and `groupBy` are set → grouped entries. + * - `rootIncs`: neither `anchor` nor `groupBy` (plus any partialAnchor ones + * after the warning) → root-level fields. + * 2. Return early when there is nothing to write. + * 3. Build root-level entries from `rootIncs`. + * 4. Build grouped entries from `anchoredIncs` (existing anchor/groupBy logic), + * with all leaf path strings resolved via `resolveManifestPaths` using the + * copy-tracked `pathMap`. + * 5. Write the resulting object to `outputDir/manifest.json`. + * + * @param pathMap - Map from raw config path values to their output-relative + * paths, as recorded during the copy phase by `copyConfigKeyEntry`. + */ +async function generateManifestFile( + config: z.infer, + context: BuildContext, + outputDir: string, + pathMap: Map, +): Promise { + const {extension, options} = context + + // Step 1: partition configKey inclusions + type ConfigKeyEntry = z.infer + const configKeyInclusions = config.inclusions.filter((entry): entry is ConfigKeyEntry => entry.type === 'configKey') + + type AnchoredEntry = ConfigKeyEntry & {anchor: string; groupBy: string} + + const partialAnchor: ConfigKeyEntry[] = [] + const anchoredIncs: AnchoredEntry[] = [] + const rootIncs: ConfigKeyEntry[] = [] + + for (const entry of configKeyInclusions) { + const hasAnchor = typeof entry.anchor === 'string' + const hasGroupBy = typeof entry.groupBy === 'string' + + if (hasAnchor && hasGroupBy) { + anchoredIncs.push(entry as AnchoredEntry) + } else if (hasAnchor !== hasGroupBy) { + // Exactly one of the pair is set — warn and demote to root + options.stdout.write( + `Warning: configKey inclusion with key "${entry.key}" has anchor without groupBy (or vice versa) — skipping manifest grouping\n`, + ) + partialAnchor.push(entry) + } else { + rootIncs.push(entry) + } + } + + // Partial-anchor entries are treated as root-level after the warning + const allRootIncs = [...rootIncs, ...partialAnchor] + + if (anchoredIncs.length === 0 && allRootIncs.length === 0) return + + // Step 2: build manifest + const manifest: {[key: string]: unknown} = {} + + // Step 3: root-level entries + for (const inc of allRootIncs) { + const key = lastKeySegment(inc.key) + const rawValue = getNestedValue(extension.configuration, inc.key) + if (rawValue === null || rawValue === undefined) continue + manifest[key] = resolveManifestPaths(rawValue, pathMap) + } + + // Step 4: anchored grouped entries — group by (anchor, groupBy) pair + const groups = new Map() + for (const inclusion of anchoredIncs) { + const groupKey = `${inclusion.anchor}||${inclusion.groupBy}` + const existing = groups.get(groupKey) + if (existing) { + existing.push(inclusion) + } else { + groups.set(groupKey, [inclusion]) + } + } + + for (const inclusions of groups.values()) { + const {anchor, groupBy} = inclusions[0]! + + // Resolve the anchor array from configuration + const anchorValue = getNestedValue(extension.configuration, anchor) + if (!Array.isArray(anchorValue)) continue + + for (const item of anchorValue) { + if (item === null || typeof item !== 'object' || Array.isArray(item)) continue + const typedItem = item as {[key: string]: unknown} + + const manifestKey = typedItem[groupBy] + if (typeof manifestKey !== 'string') continue + + // Build, path-resolve, and merge partial objects for all inclusions in this group + const partials = inclusions.map((inclusion) => { + const relPath = stripAnchorPrefix(inclusion.key, anchor) + const partial = buildRelativeEntry(typedItem, relPath) + return resolveManifestPaths(partial, pathMap) as {[key: string]: unknown} + }) + + manifest[manifestKey] = deepMerge(partials) + } + } + + // Step 5: write manifest.json + const manifestPath = joinPath(outputDir, 'manifest.json') + await mkdir(outputDir) + await writeFile(manifestPath, JSON.stringify(manifest, null, 2)) + options.stdout.write(`Generated manifest.json in ${outputDir}\n`) +} + /** * Splits a path into tokens. A token with `flatten: true` (the `[]` suffix) * signals that an array is expected at that position and the result should be