Skip to content
Merged
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
6 changes: 4 additions & 2 deletions packages/cli/lib/commands/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 0 additions & 5 deletions packages/cli/templates/react/igr-ts/projects/base/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
5 changes: 0 additions & 5 deletions packages/cli/templates/react/igr-ts/projects/empty/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
6 changes: 4 additions & 2 deletions packages/core/prompt/BasePromptSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
17 changes: 11 additions & 6 deletions packages/core/util/Util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Comment thread
georgianastasov marked this conversation as resolved.
Resolved
Util.error(`${filePath} cannot overwrite its own template source!`, "red");
return resolve(false);
}
Comment thread
georgianastasov marked this conversation as resolved.
Resolved
Util.createDirectory(path.dirname(targetPath));
const writeStream = fs.createWriteStream(targetPath);
const isImage = imageExtensions.indexOf(path.extname(targetPath)) !== -1;
Expand Down Expand Up @@ -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)) {
Expand Down
7 changes: 2 additions & 5 deletions packages/core/util/ai-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
1 change: 1 addition & 0 deletions spec/acceptance/new-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion spec/templates/template-contracts-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Expand Down
33 changes: 33 additions & 0 deletions spec/unit/PromptSession-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"),
Expand Down
48 changes: 48 additions & 0 deletions spec/unit/Util-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
});
});
});
24 changes: 24 additions & 0 deletions spec/unit/ai-skills-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 23 additions & 3 deletions spec/unit/new-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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";

Expand Down Expand Up @@ -512,7 +533,6 @@ describe("Unit - New command", () => {
getFrameworkById: { id: "blazor" },
getProjectLibrary: mockProjLib
}));
spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true));
spyOn(Util, "gitInit");
}

Expand Down
Loading