diff --git a/packages/cli/lib/commands/new.ts b/packages/cli/lib/commands/new.ts index 5f68109f0f..b6f2a45720 100644 --- a/packages/cli/lib/commands/new.ts +++ b/packages/cli/lib/commands/new.ts @@ -211,8 +211,10 @@ const command: NewCommandType = { const config = projTemplate.generateConfig(argv.name, theme); for (const templatePath of projTemplate.templatePaths) { - await Util.processTemplates(templatePath, path.join(process.cwd(), argv.name), - config, projTemplate.delimiters, false); + if (!await Util.processTemplates(templatePath, path.join(process.cwd(), argv.name), + config, projTemplate.delimiters, false)) { + return Util.error("Project generation failed!", "red"); + } } process.chdir(argv.name); diff --git a/packages/cli/templates/react/igr-ts/projects/base/index.ts b/packages/cli/templates/react/igr-ts/projects/base/index.ts index 19a824700c..13e7c14c89 100644 --- a/packages/cli/templates/react/igr-ts/projects/base/index.ts +++ b/packages/cli/templates/react/igr-ts/projects/base/index.ts @@ -1,5 +1,4 @@ import { ProjectTemplate } from "@igniteui/cli-core"; -import * as path from "path"; import { BaseIgrTsProject } from "../_base"; export class BasePageIgrTsProject extends BaseIgrTsProject implements ProjectTemplate { @@ -11,9 +10,5 @@ export class BasePageIgrTsProject extends BaseIgrTsProject implements ProjectTem public projectType: string = "igr-ts"; public hasExtraConfiguration: boolean = false; public isHidden: boolean = true; - - public get templatePaths(): string[] { - return [...super.templatePaths, path.join(__dirname, "files")]; - } } export default new BasePageIgrTsProject(); diff --git a/packages/cli/templates/react/igr-ts/projects/empty/index.ts b/packages/cli/templates/react/igr-ts/projects/empty/index.ts index d8fac86a7f..2fc6422037 100644 --- a/packages/cli/templates/react/igr-ts/projects/empty/index.ts +++ b/packages/cli/templates/react/igr-ts/projects/empty/index.ts @@ -1,5 +1,4 @@ import { ProjectTemplate } from "@igniteui/cli-core"; -import * as path from "path"; import { BaseWithHomeIgrTsProject } from "../_base_with_home"; export class EmptyIgrTsProject extends BaseWithHomeIgrTsProject implements ProjectTemplate { @@ -11,9 +10,5 @@ export class EmptyIgrTsProject extends BaseWithHomeIgrTsProject implements Proje public projectType: string = "igr-ts"; public hasExtraConfiguration: boolean = false; public isHidden: boolean = false; - - public get templatePaths(): string[] { - return [...super.templatePaths, path.join(__dirname, "files")]; - } } export default new EmptyIgrTsProject(); diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/base/index.ts b/packages/cli/templates/webcomponents/igc-ts/projects/base/index.ts index 16adae1dc9..a6053e8ed5 100644 --- a/packages/cli/templates/webcomponents/igc-ts/projects/base/index.ts +++ b/packages/cli/templates/webcomponents/igc-ts/projects/base/index.ts @@ -1,5 +1,4 @@ import { ProjectTemplate } from "@igniteui/cli-core"; -import * as path from "path"; import { BaseIgcProject } from "../_base"; export class BasePageTemplate extends BaseIgcProject implements ProjectTemplate { @@ -11,9 +10,5 @@ export class BasePageTemplate extends BaseIgcProject implements ProjectTemplate public dependencies!: string[]; public hasExtraConfiguration: boolean = false; public isHidden: boolean = true; - - public get templatePaths(): string[] { - return [...super.templatePaths, path.join(__dirname, "files")]; - } } export default new BasePageTemplate(); diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/empty/index.ts b/packages/cli/templates/webcomponents/igc-ts/projects/empty/index.ts index 7fbf9230ae..3d723a64a1 100644 --- a/packages/cli/templates/webcomponents/igc-ts/projects/empty/index.ts +++ b/packages/cli/templates/webcomponents/igc-ts/projects/empty/index.ts @@ -1,5 +1,4 @@ import { ProjectTemplate } from "@igniteui/cli-core"; -import * as path from "path"; import { BaseWithHomeIgcProject } from "../_base_with_home"; export class EmptyPageTemplate extends BaseWithHomeIgcProject implements ProjectTemplate { @@ -11,9 +10,5 @@ export class EmptyPageTemplate extends BaseWithHomeIgcProject implements Project public dependencies!: string[]; public hasExtraConfiguration: boolean = false; public isHidden: boolean = false; - - public get templatePaths(): string[] { - return [...super.templatePaths, path.join(__dirname, "files")]; - } } export default new EmptyPageTemplate(); diff --git a/packages/core/prompt/BasePromptSession.ts b/packages/core/prompt/BasePromptSession.ts index c2743636a5..318c1db199 100644 --- a/packages/core/prompt/BasePromptSession.ts +++ b/packages/core/prompt/BasePromptSession.ts @@ -90,8 +90,10 @@ export abstract class BasePromptSession { Util.log(" Generating project structure."); const config = projTemplate.generateConfig(projectName, theme); for (const templatePath of projTemplate.templatePaths) { - await Util.processTemplates(templatePath, path.join(process.cwd(), projectName), - config, projTemplate.delimiters, false); + if (!await Util.processTemplates(templatePath, path.join(process.cwd(), projectName), + config, projTemplate.delimiters, false)) { + return Util.error("Project generation failed!", "red"); + } } Util.log(Util.greenCheck() + " Project structure generated."); diff --git a/packages/core/util/Util.ts b/packages/core/util/Util.ts index 555efc00b1..a146832cb0 100644 --- a/packages/core/util/Util.ts +++ b/packages/core/util/Util.ts @@ -79,17 +79,22 @@ export class Util { } return new Promise((resolve, reject) => { - const filePaths: string[] = glob.sync(sourcePath + "/**/*", { nodir: true }) + const filePaths: string[] = glob.sync("**/*", { cwd: sourcePath, nodir: true }) .map(filePath => filePath.replace(/\\/g, "/")); let fileCount = filePaths.length; // if no files should be created, resolve if (fileCount === 0) { resolve(false); } - for (const filePath of filePaths) { - let targetPath = filePath.replace(sourcePath, destinationPath); + for (const relativePath of filePaths) { + const filePath = path.join(sourcePath, relativePath); + let targetPath = path.join(destinationPath, relativePath); targetPath = Util.applyConfigTransformation(targetPath, Util.applyDelimiters(configuration, delimiters.path || defaultDelimiters.path)); + if (!path.relative(filePath, targetPath)) { + Util.error(`${filePath} cannot overwrite its own template source!`, "red"); + return resolve(false); + } Util.createDirectory(path.dirname(targetPath)); const writeStream = fs.createWriteStream(targetPath); const isImage = imageExtensions.indexOf(path.extname(targetPath)) !== -1; @@ -600,15 +605,15 @@ export class Util { sourcePath = sourcePath.replace(/\\/g, "/"); destinationPath = destinationPath.replace(/\\/g, "/"); - let paths: string[] = glob.sync(sourcePath + "/**/*", { nodir: true }) + let paths: string[] = glob.sync("**/*", { cwd: sourcePath, nodir: true }) .map(filePath => filePath.replace(/\\/g, "/")); // TODO: D.P Temporary ignoring asset files - const ignorePaths: string[] = glob.sync(sourcePath + "/**/+(assets|data)/**/*", { nodir: true }) + const ignorePaths: string[] = glob.sync("**/+(assets|data)/**/*", { cwd: sourcePath, nodir: true }) .map(filePath => filePath.replace(/\\/g, "/")); paths = paths.filter(x => ignorePaths.indexOf(x) === -1); for (let filePath of paths) { - filePath = filePath.replace(sourcePath, destinationPath); + filePath = path.join(destinationPath, filePath); filePath = Util.applyConfigTransformation(filePath, Util.applyDelimiters(configuration, delimiters.path || defaultDelimiters.path)); if (fs.existsSync(filePath)) { diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index b9afde8fc1..1cc6461bf8 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -172,11 +172,8 @@ export function copyAISkillsToProject(agents: AIAgentTarget[], framework: string for (const p of rawPaths) { result.found++; - // Normalize to posix and strip leading '/' so path.posix.relative works - // across both FsFileSystem (relative paths) and NgTreeFileSystem (tree-rooted paths) - const normP = p.replace(/\\/g, "/").replace(/^\//, ""); - const normRoot = skillsRoot.replace(/\\/g, "/").replace(/^\//, ""); - const rel = path.posix.relative(normRoot, normP); + // native path.relative: case-insensitive on Windows, where glob upper-cases the drive letter + const rel = path.relative(skillsRoot, p).replace(/\\/g, "/"); const dest = multiRoot ? `${outputDir}/${pkgDirName}/${rel}` : `${outputDir}/${rel}`; diff --git a/spec/acceptance/new-spec.ts b/spec/acceptance/new-spec.ts index 5c1b27df56..8ee48eddb6 100644 --- a/spec/acceptance/new-spec.ts +++ b/spec/acceptance/new-spec.ts @@ -62,6 +62,7 @@ describe("New command", () => { await cli.run(["new", "React Proj", "--framework=react", "--skip-install"]); + expect(console.error).not.toHaveBeenCalled(); expect(fs.existsSync("./React Proj")).toBeTruthy(); expect(filesDiff("../templates/react/igr-ts/projects/empty/files", "./React Proj")).toEqual([]); const packageText = fs.readFileSync("./React Proj/package.json", "utf-8"); diff --git a/spec/templates/template-contracts-spec.ts b/spec/templates/template-contracts-spec.ts index a7ebff0b6c..d59f075d2d 100644 --- a/spec/templates/template-contracts-spec.ts +++ b/spec/templates/template-contracts-spec.ts @@ -31,7 +31,7 @@ describe("Template contracts", () => { } // scaffold-based projects (blazor) generate files externally if (!project.scaffold) { - expect(project.templatePaths.some(p => fs.existsSync(p))) + expect(project.templatePaths.every(p => fs.existsSync(p))) .withContext(`project ${project.id} paths ${project.templatePaths}`).toBeTrue(); } } diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index d59c08c43a..bec2238971 100644 --- a/spec/unit/PromptSession-spec.ts +++ b/spec/unit/PromptSession-spec.ts @@ -207,6 +207,38 @@ describe("Unit - PromptSession", () => { expect(mockTemplate.getFrameworkByName).toHaveBeenCalledTimes(1); expect(aiConfig.configure).toHaveBeenCalledTimes(1); }); + it("start - Should stop and not report success when template processing fails", async () => { + spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { isShowcase: true } } as unknown as Config); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); + const mockProjectLibrary = { + themes: ["infragistics"], + projects: [{ name: "Project 1", generateConfig: () => ({}), templatePaths: ["first", "second"] }], + getProject: jasmine.createSpy().and.returnValue(undefined) + }; + const mockFramework = { id: "jquery", name: "Custom Framework 1", projectLibraries: ["jQuery"] }; + App.container.set(TEMPLATE_MANAGER, jasmine.createSpyObj("mockTemplate", { + getFrameworkByName: mockFramework, + getFrameworkNames: [mockFramework.name], + getProjectLibraryNames: mockFramework.projectLibraries, + getProjectLibraryByName: mockProjectLibrary + })); + const mockSession = new PromptSession(); + spyOn(Util, "log"); + spyOn(Util, "error"); + spyOn(Util, "gitInit"); + spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(false)); + spyOn(Util, "getAvailableName").and.returnValue("IG Project"); + spyOn(InquirerWrapper, "input").and.returnValue(Promise.resolve("Test Project")); + spyOn(process, "chdir"); + spyOn(mockSession, "chooseActionLoop"); + + await mockSession.start(); + + expect(Util.processTemplates).toHaveBeenCalledTimes(1); + expect(Util.error).toHaveBeenCalledWith("Project generation failed!", "red"); + expect(Util.log).not.toHaveBeenCalledWith(jasmine.stringMatching("Project structure generated")); + expect(mockSession.chooseActionLoop).not.toHaveBeenCalled(); + }); it("start - Should go into chooseActionLoop if project has local config", async () => { const mockBaseTemplate = createMockBaseTemplate(); const mockProjectTemplate = createMockProjectTemplate(mockBaseTemplate); @@ -374,6 +406,7 @@ describe("Unit - PromptSession", () => { spyOn(Util, "isAlphanumericExt").and.callThrough(); spyOn(Util, "gitInit"); spyOn(Util, "error"); + spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); spyOn(InquirerWrapper, "input").and.returnValue(Promise.resolve("Dummy name")); spyOn(InquirerWrapper, "select").and.returnValues( Promise.resolve("Custom Framework 1"), diff --git a/spec/unit/Util-spec.ts b/spec/unit/Util-spec.ts index 4bbeff61a9..b292406855 100644 --- a/spec/unit/Util-spec.ts +++ b/spec/unit/Util-spec.ts @@ -2,6 +2,7 @@ import { Template, Util } from "@igniteui/cli-core"; // default imports give the module objects themselves, so their functions can be spied on import child_process from "child_process"; import fs from "fs"; +import os from "os"; import * as path from "path"; import { BaseComponent } from "../../packages/core/templates/BaseComponent"; @@ -381,4 +382,51 @@ describe("Unit - Util", () => { expect(choices).toEqual([{ name: "Plain", short: "Plain", value: "Plain" }]); }); }); + + describe("processTemplates", () => { + const originalCwd = process.cwd(); + let tmp: string; + + beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ig-templates-"))); + process.chdir(tmp); + spyOn(Util, "error"); + }); + + afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("processes sources with glob characters and never overwrites the source", async () => { + const source = path.join(tmp, "src [1]"); + const output = path.join(tmp, "out"); + fs.mkdirSync(source); + fs.writeFileSync(path.join(source, "__name__.txt"), "$(name)"); + + expect(await Util.processTemplates(source, output, { name: "file" }, {})).toBeTrue(); + expect(fs.readFileSync(path.join(output, "file.txt"), "utf8")).toBe("file"); + expect(await Util.processTemplates(source, output, { name: "file" }, {})).toBeFalse(); + expect(await Util.processTemplates(source, source, {}, {}, false)).toBeFalse(); + expect(fs.readFileSync(path.join(source, "__name__.txt"), "utf8")).toBe("$(name)"); + }); + + it("does not truncate the source when its drive letter case differs from glob output", async () => { + if (process.platform !== "win32") { + pending("Windows drive letter casing"); + } + const source = path.join(tmp, "src"); + fs.mkdirSync(source); + fs.writeFileSync(path.join(source, "example.txt"), "$(name)"); + const lowerDriveSource = source[0].toLowerCase() + source.slice(1); + + for (const validate of [true, false]) { + const output = path.join(tmp, `out-${validate}`); + expect(await Util.processTemplates(lowerDriveSource, output, { name: "file" }, {}, validate)).toBeTrue(); + expect(fs.readFileSync(path.join(source, "example.txt"), "utf8")).toBe("$(name)"); + expect(fs.readFileSync(path.join(output, "example.txt"), "utf8")).toBe("file"); + } + expect(Util.error).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/unit/ai-skills-spec.ts b/spec/unit/ai-skills-spec.ts index 1b17377c68..8902f180e4 100644 --- a/spec/unit/ai-skills-spec.ts +++ b/spec/unit/ai-skills-spec.ts @@ -457,6 +457,30 @@ describe("Unit - copyAISkillsToProject", () => { expect(mockTm.getFrameworkById).not.toHaveBeenCalledWith("angular"); }); + it("should copy template skills when the drive letter case differs from glob output", () => { + if (process.platform !== "win32") { + pending("Windows drive letter casing"); + } + const content = "# skill"; + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === "c:\\tpl\\files\\skills" + ? ["C:\\tpl\\files\\skills\\README.md", "C:\\tpl\\files\\skills\\grids\\SKILL.md"] + : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); + mockTemplateManager(["c:\\tpl\\files"]); + + const result = copyAISkillsToProject(["claude"], "webcomponents"); + + expect(result.failed).toBe(0); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/README.md", content); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/grids/SKILL.md", content); + }); + it("should read skill sources from real disk FS even when destFs is virtual", () => { // Simulates the schematics scenario: srcFs (FsFileSystem) reads from disk, // destFs (NgTreeFileSystem) writes into the virtual Tree. diff --git a/spec/unit/new-spec.ts b/spec/unit/new-spec.ts index 661e765e8b..9afee86fab 100644 --- a/spec/unit/new-spec.ts +++ b/spec/unit/new-spec.ts @@ -38,6 +38,7 @@ describe("Unit - New command", () => { spyOn(PackageManager, "installPackages"); spyOn(aiConfig, "configure").and.returnValue(Promise.resolve({ agents: [], assistants: [] })); spyOn(Util, "directoryExists").and.returnValue(false); + spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); }); afterEach(() => { @@ -203,7 +204,6 @@ describe("Unit - New command", () => { //spyOn(newCmd.template, "getProjectLibrary").and.returnValue(mockProjLib); spyOn(process, "cwd").and.returnValue("Mock dir"); - spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); spyOn(Util, "fileExists").and.returnValue(false); const mockFileSystem = { @@ -249,7 +249,6 @@ describe("Unit - New command", () => { App.container.set(TEMPLATE_MANAGER, mockTemplateManager); spyOn(process, "cwd").and.returnValue("Mock dir"); - spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); spyOn(Util, "fileExists").and.returnValue(false); const mockFileSystem = { @@ -272,6 +271,28 @@ describe("Unit - New command", () => { expect(Util.log).toHaveBeenCalledWith(jasmine.stringMatching("Project Created")); }); + it("Should stop and not report success when template processing fails", async () => { + const mockTemplate = { + delimiters: {}, + generateConfig: () => ({}), + templatePaths: ["first", "second", "third"] + }; + App.container.set(TEMPLATE_MANAGER, jasmine.createSpyObj("TemplateManager", { + getFrameworkById: {}, + getProjectLibrary: { getProject: () => mockTemplate, projectIds: ["empty"], projectType: "type", themes: ["ig"] } + })); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); + spyOn(Util, "error"); + (Util.processTemplates as jasmine.Spy).and.returnValues(Promise.resolve(true), Promise.resolve(false)); + + await newCmd.handler({ name: "Test", framework: "jq", _: ["new"], $0: "new" }); + + expect(Util.processTemplates).toHaveBeenCalledTimes(2); + expect(Util.error).toHaveBeenCalledWith("Project generation failed!", "red"); + expect(Util.log).not.toHaveBeenCalledWith(jasmine.stringMatching("Project Created")); + expect(aiConfig.configure).not.toHaveBeenCalled(); + }); + it("Git initialization", async () => { const projectName = "projTitle"; @@ -512,7 +533,6 @@ describe("Unit - New command", () => { getFrameworkById: { id: "blazor" }, getProjectLibrary: mockProjLib })); - spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); spyOn(Util, "gitInit"); }