diff --git a/packages/core/src/templates/create-project-from-template.test.ts b/packages/core/src/templates/create-project-from-template.test.ts index fe9d3166..e31b0be2 100644 --- a/packages/core/src/templates/create-project-from-template.test.ts +++ b/packages/core/src/templates/create-project-from-template.test.ts @@ -160,6 +160,49 @@ describe("createProjectFromTemplate", () => { expect(artifacts.networks.testnet.dependencyGraph).toEqual({}); }); + // #90: in merge mode, placeholder substitution must touch only the files the + // template copied — never a user's pre-existing files. + it("should_not_replace_placeholders_in_pre_existing_files_in_merge_mode", async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "caatinga-merge-")); + const templateDir = path.join(tmpDir, "template"); + const targetDir = path.join(tmpDir, "existing-app"); + await mkdir(templateDir); + await writeFile( + path.join(templateDir, "caatinga.template.json"), + JSON.stringify({ + name: "merge-template", + version: "0.1.0", + caatinga: { compatibleCore: "^3.0.0", templateVersion: 1 }, + frontend: { framework: "vite-react", packageManager: "npm" }, + contracts: { path: "contracts", default: "counter" }, + files: { config: "caatinga.config.ts", artifacts: "caatinga.artifacts.json" }, + }), + "utf8" + ); + await writeFile( + path.join(templateDir, "caatinga.config.ts"), + 'export default { project: "__PROJECT_NAME__" };\n', + "utf8" + ); + + // A pre-existing user file that is NOT part of the template but happens to + // contain the placeholder token. + await mkdir(targetDir, { recursive: true }); + await writeFile(path.join(targetDir, "README.md"), "# __PROJECT_NAME__ notes\n", "utf8"); + + await createProjectFromTemplate({ + projectName: "my-dapp", + targetDir, + templateDir, + filter: (relativePath) => relativePath === "caatinga.config.ts", + }); + + // The copied template file had its placeholder replaced … + expect(await readFile(path.join(targetDir, "caatinga.config.ts"), "utf8")).toContain("my-dapp"); + // … while the user's pre-existing file was left byte-for-byte untouched. + expect(await readFile(path.join(targetDir, "README.md"), "utf8")).toBe("# __PROJECT_NAME__ notes\n"); + }); + it("should_fail_when_template_manifest_is_missing", async () => { tmpDir = await mkdtemp(path.join(os.tmpdir(), "caatinga-init-")); const templateDir = path.join(tmpDir, "template"); diff --git a/packages/core/src/templates/create-project-from-template.ts b/packages/core/src/templates/create-project-from-template.ts index 9143254c..c261e989 100644 --- a/packages/core/src/templates/create-project-from-template.ts +++ b/packages/core/src/templates/create-project-from-template.ts @@ -38,15 +38,27 @@ export async function createProjectFromTemplate(options: CreateProjectFromTempla const manifest = await readTemplateManifest(templateDir); const mergeIntoExisting = Boolean(options.filter); + // #90: in merge mode, record exactly which relative paths the copy writes so + // variable substitution never touches a user's pre-existing files. + const copiedRelPaths = mergeIntoExisting ? new Set() : undefined; await mkdir(targetDir, { recursive: true }); await cp(templateDir, targetDir, { recursive: true, force: true, errorOnExist: false, - filter: (source) => shouldCopyTemplateEntry(templateDir, source, options.filter), + filter: (source) => { + const keep = shouldCopyTemplateEntry(templateDir, source, options.filter); + if (keep && copiedRelPaths) { + const rel = path.relative(templateDir, source); + if (rel) { + copiedRelPaths.add(rel); + } + } + return keep; + }, }); - await replaceTemplateVariables(targetDir, options.projectName); + await replaceTemplateVariables(targetDir, options.projectName, copiedRelPaths); if (!mergeIntoExisting) { await ensureArtifacts(targetDir, options.projectName); } @@ -113,7 +125,12 @@ async function readTemplateManifest(templateDir: string): Promise { +async function replaceTemplateVariables( + dir: string, + projectName: string, + allowlist?: ReadonlySet, + rootDir: string = dir +): Promise { const entries = await readdir(dir); await Promise.all( @@ -136,7 +153,7 @@ async function replaceTemplateVariables(dir: string, projectName: string): Promi } if (entryStat.isDirectory()) { - await replaceTemplateVariables(entryPath, projectName); + await replaceTemplateVariables(entryPath, projectName, allowlist, rootDir); return; } @@ -144,6 +161,12 @@ async function replaceTemplateVariables(dir: string, projectName: string): Promi return; } + // #90: in merge mode, only substitute in files the template actually + // copied — never in a user's pre-existing files. + if (allowlist && !allowlist.has(path.relative(rootDir, entryPath))) { + return; + } + const content = await readFile(entryPath, "utf8"); await writeFile(entryPath, content.replaceAll("__PROJECT_NAME__", projectName), "utf8"); })