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 47680ab9ed..3f6f1e40cd 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 @@ -196,11 +196,12 @@ describe('executeIncludeAssetsStep', () => { expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Copied README.md to README.md')) }) - test('copies a directory to explicit destination path', async () => { + test('copies a directory to explicit destination path and returns actual file count', async () => { // Given vi.mocked(fs.fileExists).mockResolvedValue(true) vi.mocked(fs.isDirectory).mockResolvedValue(true) - vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['a.js', 'b.js', 'c.js']) vi.mocked(fs.mkdir).mockResolvedValue() const step: LifecycleStep = { @@ -215,9 +216,10 @@ describe('executeIncludeAssetsStep', () => { // When const result = await executeIncludeAssetsStep(step, mockContext) - // Then - expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/dist', '/test/output/assets/dist') - expect(result.filesCopied).toBe(1) + // Then — uses copyDirectoryContents (not copyFile) and counts actual files via glob + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/dist', '/test/output/assets/dist') + expect(fs.copyFile).not.toHaveBeenCalled() + expect(result.filesCopied).toBe(3) expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Copied dist to assets/dist')) }) }) @@ -346,6 +348,75 @@ describe('executeIncludeAssetsStep', () => { ) }) + test('renames output file to avoid collision when candidate path already exists', async () => { + // Given — tools.json already exists in the output dir; findUniqueDestPath must try tools-1.json + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {tools: './tools.json'}, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockImplementation(async (path) => { + const pathStr = String(path) + // Source file exists; first candidate output path is taken; suffixed path is free + return pathStr === '/test/extension/tools.json' || pathStr === '/test/output/tools.json' + }) + vi.mocked(fs.isDirectory).mockResolvedValue(false) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'tools'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — copied to tools-1.json, not tools.json + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools.json', '/test/output/tools-1.json') + expect(result.filesCopied).toBe(1) + }) + + test('throws after exhausting 1000 rename attempts', async () => { + // Given — every candidate path (tools.json, tools-1.json … tools-1000.json) is taken + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {tools: './tools.json'}, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockImplementation(async (path) => { + const pathStr = String(path) + // Source file exists; all 1001 output candidates are occupied + return pathStr.startsWith('/test/extension/') || pathStr.startsWith('/test/output/tools') + }) + vi.mocked(fs.isDirectory).mockResolvedValue(false) + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'tools'}], + }, + } + + // When / Then + await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( + "Unable to find unique destination path for 'tools.json' in '/test/output' after 1000 attempts", + ) + }) + test('resolves array config value and copies each path', async () => { // Given — static_root is an array const contextWithArrayConfig = { @@ -393,10 +464,14 @@ describe('executeIncludeAssetsStep', () => { } as unknown as ExtensionInstance, } - vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.fileExists).mockImplementation( + async (path) => typeof path === 'string' && path.startsWith('/test/extension'), + ) + vi.mocked(fs.isDirectory).mockResolvedValue(false) vi.mocked(fs.copyDirectoryContents).mockResolvedValue() vi.mocked(fs.glob).mockResolvedValue(['file.js']) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() const step: LifecycleStep = { id: 'copy-tools', @@ -410,10 +485,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 () => { @@ -454,7 +529,8 @@ describe('executeIncludeAssetsStep', () => { } vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.isDirectory).mockResolvedValue(true) + // Directories have no file extension; files do + vi.mocked(fs.isDirectory).mockImplementation(async (path) => !/\.\w+$/.test(String(path))) vi.mocked(fs.copyDirectoryContents).mockResolvedValue() vi.mocked(fs.copyFile).mockResolvedValue() vi.mocked(fs.mkdir).mockResolvedValue() @@ -475,7 +551,7 @@ describe('executeIncludeAssetsStep', () => { // When const result = await executeIncludeAssetsStep(step, contextWithConfig) - // Then + // Then — directory configKey uses copyDirectoryContents; file static uses copyFile expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/public', '/test/output') expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/icon.png', '/test/output/assets/icon.png') expect(result.filesCopied).toBe(2) @@ -630,14 +706,16 @@ describe('executeIncludeAssetsStep', () => { } vi.mocked(fs.fileExists).mockResolvedValue(true) - vi.mocked(fs.isDirectory).mockResolvedValue(true) + // Directories have no file extension; files do + vi.mocked(fs.isDirectory).mockImplementation(async (path) => !/\.\w+$/.test(String(path))) vi.mocked(fs.copyDirectoryContents).mockResolvedValue() vi.mocked(fs.copyFile).mockResolvedValue() vi.mocked(fs.mkdir).mockResolvedValue() - // glob: first call for pattern entry, second for configKey dir listing + // configKey entries run sequentially first, then pattern/static in parallel. + // glob: first call for configKey dir listing, second for pattern source files. vi.mocked(fs.glob) - .mockResolvedValueOnce(['/test/extension/assets/logo.png', '/test/extension/assets/icon.svg']) .mockResolvedValueOnce(['index.html', 'style.css']) + .mockResolvedValueOnce(['/test/extension/assets/logo.png', '/test/extension/assets/icon.svg']) const step: LifecycleStep = { id: 'include-all', @@ -656,10 +734,725 @@ describe('executeIncludeAssetsStep', () => { const result = await executeIncludeAssetsStep(step, contextWithConfig) // Then - // 5 = 2 pattern + 2 configKey dir contents + 1 explicit file + // 5 = 2 pattern + 2 configKey dir contents + 1 explicit file (manifest.json is a file → copyFile → 1) expect(result.filesCopied).toBe(5) expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/manifest.json', '/test/output/manifest.json') 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('maps a directory configKey to a file list in the manifest', async () => { + // Directory sources produce a string[] of output-relative file paths rather + // than an opaque directory marker like "." or "". + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {admin: {static_root: 'dist'}}, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockImplementation(async (pathArg) => { + // The source 'dist' directory must exist so the copy runs; manifest.json must not + return String(pathArg) === '/test/extension/dist' + }) + vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['index.html', 'style.css']) + + const step: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + generateManifest: true, + // no destination, no preserveStructure → contents merged into output root + inclusions: [{type: 'configKey', key: 'admin.static_root'}], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — directory produces a file list, not an opaque directory marker + expect(fs.writeFile).toHaveBeenCalledOnce() + const manifestContent = JSON.parse(vi.mocked(fs.writeFile).mock.calls[0]![1] as string) + expect(manifestContent).toEqual({static_root: ['index.html', 'style.css']}) + }) + + test('throws a validation error when only anchor is set without groupBy', async () => { + // Given — inclusion has anchor but no groupBy — schema now rejects this at parse time + const step: LifecycleStep = { + id: 'gen-manifest', + name: 'Generate Manifest', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [{type: 'configKey', key: 'targeting.tools', anchor: 'targeting'}], + }, + } + + // When / Then — schema refinement rejects anchor without groupBy + await expect(executeIncludeAssetsStep(step, mockContext)).rejects.toThrow( + '`anchor` and `groupBy` must both be set or both be omitted', + ) + }) + + test('throws when manifest.json already exists in the output directory', async () => { + // Given — a prior inclusion already copied a manifest.json to the output dir + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], + }, + } as unknown as ExtensionInstance, + } + + // Source files exist; output manifest.json already exists (simulating conflict); + // candidate output paths for tools.json are free so copyConfigKeyEntry succeeds. + vi.mocked(fs.fileExists).mockImplementation(async (path) => { + const pathStr = String(path) + return pathStr === '/test/output/manifest.json' || pathStr.startsWith('/test/extension/') + }) + 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 / Then — throws rather than silently overwriting + await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( + `Can't write manifest.json: a file already exists at '/test/output/manifest.json'`, + ) + }) + + 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 — no entries produced; manifest.json is NOT written, warning is logged + expect(fs.writeFile).not.toHaveBeenCalled() + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('no manifest entries produced')) + }) + + 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('resolves bare filename in manifest even without ./ prefix', async () => { + // Given — config value is a bare filename with no ./ prefix; pathMap.has() must catch it + 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 — 'tools.json' (no ./ prefix) must be resolved to its output-relative path in the manifest + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/tools.json', '/test/output/tools.json') + 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). + // tools: './tools.json' was never copied (configKey resolved to an object, not a string), + // so the path is left as-is and a warning is logged. + 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', + }, + }) + expect(mockStdout.write).toHaveBeenCalledWith( + expect.stringContaining("manifest entry 'admin.intent.link' contains unresolved paths"), + ) + }) + + test('warns when a manifest entry contains an unresolved path because the source file was missing', async () => { + // Given — tools.json is referenced in config but does not exist on disk, + // so copyConfigKeyEntry skips it and it never enters pathMap. + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [{targeting: [{target: 'admin.intent.link', tools: './tools.json'}]}], + }, + } as unknown as ExtensionInstance, + } + + // Source file does not exist; output paths are free + vi.mocked(fs.fileExists).mockResolvedValue(false) + 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 — raw './tools.json' appears in manifest (not copied → not resolved), + // and a diagnostic warning is logged so the user knows a file is missing. + 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'}}) + expect(mockStdout.write).toHaveBeenCalledWith( + expect.stringContaining("manifest entry 'admin.intent.link' contains unresolved paths"), + ) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining("path './tools.json' does not exist")) + }) + }) }) 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 e1a0f98fa0..313f58997e 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,14 @@ import {joinPath, dirname, extname, relativePath, basename} from '@shopify/cli-kit/node/path' -import {glob, copyFile, copyDirectoryContents, fileExists, mkdir, isDirectory} from '@shopify/cli-kit/node/fs' +import { + glob, + copyFile, + copyDirectoryContents, + fileExists, + mkdir, + writeFile, + isDirectory, +} from '@shopify/cli-kit/node/fs' + import {z} from 'zod' import type {LifecycleStep, BuildContext} from '../client-steps.js' @@ -42,12 +51,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]) @@ -56,11 +70,34 @@ const InclusionEntrySchema = z.discriminatedUnion('type', [PatternEntrySchema, S * Configuration schema for include_assets step. * * `inclusions` is a flat array of entries, each with a `type` discriminant - * (`'static'`, `'configKey'`, or `'pattern'`). All entries are processed in parallel. + * (`'static'`, `'configKey'`, or `'pattern'`). `configKey` entries run sequentially + * (to avoid filesystem race conditions on shared output paths), then `pattern` and + * `static` entries run 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), -}) +const IncludeAssetsConfigSchema = z + .object({ + inclusions: z.array(InclusionEntrySchema), + generateManifest: z.boolean().default(false), + }) + .superRefine((data, ctx) => { + for (const [i, entry] of data.inclusions.entries()) { + if (entry.type === 'configKey') { + const hasAnchor = entry.anchor !== undefined + const hasGroupBy = entry.groupBy !== undefined + if (hasAnchor !== hasGroupBy) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: '`anchor` and `groupBy` must both be set or both be omitted', + path: ['inclusions', i], + }) + } + } + } + }) /** * Removes any '..' traversal segments from a relative destination path and @@ -108,48 +145,79 @@ 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 counts = await Promise.all( - config.inclusions.map(async (entry) => { - const warn = (msg: string) => options.stdout.write(msg) - const sanitizedDest = entry.destination !== undefined ? sanitizeDestination(entry.destination, warn) : undefined - - if (entry.type === 'pattern') { - const sourceDir = entry.baseDir ? joinPath(extension.directory, entry.baseDir) : extension.directory - const destinationDir = sanitizedDest ? joinPath(outputDir, sanitizedDest) : outputDir - const result = await copyByPattern( - sourceDir, - destinationDir, - entry.include, - entry.ignore ?? [], - entry.preserveStructure, - options, - ) - return result.filesCopied - } + const aggregatedPathMap = new Map() + + // configKey entries are processed sequentially: copyConfigKeyEntry uses + // findUniqueDestPath which checks filesystem state before writing. Running two + // configKey entries in parallel against the same output directory can cause both + // to see the same candidate path as free and silently overwrite each other. + // It would also corrupt aggregatedPathMap with duplicate output paths. + // + // pattern and static entries do not use findUniqueDestPath and do not contribute + // to the pathMap, so they are safe to run in parallel. + let configKeyCount = 0 + for (const entry of config.inclusions) { + if (entry.type !== 'configKey') continue + const warn = (msg: string) => options.stdout.write(msg) + // sanitizeDestination can return "" when all segments were traversal (e.g. ".."). + // Treat that the same as no destination so callers can rely on a strict undefined check. + const rawDest = entry.destination !== undefined ? sanitizeDestination(entry.destination, warn) : undefined + const sanitizedDest = rawDest === '' ? undefined : rawDest + // eslint-disable-next-line no-await-in-loop + const result = await copyConfigKeyEntry( + entry.key, + extension.directory, + outputDir, + context, + options, + entry.preserveStructure, + sanitizedDest, + ) + result.pathMap.forEach((val, key) => aggregatedPathMap.set(key, val)) + configKeyCount += result.filesCopied + } - if (entry.type === 'configKey') { - return copyConfigKeyEntry( - entry.key, + const otherCounts = await Promise.all( + config.inclusions + .filter((entry) => entry.type !== 'configKey') + .map(async (entry) => { + const warn = (msg: string) => options.stdout.write(msg) + // sanitizeDestination can return "" when all segments were traversal (e.g. ".."). + // Treat that the same as no destination so callers can rely on a strict undefined check. + const rawDest = entry.destination !== undefined ? sanitizeDestination(entry.destination, warn) : undefined + const sanitizedDest = rawDest === '' ? undefined : rawDest + + if (entry.type === 'pattern') { + const sourceDir = entry.baseDir ? joinPath(extension.directory, entry.baseDir) : extension.directory + const destinationDir = sanitizedDest ? joinPath(outputDir, sanitizedDest) : outputDir + const result = await copyByPattern( + sourceDir, + destinationDir, + entry.include, + entry.ignore ?? [], + entry.preserveStructure, + options, + ) + return result.filesCopied + } + + return copySourceEntry( + entry.source, + sanitizedDest, extension.directory, outputDir, - context, options, entry.preserveStructure, - sanitizedDest, ) - } - - return copySourceEntry( - entry.source, - sanitizedDest, - extension.directory, - outputDir, - options, - entry.preserveStructure, - ) - }), + }), ) + const counts = [configKeyCount, ...otherCounts] + + if (config.generateManifest) { + await generateManifestFile(config, context, outputDir, aggregatedPathMap) + } + return {filesCopied: counts.reduce((sum, count) => sum + count, 0)} } @@ -158,7 +226,11 @@ export async function executeIncludeAssetsStep( * * - No `destination`, `preserveStructure` false: copy directory contents into the output root. * - No `destination`, `preserveStructure` true: copy the directory under its own name in the output. - * - With `destination`: copy the file to the explicit destination path (`preserveStructure` is ignored). + * - With `destination`, file source: copy the file to the explicit destination path. + * - With `destination`, directory source: copy directory contents into destination; returns + * actual file count (consistent with the no-destination directory branch). + * + * `preserveStructure` is ignored when `destination` is given. */ async function copySourceEntry( source: string, @@ -176,6 +248,12 @@ async function copySourceEntry( if (destination !== undefined) { const destPath = joinPath(outputDir, destination) + if (await isDirectory(sourcePath)) { + await copyDirectoryContents(sourcePath, destPath) + const copied = await glob(['**/*'], {cwd: destPath, absolute: false}) + options.stdout.write(`Copied ${source} to ${destination}\n`) + return copied.length + } await mkdir(dirname(destPath)) await copyFile(sourcePath, destPath) options.stdout.write(`Copied ${source} to ${destination}\n`) @@ -200,6 +278,31 @@ 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. + + const maxAttempts = 1000 + while (counter <= maxAttempts) { + const next = joinPath(dir, `${base}-${counter}${ext}`) + // eslint-disable-next-line no-await-in-loop + if (!(await fileExists(next))) return next + counter++ + } + throw new Error(`Unable to find unique destination path for '${filename}' in '${dir}' after ${maxAttempts} attempts`) +} + /** * Handles a `{configKey}` files entry. * @@ -207,6 +310,18 @@ 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 to its output-relative location. File sources map to a single string + * (e.g. `"tools.json"`, renamed to `"tools-1.json"` on collision). Directory + * sources map to a `string[]` of every output-relative file path (e.g. + * `["index.html", "style.css"]`). Only successfully copied paths appear in the + * map. */ async function copyConfigKeyEntry( key: string, @@ -216,7 +331,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') { @@ -229,37 +344,62 @@ 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 - } - if (!(await isDirectory(fullPath))) { - const destPath = joinPath(effectiveOutputDir, basename(fullPath)) - await mkdir(effectiveOutputDir) - await copyFile(fullPath, destPath) - options.stdout.write(`Copied '${sourcePath}' to ${basename(fullPath)}\n`) - return 1 - } - 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 sourceIsDir = await isDirectory(fullPath) + + // preserveStructure only applies to directories: nest the directory under + // its own name rather than merging its contents into effectiveOutputDir. + // For files, destDir is always effectiveOutputDir regardless. + const destDir = + sourceIsDir && preserveStructure ? joinPath(effectiveOutputDir, basename(fullPath)) : effectiveOutputDir + + if (sourceIsDir) { 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) + // Store each copied file as an output-relative path so the manifest + // value is a detailed file list rather than an opaque directory marker. + const relFiles = copied.map((file) => relativePath(outputDir, joinPath(destDir, file))) + pathMap.set(sourcePath, relFiles) + filesCopied += copied.length + } else { + 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 + } + } + /* eslint-enable no-await-in-loop */ + + return {filesCopied, pathMap} } /** @@ -305,14 +445,19 @@ async function copyByPattern( } } - // When flattening and collisions exist, deduplicate so last-in-array deterministically wins + // When flattening and collisions exist, deduplicate so last-in-array deterministically wins. + // Pre-build a last-index map in one O(n) pass to avoid an O(n²) reduce inside filter. + const lastIndexByName = new Map() + if (!preserveStructure && duplicates.size > 0) { + files.forEach((fp, ii) => { + if (duplicates.has(basename(fp))) lastIndexByName.set(basename(fp), ii) + }) + } const filesToCopy = !preserveStructure && duplicates.size > 0 ? files.filter((fp, idx) => { const name = basename(fp) - if (!duplicates.has(name)) return true - const lastIdx = files.reduce((last, file, ii) => (basename(file) === name ? ii : last), -1) - return lastIdx === idx + return !duplicates.has(name) || lastIndexByName.get(name) === idx }) : files @@ -339,6 +484,235 @@ async function copyByPattern( return {filesCopied: copiedCount} } +/** + * 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 shallowMerge(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. Only substitutes strings that look like file-system + * paths (start with `.`, contain `/`, or contain `\`) or that appear directly + * in the pathMap (which catches bare filenames like `tools.json` that lack a + * directory prefix). This avoids accidentally replacing non-path string fields + * that happen to share a value with a copied path entry. Strings not in the + * map are left unchanged. Walks objects and arrays recursively. + * + * When a pathMap value is a `string[]` (directory source), the string is + * replaced with the array — producing a file-list manifest entry. + */ +function resolveManifestPaths(value: unknown, pathMap: Map): unknown { + if (typeof value === 'string') { + const looksLikePath = value.startsWith('.') || value.includes('/') || value.includes('\\') || pathMap.has(value) + return (looksLikePath ? pathMap.get(value) : undefined) ?? 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 `configKey` inclusions into two categories (the schema guarantees + * no partial anchor/groupBy pair reaches this function): + * - `anchoredIncs`: both `anchor` and `groupBy` set → grouped entries. + * - `rootIncs`: neither set → 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` (anchor/groupBy logic), with all + * leaf path strings resolved via `resolveManifestPaths` using the copy-tracked + * `pathMap`. + * 5. Throw if `outputDir/manifest.json` already exists (copied by an earlier + * inclusion) to prevent silent overwrite, then write the manifest. + * + * @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 into anchored (both anchor+groupBy set) + // and root-level (neither set). The schema superRefine guarantees the partial + // case (exactly one of the pair) never reaches here. + type ConfigKeyEntry = z.infer + const configKeyInclusions = config.inclusions.filter((entry): entry is ConfigKeyEntry => entry.type === 'configKey') + + type AnchoredEntry = ConfigKeyEntry & {anchor: string; groupBy: string} + + const anchoredIncs: AnchoredEntry[] = [] + const rootIncs: ConfigKeyEntry[] = [] + + for (const entry of configKeyInclusions) { + if (typeof entry.anchor === 'string' && typeof entry.groupBy === 'string') { + anchoredIncs.push(entry as AnchoredEntry) + } else { + rootIncs.push(entry) + } + } + + if (anchoredIncs.length === 0 && rootIncs.length === 0) return + + // Step 2: build manifest + const manifest: {[key: string]: unknown} = {} + + // Step 3: root-level entries + for (const inc of rootIncs) { + 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} + }) + + // Warn when any string value — at any depth — still looks like a raw config + // path (./… or ../…), meaning the source file was skipped (not copied) and + // the manifest will contain an unresolved value rather than an output-relative path. + const hasUnresolved = partials.some(containsUnresolvedPath) + if (hasUnresolved) { + options.stdout.write( + `Warning: manifest entry '${manifestKey}' contains unresolved paths — source files may be missing\n`, + ) + } + + if (Object.prototype.hasOwnProperty.call(manifest, manifestKey)) { + options.stdout.write(`Warning: duplicate manifest key '${manifestKey}' — later entry overwrites earlier one\n`) + } + manifest[manifestKey] = shallowMerge(partials) + } + } + + // Step 5: write manifest.json — skip if no entries were produced (e.g. anchor + // resolved to a non-array) so consumers can treat file presence as a signal + // that meaningful content exists. + if (Object.keys(manifest).length === 0) { + options.stdout.write('Warning: no manifest entries produced — skipping manifest.json\n') + return + } + const manifestPath = joinPath(outputDir, 'manifest.json') + if (await fileExists(manifestPath)) { + throw new Error( + `Can't write manifest.json: a file already exists at '${manifestPath}'. ` + + `Remove or rename the conflicting inclusion to avoid overwriting the generated manifest.`, + ) + } + 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 @@ -351,6 +725,22 @@ async function copyByPattern( * "targeting.tools" → [name:"targeting",...], [name:"tools",...] * "extensions[].targeting[].schema" → [name:"extensions", flatten:true], ... */ +/** + * Returns `true` if `value` contains any string — at any depth — that looks + * like an unresolved config path (starts with `./` or `../`). Mirrors the + * recursive walk of `resolveManifestPaths` so nested structures produced by + * `buildRelativeEntry` (e.g. `{intents: [{schema: './missing.json'}]}`) are + * fully checked. + */ +function containsUnresolvedPath(value: unknown): boolean { + if (typeof value === 'string') return value.startsWith('./') || value.startsWith('../') + if (Array.isArray(value)) return value.some(containsUnresolvedPath) + if (value !== null && typeof value === 'object') { + return Object.values(value as {[key: string]: unknown}).some(containsUnresolvedPath) + } + return false +} + function tokenizePath(path: string): {name: string; flatten: boolean}[] { return path.split('.').map((part) => { const flatten = part.endsWith('[]')