diff --git a/packages/cli/lib/commands/new.ts b/packages/cli/lib/commands/new.ts index 852ed0ce2..8bab48c2e 100644 --- a/packages/cli/lib/commands/new.ts +++ b/packages/cli/lib/commands/new.ts @@ -45,6 +45,16 @@ const command: NewCommandType = { describe: "Project theme (depends on project type)", type: "string" }) + .option("hosting", { + describe: "Blazor hosting model (Blazor projects only)", + type: "string", + choices: ["Server", "Wasm", "Auto"] + }) + .option("variant", { + describe: "Theme variant (Blazor projects only)", + type: "string", + choices: ["light", "dark"] + }) .option("skip-git", { alias: "sg", describe: "Do not initialize a git repository for the project", @@ -155,6 +165,46 @@ const command: NewCommandType = { cd14: theme }); + if (typeof projTemplate.scaffold === "function") { + if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(argv.name)) { + Util.warn(`The project namespace will be derived from the name '${argv.name}'. ` + + "Use only letters, digits and dashes for a clean identifier.", "yellow"); + } + + const extraConfig: { [key: string]: any } = {}; + if (argv.hosting) { + extraConfig.Hosting = argv.hosting; + } + if (argv.variant) { + extraConfig.Variant = argv.variant; + } + + const success = await projTemplate.scaffold({ + name: argv.name, + theme, + skipInstall: !!argv.skipInstall, + skipGit: !!argv.skipGit, + extraConfig + }); + if (!success) { + return; + } + + process.chdir(argv.name); + await configure(argv.framework, argv.agents as (AIAgentTarget | "none")[], argv.assistants as (AiCodingAssistant | "none")[]); + process.chdir(".."); + + if (!argv["skip-git"] && !ProjectConfig.getConfig().skipGit) { + Util.gitInit(process.cwd(), argv.name); + } + + Util.log(""); + Util.log("Next Steps:"); + Util.log(` cd ${argv.name}`); + Util.log(` dotnet run --project ${argv.name}`); + return; + } + const config = projTemplate.generateConfig(argv.name, theme); for (const templatePath of projTemplate.templatePaths) { await Util.processTemplates(templatePath, path.join(process.cwd(), argv.name), diff --git a/packages/cli/lib/commands/types.ts b/packages/cli/lib/commands/types.ts index 5a1de0736..d9ae96589 100644 --- a/packages/cli/lib/commands/types.ts +++ b/packages/cli/lib/commands/types.ts @@ -14,6 +14,12 @@ export interface PositionalArgs { /** Which theme to use when creating a new project. */ theme?: string; + /** Blazor hosting model (Blazor projects only). */ + hosting?: string; + + /** Theme variant (Blazor projects only). */ + variant?: string; + template?: string; module?: string; diff --git a/packages/cli/templates/blazor/igb/index.ts b/packages/cli/templates/blazor/igb/index.ts index ec543b04a..0f0394e5b 100644 --- a/packages/cli/templates/blazor/igb/index.ts +++ b/packages/cli/templates/blazor/igb/index.ts @@ -5,7 +5,7 @@ class IgbBlazorProjectLibrary extends BaseProjectLibrary { super(__dirname); this.name = "Ignite UI for Blazor"; this.projectType = "igb"; - this.themes = ["default"]; + this.themes = ["bootstrap", "material", "fluent", "indigo"]; } } module.exports = new IgbBlazorProjectLibrary(); diff --git a/packages/cli/templates/blazor/igb/projects/empty/index.ts b/packages/cli/templates/blazor/igb/projects/empty/index.ts new file mode 100644 index 000000000..db22c9692 --- /dev/null +++ b/packages/cli/templates/blazor/igb/projects/empty/index.ts @@ -0,0 +1,70 @@ +import { + ControlExtraConfiguration, ControlExtraConfigType, defaultDelimiters, + DotnetTemplateManager, ProjectTemplate, ScaffoldOptions +} from "@igniteui/cli-core"; + +export class EmptyIgbProject implements ProjectTemplate { + + public id: string = "empty"; + public name = "Blazor Web App"; + public description = "Ignite UI for Blazor Web App, scaffolded via the IgniteUI.Blazor.Templates package"; + public framework: string = "blazor"; + public projectType: string = "igb"; + public dependencies: string[] = []; + public hasExtraConfiguration: boolean = true; + public isHidden: boolean = false; + public delimiters = defaultDelimiters; + public templatePaths: string[] = []; + + private extraConfiguration: { [key: string]: any } = {}; + + public getExtraConfiguration(): ControlExtraConfiguration[] { + return [ + { + choices: ["Server", "Wasm", "Auto"], + default: "Server", + key: "Hosting", + message: "Choose the hosting model:", + type: ControlExtraConfigType.Choice + }, + { + choices: ["light", "dark"], + default: "light", + key: "Variant", + message: "Choose the theme variant:", + type: ControlExtraConfigType.Choice + } + ]; + } + + public setExtraConfiguration(extraConfigKeys: {} | any[]) { + if (Array.isArray(extraConfigKeys)) { + // the wizard supplies answers positionally, matching getExtraConfiguration() order + const keys = this.getExtraConfiguration().map(c => c.key); + extraConfigKeys.forEach((value, i) => { + if (keys[i] !== undefined) { + this.extraConfiguration[keys[i]] = value; + } + }); + } else { + this.extraConfiguration = { ...this.extraConfiguration, ...extraConfigKeys }; + } + } + + public scaffold(options: ScaffoldOptions): Promise { + const extraConfig = { ...this.extraConfiguration, ...options.extraConfig }; + return Promise.resolve(DotnetTemplateManager.scaffold({ ...options, extraConfig })); + } + + // Unused — the host calls scaffold() instead of the generateConfig pipeline when scaffold exists. + public installModules(): void { + throw new Error("Method not implemented."); + } + public upgradeIgniteUIPackages(_projectPath: string, _packagePath: string): Promise { + throw new Error("Method not implemented."); + } + public generateConfig(_name: string, _theme: string, ..._options: any[]): { [key: string]: any; } { + throw new Error("Method not implemented."); + } +} +export default new EmptyIgbProject(); diff --git a/packages/cli/templates/blazor/index.ts b/packages/cli/templates/blazor/index.ts index 352054bdc..c69a04ece 100644 --- a/packages/cli/templates/blazor/index.ts +++ b/packages/cli/templates/blazor/index.ts @@ -4,7 +4,7 @@ class BlazorFramework implements Framework { public id: string; public name: string; public projectLibraries: ProjectLibrary[]; - public hidden = true; + public hidden = false; constructor() { this.id = "blazor"; diff --git a/packages/core/prompt/BasePromptSession.ts b/packages/core/prompt/BasePromptSession.ts index e1b949cb5..1bd57d486 100644 --- a/packages/core/prompt/BasePromptSession.ts +++ b/packages/core/prompt/BasePromptSession.ts @@ -2,7 +2,7 @@ import * as path from "path"; import { Separator } from "@inquirer/prompts"; import { BaseTemplateManager } from "../templates"; import { - Component, Config, ControlExtraConfigType, ControlExtraConfiguration, Framework, + BaseTemplate, Component, Config, ControlExtraConfigType, ControlExtraConfiguration, Framework, FrameworkId, ProjectLibrary, ProjectTemplate, Template } from "../types"; import { App, ChoiceItem, GoogleAnalytics, ProjectConfig, Util } from "../util"; @@ -60,20 +60,44 @@ export abstract class BasePromptSession { // project options: theme = await this.getTheme(projLibrary); - 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 (projTemplate.hasExtraConfiguration) { + await this.customizeTemplateTask(projTemplate); } - Util.log(Util.greenCheck() + " Project structure generated."); - if (!this.config.skipGit) { - Util.gitInit(process.cwd(), projectName); + if (typeof projTemplate.scaffold === "function") { + Util.log(" Generating project structure."); + const success = await projTemplate.scaffold({ + name: projectName, + theme, + skipInstall: false, + skipGit: this.config.skipGit + }); + if (!success) { + return; + } + // the scaffold service never touches git, so initialize git here when requested + if (!this.config.skipGit) { + Util.gitInit(process.cwd(), projectName); + } + // move cwd to project folder + process.chdir(projectName); + await this.configureAI(framework.id); + } else { + 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); + } + + Util.log(Util.greenCheck() + " Project structure generated."); + if (!this.config.skipGit) { + Util.gitInit(process.cwd(), projectName); + } + // move cwd to project folder + process.chdir(projectName); + await this.configureAI(framework.id); } - // move cwd to project folder - process.chdir(projectName); - await this.configureAI(framework.id); } await this.chooseActionLoop(projLibrary); //TODO: restore cwd? @@ -279,7 +303,7 @@ export abstract class BasePromptSession { } /** Create prompts from template extra configuration and assign user answers to the template */ - protected async customizeTemplateTask(template: Template) { + protected async customizeTemplateTask(template: BaseTemplate) { const extraPrompt = this.createQuestions(template.getExtraConfiguration()); const extraConfigAnswers = []; for (const question of extraPrompt) { @@ -405,6 +429,26 @@ export abstract class BasePromptSession { case "Complete & Run": const config = ProjectConfig.localConfig(); + if (!config.project) { + // Blazor (scaffolded via dotnet) has no cli-config — print next-steps instead of + // routing through completeAndRun (npm + start.start, which requires a cli-config). + const projectName = path.basename(process.cwd()); + if (Util.canPrompt() && await InquirerWrapper.confirm({ + message: "Run the app now (dotnet run)?", + default: false + })) { + const result = Util.spawnSync("dotnet", ["run", "--project", projectName], { stdio: "inherit" }); + if (result.error || result.status !== 0) { + Util.error("dotnet run failed (see dotnet output above).", "red"); + } + } else { + Util.log(""); + Util.log("Next Steps:"); + Util.log(` dotnet run --project ${projectName}`); + } + break; + } + if (config.project.framework === "angular" && config.project.projectType === "igx-ts" && !config.packagesInstalled) { diff --git a/packages/core/templates/BaseTemplateManager.ts b/packages/core/templates/BaseTemplateManager.ts index fe8e5a66b..91398a899 100644 --- a/packages/core/templates/BaseTemplateManager.ts +++ b/packages/core/templates/BaseTemplateManager.ts @@ -24,7 +24,7 @@ export abstract class BaseTemplateManager { return this.frameworks.filter(f => includeHidden || !f.hidden).map(f => f.id); } public getFrameworkNames(includeHidden = false): string[] { - // exclude WebComponents from the Step-By-Step wizard + // hidden frameworks are excluded from the Step-By-Step wizard unless includeHidden is set return this.frameworks.filter(f => includeHidden || !f.hidden).map(f => f.name); } /** Returns framework found by its name or undefined. */ diff --git a/packages/core/types/ProjectTemplate.ts b/packages/core/types/ProjectTemplate.ts index 3e8555671..8c6c935f4 100644 --- a/packages/core/types/ProjectTemplate.ts +++ b/packages/core/types/ProjectTemplate.ts @@ -1,5 +1,19 @@ import { BaseTemplate } from "./BaseTemplate"; +/** Options passed to a project template's `scaffold` method. */ +export interface ScaffoldOptions { + /** Project name (already validated alphanumeric-ext by the host). */ + name: string; + /** Theme to apply, one of the library's themes (e.g. bootstrap|material|fluent|indigo). */ + theme: string; + /** Skip restoring/installing packages after scaffolding. */ + skipInstall?: boolean; + /** Skip git initialization. */ + skipGit?: boolean; + /** Additional template-specific configuration (e.g. { Hosting, Variant }). */ + extraConfig?: { [key: string]: any }; +} + /** Interface for project templates */ export interface ProjectTemplate extends BaseTemplate { /** This method should be called after generateConfig completes. */ @@ -15,4 +29,12 @@ export interface ProjectTemplate extends BaseTemplate { /** Generates template files. */ generateConfig(name: string, theme: string, ...options: any[]): {[key: string]: any}; + + /** + * Optional alternative scaffolding strategy. When implemented, the host calls this + * instead of the generateConfig → processTemplates → installPackages pipeline. + * @param options Scaffold options + * @returns true on success, false on failure. + */ + scaffold?(options: ScaffoldOptions): Promise; } diff --git a/packages/core/util/DotnetTemplateManager.ts b/packages/core/util/DotnetTemplateManager.ts new file mode 100644 index 000000000..d070d2994 --- /dev/null +++ b/packages/core/util/DotnetTemplateManager.ts @@ -0,0 +1,87 @@ +import { ScaffoldOptions } from "../types/ProjectTemplate"; +import { Util } from "./Util"; + +const TEMPLATE_PACKAGE = "IgniteUI.Blazor.Templates"; +const TEMPLATE_SHORT_NAME = "igb-blazor"; +const MIN_SDK_MAJOR = 8; + +/** + * Thin wrapper around the `dotnet` CLI used to scaffold Blazor projects from the + * published `IgniteUI.Blazor.Templates` NuGet template package. + */ +export class DotnetTemplateManager { + + /** Returns the major version of the installed .NET SDK, or null if dotnet is unavailable. */ + public static getSdkMajorVersion(): number | null { + const result = Util.spawnSync("dotnet", ["--version"], { encoding: "utf8" }); + if (result.error || result.status !== 0 || !result.stdout) { + return null; + } + const major = parseInt(result.stdout.toString().trim().split(".")[0], 10); + return isNaN(major) ? null : major; + } + + /** Checks whether the IgniteUI.Blazor.Templates package is already installed. */ + public static isTemplateInstalled(): boolean { + const result = Util.spawnSync("dotnet", ["new", "list", TEMPLATE_SHORT_NAME], { encoding: "utf8" }); + return result.status === 0 && !!result.stdout && result.stdout.toString().includes(TEMPLATE_SHORT_NAME); + } + + /** Installs the latest IgniteUI.Blazor.Templates package. Returns false on failure. */ + public static installTemplate(): boolean { + Util.log(`Installing ${TEMPLATE_PACKAGE}…`); + const result = Util.spawnSync("dotnet", ["new", "install", TEMPLATE_PACKAGE], { stdio: "inherit" }); + if (result.error || result.status !== 0) { + Util.error(`Failed to install the ${TEMPLATE_PACKAGE} package. ` + + "Check your network/NuGet feed and try again.", "red"); + return false; + } + return true; + } + + /** + * Scaffolds a Blazor project via `dotnet new igb-blazor`. Ensures the SDK and template + * package are available first. Returns false on any failure (no throwing). + */ + public static scaffold(options: ScaffoldOptions): boolean { + const sdkMajor = this.getSdkMajorVersion(); + if (sdkMajor === null) { + Util.error("The .NET SDK is required to create a Blazor project but was not found. " + + "Install it from https://dotnet.microsoft.com/download and try again.", "red"); + return false; + } + if (sdkMajor < MIN_SDK_MAJOR) { + Util.error(`The Blazor Web App template requires .NET ${MIN_SDK_MAJOR}+ but found .NET ${sdkMajor}. ` + + "Install a newer SDK from https://dotnet.microsoft.com/download and try again.", "red"); + return false; + } + + if (!this.isTemplateInstalled() && !this.installTemplate()) { + return false; + } + + const extra = options.extraConfig ?? {}; + const hosting = extra.Hosting ?? "Server"; + const variant = extra.Variant ?? "light"; + const args = [ + "new", TEMPLATE_SHORT_NAME, + "-n", options.name, + "-o", options.name, + "--Hosting", hosting, + "--Theme", options.theme, + "--Variant", variant + ]; + if (options.skipInstall) { + args.push("--SkipRestore"); + } + + const result = Util.spawnSync("dotnet", args, { stdio: "inherit" }); + if (result.error || result.status !== 0) { + Util.error("Project creation failed (see dotnet output above).", "red"); + return false; + } + + Util.log(Util.greenCheck() + " Project Created"); + return true; + } +} diff --git a/packages/core/util/Util.ts b/packages/core/util/Util.ts index eb0454a37..4e5678dc4 100644 --- a/packages/core/util/Util.ts +++ b/packages/core/util/Util.ts @@ -367,13 +367,14 @@ export class Util { * @param command Command to be executed * NOTE: `spawn` without `shell` (unsafe) is **not** equivalent to `exec` & requires direct path to run the correct process on win, * e.g. `npm.cmd` but that is also blocked in node@24+ for security reasons - * Do not call with/add commands that are not known binaries without validating first + * Do not call with/add commands that are not known binaries without validating first. + * Allowed binaries: `node`, `git`, `dotnet` (all real PATH binaries on every OS, no `.cmd`-shim issue). * @param args Command arguments * @param options Command options * @returns {SpawnSyncReturns} object with status and stdout * @remarks Consuming code MUST handle the result and check for failure status! */ - public static spawnSync(command: 'node' | 'git', args: string[], options?: Omit) { + public static spawnSync(command: 'node' | 'git' | 'dotnet', args: string[], options?: Omit) { return spawnSync(command, args, options); } diff --git a/packages/core/util/index.ts b/packages/core/util/index.ts index 045234447..fefde718d 100644 --- a/packages/core/util/index.ts +++ b/packages/core/util/index.ts @@ -3,6 +3,7 @@ export * from './detect-framework'; export * from './GoogleAnalytics'; export * from './mcp-config'; export * from './Util'; +export * from './DotnetTemplateManager'; export * from './ProjectConfig'; export * from './Schematics'; export * from './App'; diff --git a/spec/acceptance/help-spec.ts b/spec/acceptance/help-spec.ts index 296228d66..20dbaafc5 100644 --- a/spec/acceptance/help-spec.ts +++ b/spec/acceptance/help-spec.ts @@ -62,10 +62,14 @@ describe("Help command", () => { -v, --version Show current Ignite UI CLI version [boolean] -h, --help Show help [boolean] -f, --framework Framework to scaffold the project for. - [string] [choices: "angular", "jquery", "react", "webcomponents"] [default: - "angular"] + [string] [choices: "angular", "blazor", "jquery", "react", "webcomponents"] + [default: "angular"] -t, --type Project type (depends on framework) [string] --theme, --th Project theme (depends on project type) [string] + --hosting Blazor hosting model (Blazor projects only) + [string] [choices: "Server", "Wasm", "Auto"] + --variant Theme variant (Blazor projects only) + [string] [choices: "light", "dark"] --skip-git, --sg Do not initialize a git repository for the project [boolean] --skip-install, --si Do not install packages after scaffolding [boolean] diff --git a/spec/templates/blazor-spec.ts b/spec/templates/blazor-spec.ts index eb63a3ad6..eaacecdf4 100644 --- a/spec/templates/blazor-spec.ts +++ b/spec/templates/blazor-spec.ts @@ -19,6 +19,26 @@ describe("Blazor templates", () => { } }); + it("Blazor framework should not be hidden", () => { + const blazorFramework: Framework = require(templatesLocation); + expect(blazorFramework.hidden).toBeFalse(); + }); + + it("igb library should expose the four Blazor themes", () => { + const blazorFramework: Framework = require(templatesLocation); + const projLibrary = blazorFramework.projectLibraries.find(x => x.projectType === "igb"); + expect(projLibrary.themes).toEqual(["bootstrap", "material", "fluent", "indigo"]); + }); + + it("empty project template should be registered and visible", () => { + const blazorFramework: Framework = require(templatesLocation); + const projLibrary = blazorFramework.projectLibraries.find(x => x.projectType === "igb"); + const emptyProject = projLibrary.getProject("empty"); + expect(emptyProject).toBeDefined(); + expect(emptyProject.isHidden).toBeFalse(); + expect(typeof emptyProject.scaffold).toBe("function"); + }); + describe("ai-config template file presence", () => { it("ai-config project template must be registered", () => { const blazorFramework: Framework = require(templatesLocation); diff --git a/spec/unit/DotnetTemplateManager-spec.ts b/spec/unit/DotnetTemplateManager-spec.ts new file mode 100644 index 000000000..502b8c39f --- /dev/null +++ b/spec/unit/DotnetTemplateManager-spec.ts @@ -0,0 +1,190 @@ +import { DotnetTemplateManager, Util } from "@igniteui/cli-core"; + +describe("Unit - DotnetTemplateManager", () => { + let spawnSpy: jasmine.Spy; + + beforeEach(() => { + spyOn(Util, "log"); + spyOn(Util, "error"); + spyOn(Util, "greenCheck").and.returnValue("√"); + spawnSpy = spyOn(Util, "spawnSync"); + }); + + function mockResult(overrides: Partial<{ status: number; stdout: any; error: any }> = {}) { + return { status: 0, stdout: "", error: undefined, ...overrides } as any; + } + + describe("getSdkMajorVersion", () => { + it("parses the leading major version", () => { + spawnSpy.and.returnValue(mockResult({ stdout: "8.0.100" })); + expect(DotnetTemplateManager.getSdkMajorVersion()).toBe(8); + expect(spawnSpy).toHaveBeenCalledWith("dotnet", ["--version"], { encoding: "utf8" }); + }); + + it("returns null when dotnet is missing (error)", () => { + spawnSpy.and.returnValue(mockResult({ error: new Error("ENOENT"), status: null })); + expect(DotnetTemplateManager.getSdkMajorVersion()).toBeNull(); + }); + + it("returns null on non-zero status", () => { + spawnSpy.and.returnValue(mockResult({ status: 1, stdout: "" })); + expect(DotnetTemplateManager.getSdkMajorVersion()).toBeNull(); + }); + }); + + describe("isTemplateInstalled", () => { + it("returns true when the short name is present and status is 0", () => { + spawnSpy.and.returnValue(mockResult({ stdout: "Template Name Short Name\nigb-blazor ..." })); + expect(DotnetTemplateManager.isTemplateInstalled()).toBeTrue(); + }); + + it("returns false when the short name is absent", () => { + spawnSpy.and.returnValue(mockResult({ stdout: "nothing here" })); + expect(DotnetTemplateManager.isTemplateInstalled()).toBeFalse(); + }); + + it("returns false on non-zero status (e.g. 103)", () => { + spawnSpy.and.returnValue(mockResult({ status: 103, stdout: "" })); + expect(DotnetTemplateManager.isTemplateInstalled()).toBeFalse(); + }); + }); + + describe("installTemplate", () => { + it("installs the latest package (no version pin) and returns true", () => { + spawnSpy.and.returnValue(mockResult()); + expect(DotnetTemplateManager.installTemplate()).toBeTrue(); + expect(spawnSpy).toHaveBeenCalledWith( + "dotnet", ["new", "install", "IgniteUI.Blazor.Templates"], { stdio: "inherit" }); + }); + + it("returns false and logs an error on failure", () => { + spawnSpy.and.returnValue(mockResult({ status: 1 })); + expect(DotnetTemplateManager.installTemplate()).toBeFalse(); + expect(Util.error).toHaveBeenCalled(); + }); + }); + + describe("scaffold", () => { + it("aborts when the SDK is missing", () => { + spawnSpy.and.returnValue(mockResult({ error: new Error("ENOENT"), status: null })); + expect(DotnetTemplateManager.scaffold({ name: "app", theme: "bootstrap" })).toBeFalse(); + expect(Util.error).toHaveBeenCalledWith(jasmine.stringMatching("dotnet.microsoft.com/download"), "red"); + }); + + it("aborts when the SDK is older than 8", () => { + spawnSpy.and.returnValue(mockResult({ stdout: "6.0.400" })); + expect(DotnetTemplateManager.scaffold({ name: "app", theme: "bootstrap" })).toBeFalse(); + expect(Util.error).toHaveBeenCalledWith(jasmine.stringMatching("requires .NET 8"), "red"); + }); + + it("builds the exact dotnet args array, omitting the weather flag", () => { + spawnSpy.and.callFake((_cmd: string, args: string[]) => { + if (args[0] === "--version") { return mockResult({ stdout: "8.0.100" }); } + if (args[1] === "list") { return mockResult({ stdout: "igb-blazor" }); } + return mockResult(); + }); + + const result = DotnetTemplateManager.scaffold({ + name: "my-blazor", theme: "material", + extraConfig: { Hosting: "Auto", Variant: "dark" } + }); + + expect(result).toBeTrue(); + const createCall = spawnSpy.calls.all().find(c => c.args[1][0] === "new" && c.args[1][1] === "igb-blazor"); + expect(createCall.args[1]).toEqual([ + "new", "igb-blazor", + "-n", "my-blazor", + "-o", "my-blazor", + "--Hosting", "Auto", + "--Theme", "material", + "--Variant", "dark" + ]); + expect(createCall.args[1]).not.toContain("--IncludeWeatherSample"); + expect(createCall.args[2]).toEqual({ stdio: "inherit" }); + }); + + it("applies Server/light defaults when extraConfig keys are absent", () => { + spawnSpy.and.callFake((_cmd: string, args: string[]) => { + if (args[0] === "--version") { return mockResult({ stdout: "9.0.100" }); } + if (args[1] === "list") { return mockResult({ stdout: "igb-blazor" }); } + return mockResult(); + }); + + DotnetTemplateManager.scaffold({ name: "app", theme: "bootstrap" }); + + const createCall = spawnSpy.calls.all().find(c => c.args[1][0] === "new" && c.args[1][1] === "igb-blazor"); + expect(createCall.args[1]).toContain("Server"); + expect(createCall.args[1]).toContain("light"); + }); + + it("keeps a name with spaces as a single argv element", () => { + spawnSpy.and.callFake((_cmd: string, args: string[]) => { + if (args[0] === "--version") { return mockResult({ stdout: "8.0.100" }); } + if (args[1] === "list") { return mockResult({ stdout: "igb-blazor" }); } + return mockResult(); + }); + + DotnetTemplateManager.scaffold({ name: "my blazor app", theme: "bootstrap" }); + + const createCall = spawnSpy.calls.all().find(c => c.args[1][0] === "new" && c.args[1][1] === "igb-blazor"); + expect(createCall.args[1]).toContain("my blazor app"); + }); + + it("adds --SkipRestore only when skipInstall is set", () => { + spawnSpy.and.callFake((_cmd: string, args: string[]) => { + if (args[0] === "--version") { return mockResult({ stdout: "8.0.100" }); } + if (args[1] === "list") { return mockResult({ stdout: "igb-blazor" }); } + return mockResult(); + }); + + DotnetTemplateManager.scaffold({ name: "app", theme: "bootstrap", skipInstall: true }); + let createCall = spawnSpy.calls.all().find(c => c.args[1][0] === "new" && c.args[1][1] === "igb-blazor"); + expect(createCall.args[1]).toContain("--SkipRestore"); + + spawnSpy.calls.reset(); + DotnetTemplateManager.scaffold({ name: "app", theme: "bootstrap" }); + createCall = spawnSpy.calls.all().find(c => c.args[1][0] === "new" && c.args[1][1] === "igb-blazor"); + expect(createCall.args[1]).not.toContain("--SkipRestore"); + }); + + it("installs the template first when not present", () => { + const order: string[] = []; + spawnSpy.and.callFake((_cmd: string, args: string[]) => { + if (args[0] === "--version") { return mockResult({ stdout: "8.0.100" }); } + if (args[1] === "list") { order.push("list"); return mockResult({ stdout: "no match" }); } + if (args[1] === "install") { order.push("install"); return mockResult(); } + order.push("create"); + return mockResult(); + }); + + expect(DotnetTemplateManager.scaffold({ name: "app", theme: "bootstrap" })).toBeTrue(); + expect(order).toEqual(["list", "install", "create"]); + }); + + it("aborts when template install fails (offline)", () => { + spawnSpy.and.callFake((_cmd: string, args: string[]) => { + if (args[0] === "--version") { return mockResult({ stdout: "8.0.100" }); } + if (args[1] === "list") { return mockResult({ stdout: "no match" }); } + if (args[1] === "install") { return mockResult({ status: 1 }); } + return mockResult(); + }); + + expect(DotnetTemplateManager.scaffold({ name: "app", theme: "bootstrap" })).toBeFalse(); + // no create call attempted + const createCall = spawnSpy.calls.all().find(c => c.args[1][0] === "new" && c.args[1][1] === "igb-blazor"); + expect(createCall).toBeUndefined(); + }); + + it("returns false when project creation fails", () => { + spawnSpy.and.callFake((_cmd: string, args: string[]) => { + if (args[0] === "--version") { return mockResult({ stdout: "8.0.100" }); } + if (args[1] === "list") { return mockResult({ stdout: "igb-blazor" }); } + return mockResult({ status: 1 }); + }); + + expect(DotnetTemplateManager.scaffold({ name: "app", theme: "bootstrap" })).toBeFalse(); + expect(Util.error).toHaveBeenCalledWith( + "Project creation failed (see dotnet output above).", "red"); + }); + }); +}); diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index f6987d70f..739939543 100644 --- a/spec/unit/PromptSession-spec.ts +++ b/spec/unit/PromptSession-spec.ts @@ -408,6 +408,85 @@ describe("Unit - PromptSession", () => { expect(Util.directoryExists).toHaveBeenCalledWith("Th15 w1ll"); expect(aiConfig.configure).toHaveBeenCalledTimes(1); }); + it("start - Should call scaffold (not processTemplates) and run extra-config for Blazor", async () => { + const mockProjectTemplate = { + name: "Blazor Web App", + framework: "blazor", + projectType: "igb", + hasExtraConfiguration: true, + isHidden: false, + templatePaths: [], + delimiters: {}, + scaffold: jasmine.createSpy("scaffold").and.returnValue(Promise.resolve(true)), + generateConfig: jasmine.createSpy("generateConfig"), + getExtraConfiguration: () => [ + { choices: ["Server", "Wasm", "Auto"], default: "Server", key: "Hosting", message: "h", type: ControlExtraConfigType.Choice }, + { choices: ["light", "dark"], default: "light", key: "Variant", message: "v", type: ControlExtraConfigType.Choice } + ], + setExtraConfiguration: jasmine.createSpy("setExtraConfiguration") + }; + const mockProjectLibrary = { + themes: ["bootstrap"], + projectIds: ["empty"], + projects: [mockProjectTemplate] + }; + const mockFramework = { id: "blazor", name: "Blazor", projectLibraries: [mockProjectLibrary] }; + const mockTemplate = jasmine.createSpyObj("mockTemplate", { + getFrameworkByName: mockFramework, + getFrameworkById: mockFramework, + getFrameworkNames: ["Blazor"], + getProjectLibraryNames: ["Ignite UI for Blazor"], + getProjectLibraryByName: mockProjectLibrary + }); + App.container.set(TEMPLATE_MANAGER, mockTemplate); + const mockSession = new PromptSession(); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); + spyOn(ProjectConfig, "getConfig").and.returnValue({ skipGit: false } as unknown as Config); + spyOn(Util, "log"); + spyOn(Util, "directoryExists").and.returnValue(false); + spyOn(Util, "getAvailableName").and.returnValue("Test Project"); + spyOn(Util, "gitInit"); + spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); + spyOn(InquirerWrapper, "input").and.returnValue(Promise.resolve("Test Project")); + spyOn(InquirerWrapper, "select").and.returnValues( + Promise.resolve("Auto"), + Promise.resolve("dark") + ); + spyOn(process, "chdir"); + spyOn(mockSession, "chooseActionLoop"); + + await mockSession.start(); + + expect(mockProjectTemplate.setExtraConfiguration).toHaveBeenCalledWith(["Auto", "dark"]); + expect(mockProjectTemplate.scaffold).toHaveBeenCalledWith({ + name: "Test Project", + theme: "bootstrap", + skipInstall: false, + skipGit: false + }); + expect(Util.processTemplates).not.toHaveBeenCalled(); + expect(mockProjectTemplate.generateConfig).not.toHaveBeenCalled(); + expect(Util.gitInit).toHaveBeenCalled(); + expect(aiConfig.configure).toHaveBeenCalledWith("blazor"); + }); + it("Complete & Run - with empty localConfig prints dotnet next-steps and skips completeAndRun", async () => { + App.container.set(TEMPLATE_MANAGER, {} as any); + const mockSession = new PromptSession(); + spyOn(ProjectConfig, "localConfig").and.returnValue({} as unknown as Config); + spyOn(mockSession as any, "generateActionChoices").and.returnValue([]); + spyOn(mockSession as any, "getUserInput").and.returnValue(Promise.resolve("Complete & Run")); + spyOn(mockSession as any, "completeAndRun"); + spyOn(Util, "canPrompt").and.returnValue(false); + spyOn(Util, "spawnSync"); + spyOn(Util, "log"); + spyOn(process, "cwd").and.returnValue(path.join("root", "my-blazor")); + + await mockSession.chooseActionLoop({} as any); + + expect((mockSession as any).completeAndRun).not.toHaveBeenCalled(); + expect(Util.spawnSync).not.toHaveBeenCalled(); + expect(Util.log).toHaveBeenCalledWith(" dotnet run --project my-blazor"); + }); it("chooseActionLoop - should run through properly - Add Component", async () => { const mockExtraConfigurations = [{ default: "Choice 1", diff --git a/spec/unit/Util-spec.ts b/spec/unit/Util-spec.ts index 20f5225c9..e35b8544f 100644 --- a/spec/unit/Util-spec.ts +++ b/spec/unit/Util-spec.ts @@ -137,6 +137,13 @@ describe("Unit - Util", () => { }); }); + it("spawnSync accepts the 'dotnet' command", () => { + // compile-time guarantee that 'dotnet' is in the allowed command union + const cmd: Parameters[0] = "dotnet"; + expect(cmd).toBe("dotnet"); + expect(typeof Util.spawnSync).toBe("function"); + }); + describe("canPrompt", () => { let originalStdoutIsTTY: boolean | undefined; let originalStdinIsTTY: boolean | undefined; diff --git a/spec/unit/new-spec.ts b/spec/unit/new-spec.ts index 22c3eb037..6f87db4db 100644 --- a/spec/unit/new-spec.ts +++ b/spec/unit/new-spec.ts @@ -487,4 +487,96 @@ describe("Unit - New command", () => { expect(configureSpy).not.toHaveBeenCalled(); }); }); + + describe("scaffold project template path", () => { + let scaffoldSpy: jasmine.Spy; + let generateConfigSpy: jasmine.Spy; + + function createScaffoldMocks(scaffoldResult = true) { + scaffoldSpy = jasmine.createSpy("scaffold").and.returnValue(Promise.resolve(scaffoldResult)); + generateConfigSpy = jasmine.createSpy("generateConfig").and.returnValue({}); + const mockTemplate = { + framework: "blazor", + projectType: "igb", + scaffold: scaffoldSpy, + generateConfig: generateConfigSpy, + templatePaths: ["test"] + }; + const mockProjLib = { + getProject: () => mockTemplate, + projectIds: ["empty"], + projectType: "igb", + themes: ["bootstrap", "material"] + }; + App.container.set(TEMPLATE_MANAGER, jasmine.createSpyObj("TemplateManager", { + getFrameworkById: { id: "blazor" }, + getProjectLibrary: mockProjLib + })); + spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); + spyOn(Util, "gitInit"); + } + + it("calls scaffold instead of the npm pipeline", async () => { + createScaffoldMocks(); + + await newCmd.handler({ + name: "my-blazor", framework: "blazor", theme: "material", + hosting: "Auto", variant: "dark", _: ["new"], $0: "new" + }); + + expect(scaffoldSpy).toHaveBeenCalledWith({ + name: "my-blazor", + theme: "material", + skipInstall: false, + skipGit: false, + extraConfig: { Hosting: "Auto", Variant: "dark" } + }); + expect(generateConfigSpy).not.toHaveBeenCalled(); + expect(Util.processTemplates).not.toHaveBeenCalled(); + expect(PackageManager.installPackages).not.toHaveBeenCalled(); + }); + + it("omits unset hosting/variant from extraConfig and never passes a weather flag", async () => { + createScaffoldMocks(); + + await newCmd.handler({ name: "my-blazor", framework: "blazor", _: ["new"], $0: "new" }); + + const options = scaffoldSpy.calls.mostRecent().args[0]; + expect(options.extraConfig).toEqual({}); + expect(JSON.stringify(options)).not.toContain("Weather"); + }); + + it("runs configure and gitInit then prints dotnet next-steps on success", async () => { + createScaffoldMocks(); + + await newCmd.handler({ name: "my-blazor", framework: "blazor", _: ["new"], $0: "new" }); + + expect(aiConfig.configure as jasmine.Spy).toHaveBeenCalledWith("blazor", undefined, undefined); + expect(Util.gitInit).toHaveBeenCalled(); + expect(Util.log).toHaveBeenCalledWith(" dotnet run --project my-blazor"); + }); + + it("skips gitInit and next-steps when scaffold fails", async () => { + createScaffoldMocks(false); + + await newCmd.handler({ name: "my-blazor", framework: "blazor", _: ["new"], $0: "new" }); + + expect(aiConfig.configure as jasmine.Spy).not.toHaveBeenCalled(); + expect(Util.gitInit).not.toHaveBeenCalled(); + expect(Util.log).not.toHaveBeenCalledWith(" dotnet run --project my-blazor"); + }); + + it("honors skip-install (→ SkipRestore) and skip-git", async () => { + createScaffoldMocks(); + + await newCmd.handler({ + name: "my-blazor", framework: "blazor", + skipInstall: true, "skip-git": true, _: ["new"], $0: "new" + }); + + const options = scaffoldSpy.calls.mostRecent().args[0]; + expect(options.skipInstall).toBeTrue(); + expect(Util.gitInit).not.toHaveBeenCalled(); + }); + }); });