From 6fd6046f7ef064e425740da82611b6a16b2f27cc Mon Sep 17 00:00:00 2001 From: brunozoric Date: Tue, 8 Sep 2026 13:45:02 +0200 Subject: [PATCH 1/4] fix(presets): skip .d.ts declaration files in preset discovery stripExtension now returns null for .d.ts files so declaration files like copy-ddb.d.ts don't appear as duplicate preset entries in the wizard menu. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/transfer/wizard/presetDiscovery.ts | 3 +++ src/features/PresetLoader/PresetLoader.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/commands/transfer/wizard/presetDiscovery.ts b/src/commands/transfer/wizard/presetDiscovery.ts index 51ba6c5..c4d144c 100644 --- a/src/commands/transfer/wizard/presetDiscovery.ts +++ b/src/commands/transfer/wizard/presetDiscovery.ts @@ -29,6 +29,9 @@ export interface PresetEntry { } 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); 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); From 811c457850845a36a38e33510a9ca42aa493d679 Mon Sep 17 00:00:00 2001 From: brunozoric Date: Tue, 8 Sep 2026 13:51:20 +0200 Subject: [PATCH 2/4] refactor(presets): load preset name and description from module, not filename listAvailablePresetsWithDescriptions now dynamically imports all preset files in parallel and reads name + description from the module export. Broken or nameless files are silently skipped. Deduplication uses the module's name field. listAvailablePresets (used by PresetLoader) stays filename-based for the resolution path. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../transfer/wizard/presetDiscovery.test.ts | 20 ++++- .../transfer/wizard/presetDiscovery.ts | 79 +++++++++++-------- 2 files changed, 63 insertions(+), 36 deletions(-) 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 c4d144c..872ecfe 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,6 +23,18 @@ 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; @@ -53,35 +60,32 @@ 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 : ""; + if (!preset || typeof preset.name !== "string") { + return null; + } + return { + name: preset.name, + description: typeof preset.description === "string" ? preset.description : "" + }; } catch { - return ""; + return null; } } @@ -95,11 +99,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)); } From 1f1aded25249f5df3ac53fa93c86a2062d6c7095 Mon Sep 17 00:00:00 2001 From: brunozoric Date: Tue, 8 Sep 2026 14:02:34 +0200 Subject: [PATCH 3/4] fix(presets): log warning when a preset file fails to import Helps debug typos and broken exports in user preset files instead of silently skipping them. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/transfer/wizard/presetDiscovery.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/transfer/wizard/presetDiscovery.ts b/src/commands/transfer/wizard/presetDiscovery.ts index 872ecfe..2b57818 100644 --- a/src/commands/transfer/wizard/presetDiscovery.ts +++ b/src/commands/transfer/wizard/presetDiscovery.ts @@ -78,13 +78,17 @@ async function loadPresetEntry(filePath: string): Promise { const mod = await import(pathToFileURL(filePath).href); const preset = mod.default ?? mod.preset; 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 { + } catch (error) { + console.warn( + `Preset skipped: ${filePath} — failed to import: ${error instanceof Error ? error.message : String(error)}` + ); return null; } } From a74e432d406e7d02177b428e6f3099168ed97591 Mon Sep 17 00:00:00 2001 From: brunozoric Date: Tue, 8 Sep 2026 14:11:10 +0200 Subject: [PATCH 4/4] chore: add changeset for preset discovery fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/violet-suits-ask.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/violet-suits-ask.md 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.