Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/core/src/templates/create-project-from-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
31 changes: 27 additions & 4 deletions packages/core/src/templates/create-project-from-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>() : 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);
}
Expand Down Expand Up @@ -113,7 +125,12 @@ async function readTemplateManifest(templateDir: string): Promise<TemplateManife
}
}

async function replaceTemplateVariables(dir: string, projectName: string): Promise<void> {
async function replaceTemplateVariables(
dir: string,
projectName: string,
allowlist?: ReadonlySet<string>,
rootDir: string = dir
): Promise<void> {
const entries = await readdir(dir);

await Promise.all(
Expand All @@ -136,14 +153,20 @@ async function replaceTemplateVariables(dir: string, projectName: string): Promi
}

if (entryStat.isDirectory()) {
await replaceTemplateVariables(entryPath, projectName);
await replaceTemplateVariables(entryPath, projectName, allowlist, rootDir);
return;
}

if (!entryStat.isFile() || !isTextTemplateFile(entryPath)) {
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");
})
Expand Down