diff --git a/.changeset/violet-suits-ask.md b/.changeset/violet-suits-ask.md new file mode 100644 index 0000000..6726880 --- /dev/null +++ b/.changeset/violet-suits-ask.md @@ -0,0 +1,5 @@ +--- +"@webiny/data-transfer": patch +--- + +Fix preset discovery listing `.d.ts` declaration files as duplicate entries. Preset names and descriptions are now read from the module export instead of derived from filenames. Warns when a preset file fails to import. diff --git a/__tests__/commands/transfer/wizard/presetDiscovery.test.ts b/__tests__/commands/transfer/wizard/presetDiscovery.test.ts index 9363465..ff6d828 100644 --- a/__tests__/commands/transfer/wizard/presetDiscovery.test.ts +++ b/__tests__/commands/transfer/wizard/presetDiscovery.test.ts @@ -77,13 +77,12 @@ describe("listAvailablePresetsWithDescriptions", () => { expect(ddb?.description).toBeTruthy(); }); - it("returns empty description for a preset whose file cannot be imported", async () => { + it("skips a preset whose file cannot be imported", async () => { const tmp = mkdtempSync(join(tmpdir(), "presetdiscovery-broken-")); try { writeFileSync(join(tmp, "broken.js"), "this is not valid js export syntax %%%"); const entries = await listAvailablePresetsWithDescriptions(tmp); - const broken = entries.find(e => e.name === "broken"); - expect(broken?.description).toBe(""); + expect(entries.find(e => e.name === "broken")).toBeUndefined(); } finally { rmSync(tmp, { recursive: true }); } @@ -103,4 +102,19 @@ describe("listAvailablePresetsWithDescriptions", () => { rmSync(tmp, { recursive: true }); } }); + + it("uses preset.name from the module, not the filename", async () => { + const tmp = mkdtempSync(join(tmpdir(), "presetdiscovery-name-")); + try { + writeFileSync( + join(tmp, "my-file.js"), + "export default { name: 'custom-name', description: 'Custom preset' }" + ); + const entries = await listAvailablePresetsWithDescriptions(tmp); + expect(entries.find(e => e.name === "custom-name")?.description).toBe("Custom preset"); + expect(entries.find(e => e.name === "my-file")).toBeUndefined(); + } finally { + rmSync(tmp, { recursive: true }); + } + }); }); diff --git a/src/commands/transfer/wizard/presetDiscovery.ts b/src/commands/transfer/wizard/presetDiscovery.ts index 51ba6c5..2b57818 100644 --- a/src/commands/transfer/wizard/presetDiscovery.ts +++ b/src/commands/transfer/wizard/presetDiscovery.ts @@ -3,11 +3,6 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { existsSync, readdirSync } from "node:fs"; import { findPackageRoot } from "~/utils/findPackageRoot.js"; -// Presets are compiled/copied alongside everything else, so they land at -// "/presets" in the compiled (dist/) and published (npm) -// contexts, but stay nested under "src/" while running from source (tsx). -// Resolved lazily (not at module load) so importing this module doesn't -// require a real filesystem — tests that auto-mock "node:fs" still work. let cachedBuiltInPresetsDir: string | null = null; function getBuiltInPresetsDir(): string { @@ -28,7 +23,22 @@ export interface PresetEntry { description: string; } +function isPresetFile(filename: string): boolean { + if (filename.endsWith(".d.ts")) { + return false; + } + for (const ext of PRESET_EXTENSIONS) { + if (filename.endsWith(ext)) { + return true; + } + } + return false; +} + function stripExtension(filename: string): string | null { + if (filename.endsWith(".d.ts")) { + return null; + } for (const ext of PRESET_EXTENSIONS) { if (filename.endsWith(ext)) { return filename.slice(0, -ext.length); @@ -50,35 +60,36 @@ function scanDir(dir: string): string[] { } } -function resolvePresetPath(name: string, presetsDir?: string): string | null { - for (const ext of PRESET_EXTENSIONS) { - const builtIn = join(getBuiltInPresetsDir(), `${name}${ext}`); - if (existsSync(builtIn)) { - return builtIn; - } +function scanDirPaths(dir: string): string[] { + if (!existsSync(dir)) { + return []; } - if (presetsDir) { - for (const ext of PRESET_EXTENSIONS) { - const user = join(presetsDir, `${name}${ext}`); - if (existsSync(user)) { - return user; - } - } + try { + return readdirSync(dir) + .filter(isPresetFile) + .map(filename => join(dir, filename)); + } catch { + return []; } - return null; } -async function loadDescription(name: string, presetsDir?: string): Promise { - const filePath = resolvePresetPath(name, presetsDir); - if (!filePath) { - return ""; - } +async function loadPresetEntry(filePath: string): Promise { try { const mod = await import(pathToFileURL(filePath).href); const preset = mod.default ?? mod.preset; - return typeof preset?.description === "string" ? preset.description : ""; - } catch { - return ""; + if (!preset || typeof preset.name !== "string") { + console.warn(`Preset skipped: ${filePath} — no valid name export found.`); + return null; + } + return { + name: preset.name, + description: typeof preset.description === "string" ? preset.description : "" + }; + } catch (error) { + console.warn( + `Preset skipped: ${filePath} — failed to import: ${error instanceof Error ? error.message : String(error)}` + ); + return null; } } @@ -92,11 +103,20 @@ export function listAvailablePresets(presetsDir?: string): string[] { export async function listAvailablePresetsWithDescriptions( presetsDir?: string ): Promise { - const names = listAvailablePresets(presetsDir); - return Promise.all( - names.map(async name => ({ - name, - description: await loadDescription(name, presetsDir) - })) - ); + const builtInPaths = scanDirPaths(getBuiltInPresetsDir()); + const userPaths = presetsDir ? scanDirPaths(presetsDir) : []; + const allPaths = [...builtInPaths, ...userPaths]; + + const results = await Promise.all(allPaths.map(loadPresetEntry)); + + const seen = new Set(); + const entries: PresetEntry[] = []; + for (const entry of results) { + if (entry && !seen.has(entry.name)) { + seen.add(entry.name); + entries.push(entry); + } + } + + return entries.sort((a, b) => a.name.localeCompare(b.name)); } diff --git a/src/features/PresetLoader/PresetLoader.ts b/src/features/PresetLoader/PresetLoader.ts index dbd5d8b..44e7367 100644 --- a/src/features/PresetLoader/PresetLoader.ts +++ b/src/features/PresetLoader/PresetLoader.ts @@ -167,6 +167,9 @@ class PresetLoaderImpl implements PresetLoaderAbstraction.Interface { } private stripPresetExtension(filename: string): string | null { + if (filename.endsWith(".d.ts")) { + return null; + } for (const ext of PRESET_EXTENSIONS) { if (filename.endsWith(ext)) { return filename.slice(0, -ext.length);