diff --git a/.agents/README.md b/.agents/README.md index ab7298bcfe..338b50d175 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -24,7 +24,7 @@ Each procedure is written once, as a skill in `.agents/skills/` ([Agent Skills]( | [`igniteui-cli-ai-config-skills-refresh`](./skills/igniteui-cli-ai-config-skills-refresh/SKILL.md) | Workflow | Bundled `ai-config` skills | Mirror the framework repositories' `skills/` into the `ai-config` templates. In `dry-run` mode: what changed upstream and content issues | | [`igniteui-cli-mcp-tool`](./skills/igniteui-cli-mcp-tool/SKILL.md) | Workflow | MCP server `src/`, tests, tool docs | Add an MCP server tool or change an existing one. Its `scripts/call-tool.mjs` calls a tool on the built server over stdio | | [`igniteui-cli-smoke-test`](./skills/igniteui-cli-smoke-test/SKILL.md) | Reference | — | Build, running one spec file, specs that guard templates, running the smoke test, checking kept projects, triaging failures ("Triaging a Run"), editing the script | -| [`igniteui-cli-template-anatomy`](./skills/igniteui-cli-template-anatomy/SKILL.md) | Reference | — | Template layout per framework, model templates, every place versions are pinned, parity auditing ("Auditing Parity Across Frameworks") | +| [`igniteui-cli-template-anatomy`](./skills/igniteui-cli-template-anatomy/SKILL.md) | Reference | — | Template layout per framework, model templates, how Angular projects are created from `ng new`, every place versions are pinned, parity auditing ("Auditing Parity Across Frameworks") | The workflows triage their own smoke runs; use the smoke-test skill directly for a run you started by hand. diff --git a/.agents/skills/igniteui-cli-smoke-test/SKILL.md b/.agents/skills/igniteui-cli-smoke-test/SKILL.md index fa90053887..9a6857137a 100644 --- a/.agents/skills/igniteui-cli-smoke-test/SKILL.md +++ b/.agents/skills/igniteui-cli-smoke-test/SKILL.md @@ -6,7 +6,7 @@ license: MIT # Ignite UI CLI: Build, Template Specs, Smoke Test -`scripts/smoke-test.sh` is the **only** check that a generated project actually installs and builds. Jasmine stubs `PackageManager.installPackages`, so a green Jasmine run says nothing about real npm resolution or TypeScript compilation of generated code. Run the build and the template specs first: they are fast and catch id and pin mistakes before a slow smoke run. +`scripts/smoke-test.sh` is the **only** check that a generated project actually installs and builds. Jasmine stubs `PackageManager.installPackages` and the Angular CLI (`ng new` / `ng add`, which create every Angular project), so a green Jasmine run says nothing about real npm resolution, what the current Angular CLI generates, or TypeScript compilation of generated code. Run the build and the template specs first: they are fast and catch id and pin mistakes before a slow smoke run. ## Build and Template Specs @@ -20,7 +20,8 @@ license: MIT | Spec | Guards | |---|---| -| `spec/templates/angular-spec.ts` | Template ids exist; no file collisions between templates; every `igniteui-angular` pin in templates and project `package.json` equals `IGNITEUI_ANGULAR_PACKAGE` | +| `spec/templates/angular-spec.ts` | Template ids exist; no file collisions between templates; every `igniteui-angular` pin in templates equals `IGNITEUI_ANGULAR_PACKAGE`; project templates carry none of the files the Angular CLI generates and the scaffold patches (`angular.json`, `package.json`, tsconfigs, `src/main.ts`) | +| `spec/templates/angular-scaffold-spec.ts` | The Angular project scaffold, against the stubbed Angular CLI: command sequence, every patch to the generated `angular.json`, `package.json` and tsconfigs, the projects' `extras`, cleanup on failure | | `spec/templates/react-spec.ts` | Same, for all four `IGNITEUI_REACT_*` constants | | `spec/templates/webcomponents-spec.ts` | Ids and collisions only. It does **not** check version pins against the constants | | `spec/templates/template-contracts-spec.ts` | Every jQuery, React, Web Components and Blazor project and component template: matches its framework and project type, its template paths exist, it generates a config | @@ -30,7 +31,7 @@ license: MIT The template specs for a framework run in about 15 seconds, after `npm run build`: ```bash -npx jasmine spec/templates/angular-spec.js +npx jasmine spec/templates/angular-spec.js spec/templates/angular-scaffold-spec.js npx jasmine spec/templates/react-spec.js spec/templates/template-contracts-spec.js npx jasmine spec/templates/webcomponents-spec.js spec/templates/template-contracts-spec.js ``` @@ -51,7 +52,7 @@ Preconditions: `npm run build` has run (the script refuses otherwise); `node` an Defaults: angular, react, webcomponents; default project template; all component templates. jQuery and Blazor are opt-in (`-f jquery`, `-f blazor`): jQuery needs ProGet credentials for 13 templates and has no build; Blazor needs the .NET SDK and has no component templates. -`--all-projects` takes its list from `ig list`, so **hidden project templates** (`isHidden = true` in the project's `index.ts`, e.g. `base`, `side-nav-auth`, `ai-config`) are not scaffolded. Most reuse the `_base` / `_base_with_home` files; a hidden project with its own `files/package.json` (e.g. Angular `side-nav-auth`) is covered only by `scripts/smoke-test.sh -f -p `. Hidden projects can also reuse another project's `files/` through `templatePaths` (Angular `side-nav-mini-auth` includes `side-nav-auth/files`, manifest included), so a change there needs a `-p` run for each project that pulls it in. +`--all-projects` takes its list from `ig list`, so **hidden project templates** (`isHidden = true` in the project's `index.ts`, e.g. `base`, `side-nav-auth`, `ai-config`) are not scaffolded. Most reuse the `_base` / `_base_with_home` files; a hidden project with its own packages (a `files/package.json`, or Angular `extras` as in `side-nav-auth`) is covered only by `scripts/smoke-test.sh -f -p `. Hidden projects can also reuse another project's `files/` through `templatePaths` (Angular `side-nav-mini-auth` includes `side-nav-auth/files` and its packages), so a change there needs a `-p` run for each project that pulls it in. Runs longer than a few minutes: use a long command timeout, or run it in the background with output redirected to a file and wait for the process to exit. The `Results:` line is not the end: when nothing failed and `--keep` is not set, the script deletes the generated projects after printing it. The script **wipes its `--out` directory at start**, so give each run its own directory (e.g. `--out output/smoke-react`, `--out output/smoke-react-projects`) when you need more than one run's results, and never run two at once into the same directory. @@ -115,7 +116,7 @@ The test runners use browser mode with Playwright Chromium. If one fails because ## Mapping an Install Error Back to Source -A pin in an install error comes from one of: the template's variant `index.ts` (`this.packages`), the framework's `constants.ts`, or a project template's `files/package.json`. See the template-anatomy skill. +A pin in an install error comes from one of: the template's variant `index.ts` (`this.packages`), the framework's `constants.ts`, or a project template's `files/package.json`. An Angular project's own packages follow `IGNITEUI_ANGULAR_PACKAGE`: `igniteui-angular` at that range, the rest at whatever `ng add` and the project's `extras` resolve, so an install error there is an upstream peer conflict (template-anatomy skill, "Angular Projects"). ## Mapping a Build Error Back to Template Source @@ -150,7 +151,7 @@ End with the PASS/FAIL/SKIP totals, whether the run as a whole is trustworthy (n ## Constraints When Editing the Script -- Every `ig` call must happen **before** `npm install` in a generated project. After install, `bin/execute.js` delegates to the project-local *published* CLI, so later calls would test the published CLI, not your local build. +- Call the CLI through `cli` (which loads `packages/cli/lib/cli.js`), never `bin/execute.js`. The wrapper hands off to a project-local `igniteui-cli` whenever one is installed, and Angular projects install one during `ig new`, so calls through it would test the published CLI, not this checkout. - Keep the three-way step judgement (exit code, log scan, artifact) intact. ## Related Skills diff --git a/.agents/skills/igniteui-cli-template-anatomy/SKILL.md b/.agents/skills/igniteui-cli-template-anatomy/SKILL.md index 9d420e1030..4898f01159 100644 --- a/.agents/skills/igniteui-cli-template-anatomy/SKILL.md +++ b/.agents/skills/igniteui-cli-template-anatomy/SKILL.md @@ -1,6 +1,6 @@ --- name: igniteui-cli-template-anatomy -description: "Template anatomy for Angular (igx-ts), React (igr-ts) and Web Components (igc-ts): folder layout, index.ts fields, placeholders, test files, groups, and every place an Ignite UI version is pinned. WHEN TO USE: adding or changing a component template, locating version pins, auditing template parity across frameworks. WHEN NOT TO USE: running builds, template specs or the smoke test (igniteui-cli-smoke-test)." +description: "Template anatomy for Angular (igx-ts), React (igr-ts) and Web Components (igc-ts): folder layout, index.ts fields, placeholders, test files, groups, how Angular projects are created from `ng new`, and every place an Ignite UI version is pinned. WHEN TO USE: adding or changing a component template, changing what new Angular projects contain, locating version pins, auditing template parity across frameworks. WHEN NOT TO USE: running builds, template specs or the smoke test (igniteui-cli-smoke-test)." license: MIT --- @@ -83,12 +83,30 @@ Angular has two styles. Pick the one the component library dictates: React imports components from `igniteui-react` (or `-grids`, `-charts`, `-gauges`) as `Igr*`. Web Components imports `Igc*Component` and calls `defineComponents(...)`. +## Angular Projects + +`ig new` and the `ng-new` schematic create an Angular project with the Angular CLI, through `scaffold()` on `BaseIgxProject` (`packages/igx-templates/igx-ts/projects/_base/index.ts`; the steps live in `_base/scaffold.ts`): + +1. `ng new` (scss, routing, no SSR, zone.js), `ng add angular-eslint@`, `ng add igniteui-angular@`. `ng add` installs packages, so `--skip-install` only prints a warning. For the same reason the `ng-new` schematic rejects `--dry-run` (`isDryRun` in `packages/ng-schematics/src/ng-new/index.ts`, which reads the workflow's private `_dryRun`; re-check it after an Angular major). The AI config `ng add` writes is deleted; `ig new` and the schematic run their own. When a step fails, the project folder is removed. +2. The project's `templatePaths` (`_base`, `_base_with_home`, the project's own `files/`) are laid over the generated project, placeholders substituted. +3. `angular.json` and `package.json` get our deviations as patches (`patchAngularJson`, `patchPackageJson`, which also writes the `IGNITEUI_ANGULAR_PACKAGE` range over the `^` that `ng add` saves); the tsconfigs get the types in the project's `extras`. +4. `npm install` adds `playwright` and the packages in the project's `extras`: at their latest versions, or at the version an entry names. + +Project `files/` are an **overlay**: they hold only files we add or replace wholesale (app code, `README.md`, `ignite-ui-cli.json`, CI workflows, eslint config, `vitest-base.config.ts`, environments, `src/index.html`, `src/styles.scss`). The rest follows from that: + +- A change to a file `ng new` generates (`angular.json`, `package.json`, tsconfigs, `src/main.ts`) is a patch in `_base/scaffold.ts`, so the generated file keeps up with Angular. `spec/templates/angular-spec.ts` fails when a project's `files/` carries one of them. +- A package or global type a project's code needs goes in its `extras` (`side-nav-auth` declares `authExtras`; `side-nav-mini-auth` reuses it). `extras` receives the project's Angular major: a package whose majors follow Angular's (`@angular/*`, `angular-auth-oidc-client`) is pinned to it, since its latest version would outrun the project's Angular once a new major ships. +- Packages the overlay files use without an `extras` entry (`minireset.css`, `@igniteui/material-icons-extended`) come from `ng add igniteui-angular`; the smoke build fails if it stops adding them. +- `IGNITEUI_ANGULAR_PACKAGE` in `constants.ts` sets the versions: `igniteui-angular` at that range, Angular at the newest major that range's `peerDependencies` allow (resolved with `npm view` when the project is created). Minors and patches arrive on their own; moving to a new major is a change to that one constant, after which the smoke test below must pass. + +Jasmine never runs the real CLI: `spec/helpers/angular-cli-stub.ts` stands in for `ng` and `npm` and writes the project a real `ng new` + `ng add` produce, and `spec/templates/angular-scaffold-spec.ts` checks the command sequence and every patch. When the real output changes shape (a patch fails in a smoke run), update the stub with it. Only the smoke test runs the real CLI, with network access; a change here needs a run over every Angular project template, the hidden `base`, `side-nav-auth` and `side-nav-mini-auth` included (smoke-test skill). + ## Where Ignite UI Versions Are Pinned Pins live in three kinds of places. **Always regenerate the list with a search; never trust a remembered list.** 1. `constants.ts` per framework (exported `name@range` strings). -2. Project-template manifests: `/projects/*/files/package.json`. Several projects carry their own copy (e.g. Angular `_base` and `side-nav-auth`; Web Components `_base` and `_base_with_home`). +2. Project-template manifests: `/projects/*/files/package.json`, e.g. Web Components `_base` and `_base_with_home`. Angular projects have none: their versions follow `IGNITEUI_ANGULAR_PACKAGE` ("Angular Projects"). 3. Hard-coded `this.packages = ["name@range", ...]` in variant `index.ts` files, used where no constant exists (Angular charts/gauges/maps family, `igniteui-grid-lite`, wrapped `igniteui-webcomponents`; Web Components `igniteui-dockmanager`). Some build the name at runtime, e.g. Angular dock-manager: `` `${resolvePackage(NPM_DOCK_MANAGER)}@~2.1.1` ``. Run **both** search patterns (ripgrep) over `packages/cli/templates` and `packages/igx-templates`, `*.ts` and `*.json`: diff --git a/.agents/skills/igniteui-cli-template-version-update/SKILL.md b/.agents/skills/igniteui-cli-template-version-update/SKILL.md index ab9b0d2951..372a7ecaeb 100644 --- a/.agents/skills/igniteui-cli-template-version-update/SKILL.md +++ b/.agents/skills/igniteui-cli-template-version-update/SKILL.md @@ -22,7 +22,8 @@ If the scope is missing, or a scope or target is ambiguous, stop and ask. In `up Scope boundaries: - A framework scope covers only that framework's root. With scope `webcomponents`, the `igniteui-webcomponents` pins in Angular's wrapped-component templates (`packages/igx-templates/.../chat`, `rating`) are **out of scope**; report them as observations. -- Packages shared by all frameworks' project templates (e.g. `@igniteui/material-icons-extended`) are updated only when the user names them or the scope covers all three frameworks, so the frameworks don't diverge. Otherwise list them as observations. +- Angular project templates pin nothing: new projects take `igniteui-angular` at the `IGNITEUI_ANGULAR_PACKAGE` range and Angular at the newest major that range supports (anatomy skill, "Angular Projects"). Updating that constant therefore moves new projects too, including their Angular major when the new `igniteui-angular` requires one; Angular pins live only in `constants.ts` and component templates. +- Packages shared by the React and Web Components project templates (e.g. `@igniteui/material-icons-extended`) are updated only when the user names them or the scope covers both frameworks, so the frameworks don't diverge. Otherwise list them as observations. --- @@ -33,7 +34,7 @@ Your changes are Ignite UI version strings, applied as patch or minor updates. E - **Major** updates (see Step 3) are stop items, never applied. - Each pin keeps its range prefix (`~` or `^`); report inconsistent prefixes as observations. - Pins stay where they are: hard-coded pins stay hard-coded unless the user asked to move them into constants. -- Non-Ignite UI dependencies in project templates (Angular, React, Vite, TypeScript, etc.) stay as they are. If a new Ignite UI version requires them to change, that is a stop item (Step 3). +- Non-Ignite UI dependencies in project templates (React, Vite, TypeScript, etc.) stay as they are. If a new Ignite UI version requires them to change, that is a stop item (Step 3). - jQuery and Blazor templates are out of scope unless explicitly named. - `CHANGELOG.md` is written at release time: propose a line in your report. @@ -98,7 +99,7 @@ For each distinct package: - **major** (or `0.x` minor change): **stop for that package**. Leave all of its pins unchanged. - **already current** (the existing range already resolves to the target): no change. This includes `^` pins whose range admits a newer minor (e.g. `^2.1.1` admits 2.2.1). Keep their minimum unless the user asks; list them under Observations as "minimum could be raised to `^`". 4. For packages that proceed, check peers: `npm view @ peerDependencies`. - - If a peer is a **non-Ignite UI** package (e.g. a new `igniteui-angular` needing a newer `@angular/core`) and the project templates don't satisfy it, **stop for that package** and report the required change. + - If a peer is a **non-Ignite UI** package (e.g. a new `igniteui-react` needing a newer `react`) and the project templates don't satisfy it, **stop for that package** and report the required change. - If a peer is another **Ignite UI** package pinned in templates (e.g. `igniteui-angular` → `igniteui-grid-lite ~0.11.0`), that pin must satisfy the peer range after your update. Treat bringing it into range as part of this update, subject to the same patch/minor/major rule; if that would be a major (or `0.x` minor) change, report it as a stop item with the peer range as the reason, so a human can approve it explicitly. Do this check also for the framework's **main package even when it is not changing**: existing pins can already be out of range. @@ -130,7 +131,7 @@ Use the [`igniteui-cli-smoke-test`](../igniteui-cli-smoke-test/SKILL.md) skill f 3. Smoke test, per framework in scope, each into its **own output directory** (a run wipes its `--out` at start, so a shared directory loses the earlier run's results). Scope the run to what changed: - The framework's main package changed (`igniteui-angular`, `igniteui-react`, `igniteui-webcomponents`), which nearly every template pulls in: `scripts/smoke-test.sh -f --out output/smoke-` (default project + every component template). - Otherwise, only the templates that pull in a changed package: search the framework's variant `index.ts` files for the package name or its constant, read each match's `this.id`, and run `scripts/smoke-test.sh -f --templates , --out output/smoke-`. For example, an `igniteui-dockmanager` update runs `--templates dock-manager`. The default project is always scaffolded, so a pin in its `package.json` is covered too. - - Additionally, when any `projects/*/files/package.json` changed: `scripts/smoke-test.sh -f --all-projects --out output/smoke--projects`. `--all-projects` skips hidden projects, so for each changed manifest under a hidden project (`isHidden = true` in its `index.ts`, e.g. Angular `side-nav-auth`) also run `scripts/smoke-test.sh -f -p --out output/smoke--`, and the same for every other hidden project whose `templatePaths` includes that `files/` folder (Angular `side-nav-mini-auth` reuses `side-nav-auth/files`). If a changed package appears only in project manifests (no template pulls it in, e.g. `@igniteui/material-icons-extended`), these runs alone cover it; skip the `--templates` run. + - Additionally, when any `projects/*/files/package.json` changed, or for Angular when `IGNITEUI_ANGULAR_PACKAGE` changed (it sets the versions of every Angular project, hidden ones included): `scripts/smoke-test.sh -f --all-projects --out output/smoke--projects`. `--all-projects` skips hidden projects (`isHidden = true` in their `index.ts`), so also run `scripts/smoke-test.sh -f -p --out output/smoke--` for each hidden project the change reaches: every hidden Angular project when `IGNITEUI_ANGULAR_PACKAGE` changed; for a changed manifest, the hidden project that owns it and every other hidden project whose `templatePaths` includes that `files/` folder. If a changed package appears only in project manifests (no template pulls it in, e.g. `@igniteui/material-icons-extended`), these runs alone cover it; skip the `--templates` run. A scoped run takes about a minute; a full run takes several. Use a long timeout or background execution and wait for completion. Run them one at a time. Classify any failure with the smoke-test skill. Environment failures (network, credentials) are re-run once, then reported; versions stay as they are for those. diff --git a/AGENTS.md b/AGENTS.md index 3336cce1ec..2cc16922d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ scripts/smoke-test.sh # scaffold + add every template + install + buil Tests are Jasmine specs under `spec/` (unit + acceptance) and within individual packages. -`scripts/smoke-test.sh` is the only check that a generated project actually installs and builds — the Jasmine specs stub `PackageManager.installPackages`. Run it after touching anything under `packages/cli/templates/` or `packages/igx-templates/`. Running one spec file, the specs that guard templates, running the smoke test, reading its results and editing the script: `igniteui-cli-smoke-test` skill. +`scripts/smoke-test.sh` is the only check that a generated project actually installs and builds — the Jasmine specs stub `PackageManager.installPackages` and the Angular CLI. Run it after touching anything under `packages/cli/templates/` or `packages/igx-templates/`. Running one spec file, the specs that guard templates, running the smoke test, reading its results and editing the script: `igniteui-cli-smoke-test` skill. ### MCP Server (`packages/igniteui-mcp/igniteui-doc-mcp`) @@ -57,6 +57,7 @@ Adding or changing an MCP tool (schema, handler, description, tests, every doc t - Placeholders are substituted by the CLI, not a template engine: `<%=ClassName%>` style in Angular templates, `$(ClassName)` style in React and Web Components templates - Templates in `packages/cli/templates/**/files/` and `packages/igx-templates/` +- Angular projects are created by `ng new` + `ng add igniteui-angular` at the `IGNITEUI_ANGULAR_PACKAGE` range (`ig new` and the `ng-new` schematic alike); igx-ts project `files/` are an overlay on that, and changes to generated files are patches in `packages/igx-templates/igx-ts/projects/_base/scaffold.ts` (`igniteui-cli-template-anatomy` skill) - Component/template IDs (e.g., "grid", "combo") used by `ig add` command - Default framework is **jQuery** when not specified diff --git a/README.md b/README.md index 7048a85dc3..f953ae4c7a 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ To create a new project with Ignite UI for Angular use [ig new](https://github.c ```bash ig new "IG Project" --framework=angular --type=igx-ts --template=side-nav ``` +The project is created with the Angular CLI (`ng new`, then `ng add igniteui-angular`), so this needs network access and always installs the project's packages: `--skip-install` has no effect for Angular projects. + #### Generate Ignite UI for React project To create a new project with Ignite UI for React use [ig new](https://github.com/IgniteUI/igniteui-cli/wiki/New#creating-ignite-ui-for-react-applications-v400) by specifying `react` as framework and `igr-ts` as project type: @@ -287,12 +289,14 @@ yourself, if you hit that. Two things to know if you modify it: -* **All `ig` calls must happen before `npm install`.** `packages/cli/bin/execute.js` delegates to +* **Call the CLI through `cli`, never `packages/cli/bin/execute.js`.** The wrapper delegates to `node_modules/igniteui-cli` whenever one resolves inside the current directory, and every project - template lists `igniteui-cli` as a devDependency — so once a generated project has been installed, - `ig add` silently switches to the *published* CLI and its bundled templates. Scaffolding with - `--skip-install` keeps `PackageManager.queuePackage` from spawning installs, so dependencies are - written to `package.json` and installed once at the end. + template lists `igniteui-cli` as a devDependency. Angular projects install theirs during `ig new` + (`ng add` needs the packages), so through the wrapper `ig add` would silently switch to the + *published* CLI and its bundled templates. `cli` loads this checkout's `packages/cli/lib/cli.js` + instead. For the other frameworks, scaffolding with `--skip-install` still keeps + `PackageManager.queuePackage` from spawning installs, so dependencies are written to + `package.json` and installed once at the end. * **Exit codes are not sufficient.** `Util.error` logs and returns without setting one, so `ig add does-not-exist x` exits 0. Each step is judged on its exit code *and* an error scan of its log *and* an artifact check. diff --git a/packages/cli/README.md b/packages/cli/README.md index 891ab66b4b..7063a7fccc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -74,6 +74,7 @@ To create a new project with Ignite UI for Angular use [ig new](https://github.c ```bash ig new "IG Project" --framework=angular --type=igx-ts --template=side-nav ``` +The project is created with the Angular CLI (`ng new`, then `ng add igniteui-angular`), so this needs network access and always installs the project's packages: `--skip-install` has no effect for Angular projects. The `igniteui-cli` makes full use of the component definitions provided by [`igniteui/angular-templates`](../ng-schematics) package. You can find all of the available component templates in the [official documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/general/cli/component-templates). diff --git a/packages/cli/lib/commands/new.ts b/packages/cli/lib/commands/new.ts index f0dbbdc7bc..29c43be4d9 100644 --- a/packages/cli/lib/commands/new.ts +++ b/packages/cli/lib/commands/new.ts @@ -20,6 +20,18 @@ const _framework: { choices: [] }; +const IG_NEXT_STEPS = [ + "ig add start guided mode for adding views to the app", + "ig start starts a web server and opens the app in the default browser" +]; + +function logNextSteps(name: string, steps: string[]) { + Util.log(""); + Util.log("Next Steps:"); + Util.log(` cd ${Util.quoteIfNeeded(name)}`); + steps.forEach(step => Util.log(` ${step}`)); +} + const command: NewCommandType = { command: "new [name]", describe: "Creates a project", @@ -62,7 +74,7 @@ const command: NewCommandType = { }) .option("skip-install", { alias: "si", - describe: "Do not install packages after scaffolding", + describe: "Do not install packages after scaffolding (Angular projects install them anyway: `ng add` needs them)", type: "boolean" }) .option("template", { @@ -166,7 +178,8 @@ const command: NewCommandType = { }); if (typeof projTemplate.scaffold === "function") { - if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(argv.name)) { + const isBlazor = projTemplate.framework === "blazor"; + if (isBlazor && !/^[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"); } @@ -191,6 +204,12 @@ const command: NewCommandType = { } process.chdir(argv.name); + if (!isBlazor) { + // the scaffold installed the packages, so `ig add` installs the ones it adds right away + const config = ProjectConfig.localConfig(); + config.packagesInstalled = true; + ProjectConfig.setConfig(config); + } await configure(argv.framework, { agents: argv.agents as (AIAgentTarget | "none")[], assistants: argv.assistants as (AiCodingAssistant | "none")[] @@ -201,11 +220,7 @@ const command: NewCommandType = { Util.gitInit(process.cwd(), argv.name); } - const quotedName = Util.quoteIfNeeded(argv.name); - Util.log(""); - Util.log("Next Steps:"); - Util.log(` cd ${quotedName}`); - Util.log(` dotnet run --project ${quotedName}`); + logNextSteps(argv.name, isBlazor ? [`dotnet run --project ${Util.quoteIfNeeded(argv.name)}`] : IG_NEXT_STEPS); return; } @@ -234,11 +249,7 @@ const command: NewCommandType = { Util.gitInit(process.cwd(), argv.name); } - Util.log(""); - Util.log("Next Steps:"); - Util.log(` cd ${Util.quoteIfNeeded(argv.name)}`); - Util.log(" ig add start guided mode for adding views to the app"); - Util.log(" ig start starts a web server and opens the app in the default browser"); + logNextSteps(argv.name, IG_NEXT_STEPS); }, addChoices(choices: string[]): void { _framework.choices = choices; diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index b9afde8fc1..35a2cff843 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -116,14 +116,13 @@ function resolveSkillsRoots(framework: string): SkillsSource[] { candidates = new Set([...allPkgKeys, NPM_REACT, NPM_WEBCOMPONENTS]); } - const srcFs = new FsFileSystem(); for (const pkg of candidates) { const resolved = resolvePackage(pkg as keyof typeof UPGRADEABLE_PACKAGES); const skillsRoot = `node_modules/${resolved}/${AI_SKILLS_DIR_NAME}`; if (fs.directoryExists(skillsRoot) && !roots.some(r => r.path === skillsRoot)) { let version = "unknown"; try { - const pkgJson = JSON.parse(srcFs.readFile(`node_modules/${resolved}/package.json`)); + const pkgJson = JSON.parse(fs.readFile(`node_modules/${resolved}/package.json`)); version = pkgJson.version ?? "unknown"; } catch { /* version stays unknown */ } roots.push({ type: "package", packageName: resolved, packageVersion: version, path: skillsRoot }); @@ -148,11 +147,10 @@ function resolveSkillsRoots(framework: string): SkillsSource[] { */ export function copyAISkillsToProject(agents: AIAgentTarget[], framework: string): AISkillsCopyResult { const result: AISkillsCopyResult = { found: 0, skipped: 0, failed: 0, details: [], sources: [] }; - // Source reads (glob + readFile) always use physical FS - skill files can - // come from sources outside the project virtual tree (external/global package): - const srcFs = new FsFileSystem(); // Destination writes respect the App FS (which may be virtual): const destFs = App.container.get(FS_TOKEN); + // Bundled skills are template files outside the project, read from the physical FS: + const physicalFs = new FsFileSystem(); const skillsSources = resolveSkillsRoots(framework); if (!skillsSources.length) { @@ -166,6 +164,8 @@ export function copyAISkillsToProject(agents: AIAgentTarget[], framework: string const outputDir = getSkillsDir(agent); for (const source of skillsSources) { + // package skills are read through the App FS that found them, whose root may not be the cwd + const srcFs = source.type === "package" ? destFs : physicalFs; const skillsRoot = source.path; const rawPaths = srcFs.glob(skillsRoot, "**/*"); const pkgDirName = multiRoot ? path.basename(path.dirname(skillsRoot)) : ""; diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/setup_angular.md b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/setup_angular.md index 2be20aaebf..3fc21cea42 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/setup_angular.md +++ b/packages/igniteui-mcp/igniteui-doc-mcp/src/tools/setup_angular.md @@ -43,6 +43,8 @@ ig start ```bash ng new --collection="@igniteui/angular-schematics" ``` + +Both options run `ng new` and `ng add igniteui-angular`, which install packages while the project is created. Do not pass `--dry-run` to Option B: it is not supported and stops with an error. Or to add Ignite UI to an existing Angular project: @@ -66,7 +68,7 @@ ig new MyApp --framework=angular --type=igx-ts --template=side-nav | Flag | Description | |---|---| | `--skip-git` | Skip git repository initialization | -| `--skip-install` | Skip npm package installation (useful in CI) | +| `--skip-install` | Has no effect for Angular projects: `ng add igniteui-angular` installs the packages while the project is created | *For theming use igniteui-theming MCP* diff --git a/packages/igx-templates/constants.ts b/packages/igx-templates/constants.ts index 4a55269e3a..f0132cc835 100644 --- a/packages/igx-templates/constants.ts +++ b/packages/igx-templates/constants.ts @@ -1 +1,3 @@ export const IGNITEUI_ANGULAR_PACKAGE = "igniteui-angular@~22.2.0"; +/** Set by the project scaffold for `ng add igniteui-angular`, whose cli-config then skips ai-config; the caller runs its own with prompts */ +export const SKIP_AI_CONFIG_ENV = "IGNITEUI_SKIP_AI_CONFIG"; diff --git a/packages/igx-templates/igx-ts/projects/_base/files/.prettierrc b/packages/igx-templates/igx-ts/projects/_base/files/.prettierrc deleted file mode 100644 index d6c16d7ee7..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/.prettierrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "printWidth": 100, - "singleQuote": true, - "overrides": [ - { - "files": "*.html", - "options": { - "parser": "angular" - } - } - ] -} diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__editorconfig b/packages/igx-templates/igx-ts/projects/_base/files/__dot__editorconfig deleted file mode 100644 index f166060da1..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/__dot__editorconfig +++ /dev/null @@ -1,17 +0,0 @@ -# Editor configuration, see https://editorconfig.org -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.ts] -quote_type = single -ij_typescript_use_double_quotes = false - -[*.md] -max_line_length = off -trim_trailing_whitespace = false diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__gitignore b/packages/igx-templates/igx-ts/projects/_base/files/__dot__gitignore deleted file mode 100644 index b1d225e26e..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/__dot__gitignore +++ /dev/null @@ -1,43 +0,0 @@ -# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. - -# Compiled output -/dist -/tmp -/out-tsc -/bazel-out - -# Node -/node_modules -npm-debug.log -yarn-error.log - -# IDEs and editors -.idea/ -.project -.classpath -.c9/ -*.launch -.settings/ -*.sublime-workspace - -# Visual Studio Code -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -.history/* - -# Miscellaneous -/.angular/cache -.sass-cache/ -/connect.lock -/coverage -/libpeerconnection.log -testem.log -/typings -__screenshots__/ - -# System files -.DS_Store -Thumbs.db diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/extensions.json b/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/extensions.json deleted file mode 100644 index 77b374577d..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/extensions.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 - "recommendations": ["angular.ng-template"] -} diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/launch.json b/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/launch.json deleted file mode 100644 index 925af83705..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/launch.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "ng serve", - "type": "chrome", - "request": "launch", - "preLaunchTask": "npm: start", - "url": "http://localhost:4200/" - }, - { - "name": "ng test", - "type": "chrome", - "request": "launch", - "preLaunchTask": "npm: test", - "url": "http://localhost:9876/debug.html" - } - ] -} diff --git a/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/tasks.json b/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/tasks.json deleted file mode 100644 index a298b5bd87..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/__dot__vscode/tasks.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "start", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": { - "regexp": "(.*?)" - }, - "endsPattern": { - "regexp": "bundle generation complete" - } - } - } - }, - { - "type": "npm", - "script": "test", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": { - "regexp": "(.*?)" - }, - "endsPattern": { - "regexp": "bundle generation complete" - } - } - } - } - ] -} diff --git a/packages/igx-templates/igx-ts/projects/_base/files/angular.json b/packages/igx-templates/igx-ts/projects/_base/files/angular.json deleted file mode 100644 index 1e135753a1..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/angular.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "cli": { - "packageManager": "npm", - "schematicCollections": ["angular-eslint"] - }, - "newProjectRoot": "projects", - "projects": { - "<%=dashName%>": { - "projectType": "application", - "schematics": { - "@schematics/angular:component": { - "style": "scss" - } - }, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "outputPath": "dist/<%=dashName%>", - "index": "src/index.html", - "browser": "src/main.ts", - "polyfills": ["zone.js"], - "tsConfig": "tsconfig.app.json", - "inlineStyleLanguage": "scss", - "assets": ["src/favicon.ico", "src/assets"], - "styles": [ - "src/styles.scss"<%=DefaultTheme%> - ], - "stylePreprocessorOptions": { - "includePaths": ["node_modules"] - } - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "initial", - "maximumWarning": "5mb", - "maximumError": "40mb" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "8kB", - "maximumError": "2mb" - } - ], - "outputHashing": "all" - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true, - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular/build:dev-server", - "configurations": { - "production": { - "buildTarget": "<%=dashName%>:build:production" - }, - "development": { - "buildTarget": "<%=dashName%>:build:development" - } - }, - "defaultConfiguration": "development" - }, - "extract-i18n": { - "builder": "@angular/build:extract-i18n", - "options": { - "buildTarget": "<%=dashName%>:build" - } - }, - "test": { - "builder": "@angular/build:unit-test", - "options": { - "runnerConfig": true - } - }, - "lint": { - "builder": "@angular-eslint/builder:lint", - "options": { - "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] - } - } - } - } - } -} diff --git a/packages/igx-templates/igx-ts/projects/_base/files/package.json b/packages/igx-templates/igx-ts/projects/_base/files/package.json deleted file mode 100644 index eb28a50449..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "<%=dashName%>", - "version": "0.0.0", - "scripts": { - "ng": "ng", - "start": "ng serve -o", - "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test --browsers=chromium", - "lint": "ng lint" - }, - "private": true, - "dependencies": { - "@angular/animations": "~22.2.0", - "@angular/common": "~22.2.0", - "@angular/compiler": "~22.2.0", - "@angular/core": "~22.2.0", - "@angular/forms": "~22.2.0", - "@angular/platform-browser": "~22.2.0", - "@angular/platform-browser-dynamic": "~22.2.0", - "@angular/router": "~22.2.0", - "@igniteui/material-icons-extended": "^3.0.2", - "igniteui-angular": "~22.2.0", - "minireset.css": "~0.0.7", - "rxjs": "~7.8.1", - "tslib": "~2.8.1", - "zone.js": "~0.16.0" - }, - "devDependencies": { - "@angular/build": "~22.2.0", - "@angular/cli": "~22.2.0", - "@angular/compiler-cli": "~22.2.0", - "@eslint/js": "^10.0.1", - "@vitest/browser-playwright": "^4.1.0", - "angular-eslint": "~22.0.0", - "eslint": "^10.3.0", - "igniteui-cli": "~<%=cliVersion%>", - "playwright": "^1.58.2", - "prettier": "^3.8.1", - "typescript": "~6.0.2", - "typescript-eslint": "8.60.1", - "vitest": "^4.1.0" - } -} diff --git a/packages/igx-templates/igx-ts/projects/_base/files/public/favicon.ico b/packages/igx-templates/igx-ts/projects/_base/files/public/favicon.ico deleted file mode 100644 index 57614f9c96..0000000000 Binary files a/packages/igx-templates/igx-ts/projects/_base/files/public/favicon.ico and /dev/null differ diff --git a/packages/igx-templates/igx-ts/projects/_base/files/src/index.html b/packages/igx-templates/igx-ts/projects/_base/files/src/index.html deleted file mode 100644 index 469f8ebcb1..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/src/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Ignite UI for Angular - - - - - - - - - diff --git a/packages/igx-templates/igx-ts/projects/_base/files/src/main.ts b/packages/igx-templates/igx-ts/projects/_base/files/src/main.ts deleted file mode 100644 index 40016cf7ca..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/src/main.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { bootstrapApplication } from '@angular/platform-browser'; -import { App } from './app/app'; -import { appConfig } from './app/app.config'; - -bootstrapApplication(App, appConfig) - .catch((err) => console.error(err)); diff --git a/packages/igx-templates/igx-ts/projects/_base/files/tsconfig.app.json b/packages/igx-templates/igx-ts/projects/_base/files/tsconfig.app.json deleted file mode 100644 index 264f459bf8..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/tsconfig.app.json +++ /dev/null @@ -1,15 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/app", - "types": [] - }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "src/**/*.spec.ts" - ] -} diff --git a/packages/igx-templates/igx-ts/projects/_base/files/tsconfig.json b/packages/igx-templates/igx-ts/projects/_base/files/tsconfig.json deleted file mode 100644 index 07c56f68a5..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/tsconfig.json +++ /dev/null @@ -1,34 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "compileOnSave": false, - "compilerOptions": { - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "skipLibCheck": true, - "isolatedModules": true, - "experimentalDecorators": true, - "importHelpers": true, - "target": "ES2022", - "module": "preserve", - "moduleResolution": "Bundler" - }, - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true - }, - "files": [], - "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.spec.json" - } - ] -} diff --git a/packages/igx-templates/igx-ts/projects/_base/files/tsconfig.spec.json b/packages/igx-templates/igx-ts/projects/_base/files/tsconfig.spec.json deleted file mode 100644 index d38370633f..0000000000 --- a/packages/igx-templates/igx-ts/projects/_base/files/tsconfig.spec.json +++ /dev/null @@ -1,15 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/spec", - "types": [ - "vitest/globals" - ] - }, - "include": [ - "src/**/*.d.ts", - "src/**/*.spec.ts" - ] -} diff --git a/packages/igx-templates/igx-ts/projects/_base/index.ts b/packages/igx-templates/igx-ts/projects/_base/index.ts index b54bff4474..d72f1d7ed5 100644 --- a/packages/igx-templates/igx-ts/projects/_base/index.ts +++ b/packages/igx-templates/igx-ts/projects/_base/index.ts @@ -1,6 +1,25 @@ -import { ControlExtraConfiguration, NPM_ANGULAR, ProjectTemplate, Util, updateWorkspace } from "@igniteui/cli-core"; +import { + ControlExtraConfiguration, NPM_ANGULAR, ProjectTemplate, ScaffoldOptions, Util, updateWorkspace +} from "@igniteui/cli-core"; +import * as fs from "fs"; import * as path from "path"; +import { + CLI_PACKAGE, generateProject, npmInstall, npmInstallCommand, patchAngularJson, patchPackageJson, updateJson +} from "./scaffold"; +/** What a project's own files need on top of the generated project */ +export interface ProjectExtras { + /** Packages installed at their latest version, or at the version or range an entry names */ + dependencies: string[]; + devDependencies: string[]; + /** Global types added to tsconfig.app.json and tsconfig.spec.json */ + types: string[]; +} + +/** + * Projects are created by the Angular CLI (`ng new`, `ng add igniteui-angular`). `templatePaths` hold only + * the files laid over that; `scaffold` patches the generated angular.json, package.json and tsconfigs. + */ export class BaseIgxProject implements ProjectTemplate { public id: string = "base"; public name = "base"; @@ -37,13 +56,79 @@ $app-palette: palette($primary, $secondary, $surface); @include typography($font-family: $material-typeface, $type-scale: $material-type-scale); @include theme($app-palette); `; - private DEFAULT_THEME = `, - "node_modules/igniteui-angular/styles/igniteui-angular.css"`; + + /** The extras for a project created with Angular `angularMajor`, which packages that follow Angular's majors match */ + protected extras: (angularMajor: string) => ProjectExtras = () => ({ dependencies: [], devDependencies: [], types: [] }); public get templatePaths(): string[] { return [path.join(__dirname, "files")]; } + /** + * Creates the project with the Angular CLI and lays `templatePaths` over it. + * `options.extraConfig` overrides template variables; its `cliPackage` names the package providing the + * CLI commands (default `igniteui-cli`). + */ + public async scaffold(options: ScaffoldOptions): Promise { + const { cliPackage = CLI_PACKAGE, ...variables } = options.extraConfig || {}; + const config = { ...this.generateConfig(options.name, options.theme), ...variables }; + const projectPath = path.join(process.cwd(), options.name); + if (fs.existsSync(projectPath)) { + Util.error(`Folder "${options.name}" already exists!`, "red"); + return false; + } + if (options.skipInstall) { + Util.warn("--skip-install does not apply to the initial setup: `ng add` installs the packages the project starts with.", + "yellow"); + } + + /** The installs not yet done, as `npmInstall` arguments; set once the project files are complete */ + let installs: [string[], boolean][] = []; + try { + const angularMajor = generateProject(config.dashName, projectPath); + const extras = this.extras(angularMajor); + for (const templatePath of this.templatePaths) { + await Util.processTemplates(templatePath, projectPath, config, this.delimiters, false); + } + updateJson(path.join(projectPath, "angular.json"), json => patchAngularJson(json, config)); + updateJson(path.join(projectPath, "package.json"), pkg => patchPackageJson(pkg, cliPackage, config.cliVersion)); + if (extras.types.length) { + for (const file of ["tsconfig.app.json", "tsconfig.spec.json"]) { + updateJson(path.join(projectPath, file), json => { + json.compilerOptions.types = [...(json.compilerOptions.types || []), ...extras.types]; + return json; + }); + } + } + // the first also installs the package.json changes above; playwright is the browser for @vitest/browser-playwright + installs = [[["playwright", ...extras.devDependencies], true], [extras.dependencies, false]]; + installs = installs.filter(([packages]) => packages.length); + while (installs.length) { + npmInstall(projectPath, ...installs[0]); + installs.shift(); + } + } catch (error) { + if (installs.length) { + // the project is complete but for its packages (a network error, an unpublished CLI version), so it is kept + const commands = installs.map(([packages, dev]) => npmInstallCommand(packages, dev)).join(" && "); + Util.warn(`"${options.name}" is kept; finish installing its packages there with: ${commands}`, "yellow"); + } else { + // the folder is this run's own (checked above), so a retry starts clean + try { + fs.rmSync(projectPath, { recursive: true, force: true }); + } catch { + // e.g. EBUSY/EPERM on Windows while npm still holds native binaries + Util.warn(`Could not remove "${options.name}"; delete it before retrying.`, "yellow"); + } + } + Util.error(`Project creation failed: ${error.message}`, "red"); + return false; + } + + Util.log(Util.greenCheck() + " Project Created"); + return true; + } + public installModules(): void { throw new Error("Method not implemented."); } @@ -62,7 +147,6 @@ $app-palette: palette($primary, $secondary, $surface); cliVersion: Util.version(), CustomTheme: "", dashName: Util.lowerDashed(name), - DefaultTheme: "", dot: ".", path: name, projectTemplate: this.id, @@ -78,7 +162,6 @@ $app-palette: palette($primary, $secondary, $surface); break; case "Default": default: - config["DefaultTheme"] = this.DEFAULT_THEME; config["themePath"] = "node_modules/igniteui-angular/styles/igniteui-angular.css"; break; } diff --git a/packages/igx-templates/igx-ts/projects/_base/scaffold.ts b/packages/igx-templates/igx-ts/projects/_base/scaffold.ts new file mode 100644 index 0000000000..19f3fbdcb6 --- /dev/null +++ b/packages/igx-templates/igx-ts/projects/_base/scaffold.ts @@ -0,0 +1,131 @@ +import { Util } from "@igniteui/cli-core"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as ts from "typescript"; +import { IGNITEUI_ANGULAR_PACKAGE, SKIP_AI_CONFIG_ENV } from "../../../constants"; + +/** Package that provides the `ig` commands in projects created by `ig new` */ +export const CLI_PACKAGE = "igniteui-cli"; +/** Added by `ng add igniteui-angular`; projects created through the schematics keep it instead of igniteui-cli */ +const SCHEMATICS_PACKAGE = "@igniteui/angular-schematics"; + +/** + * AI config the ai-config step of `ng add igniteui-angular` writes; `ig new` and the ng-new schematic run their own. + * `SKIP_AI_CONFIG_ENV` skips that step; these are removed for @igniteui/angular-schematics versions without the check. + */ +const NG_ADD_AI_CONFIG = [".agents", ".claude", "AGENTS.md", ".mcp.json"]; + +/** Reads a field of the newest published version that matches `spec` (`name@range`) */ +function npmView(spec: string, field: string) { + const result = JSON.parse(Util.execSync(`npm view "${spec}" ${field} --json`, { encoding: "utf8" }) as string); + return Array.isArray(result) ? result[result.length - 1] : result; +} + +/** + * Creates the project at `projectPath` with `ng new`, followed by `ng add angular-eslint` and `ng add igniteui-angular`, + * which also installs the packages. `igniteui-angular` comes from the templates' `IGNITEUI_ANGULAR_PACKAGE` range and + * Angular at the newest major that supports, so the generated project matches what the component templates use. + * Returns that Angular major. + */ +export function generateProject(dashName: string, projectPath: string): string { + const angularMajor = npmView(`@angular/core@${npmView(IGNITEUI_ANGULAR_PACKAGE, "peerDependencies")["@angular/core"]}`, + "version").split(".")[0]; + const ng = (command: string, cwd: string) => + Util.execSync(`npx -y @angular/cli@${angularMajor} ${command} --defaults --interactive=false`, { + cwd, + stdio: "inherit", + env: { ...process.env, NG_CLI_ANALYTICS: "false", [SKIP_AI_CONFIG_ENV]: "true" } + }); + + // `ng new` refuses to run inside an Angular workspace, so it runs in a temp folder outside any + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "igx-new-")); + try { + ng(`new ${dashName} --directory ${dashName} --style=scss --routing --ssr=false ` + + // zone.js stays until the templates are verified zoneless + "--zoneless=false --ai-config=none --skip-git --skip-install --package-manager=npm", tmp); + fs.cpSync(path.join(tmp, dashName), projectPath, { recursive: true }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + + // angular-eslint majors follow Angular's + ng(`add angular-eslint@${angularMajor} --skip-confirmation`, projectPath); + ng(`add ${IGNITEUI_ANGULAR_PACKAGE} --skip-confirmation`, projectPath); + + for (const entry of NG_ADD_AI_CONFIG) { + fs.rmSync(path.join(projectPath, entry), { recursive: true, force: true }); + } + return angularMajor; +} + +/** The `npm install` command `npmInstall` runs */ +export function npmInstallCommand(packages: string[], dev = false) { + return `npm install ${dev ? "--save-dev " : ""}${packages.join(" ")} --no-audit --no-fund`; +} + +/** + * Installs packages at their latest version, or at the version or range an entry names, + * which also brings the lock file and node_modules in line with package.json + */ +export function npmInstall(projectPath: string, packages: string[], dev = false) { + Util.execSync(npmInstallCommand(packages, dev), { + cwd: projectPath, + stdio: "inherit" + }); +} + +/** Applies our deviations from the angular.json `ng new` generates */ +export function patchAngularJson(json: any, config: { [key: string]: any }) { + const architect = json.projects[config.dashName].architect; + const build = architect.build; + // `public/` from `ng new` is served at the root; overlays keep their files in src/assets + build.options.assets.push("src/assets"); + if (!config.CustomTheme) { + build.options.styles.push(config.themePath); + } + build.configurations.production.budgets = [ + { type: "initial", maximumWarning: "5mb", maximumError: "40mb" }, + { type: "anyComponentStyle", maximumWarning: "8kB", maximumError: "2mb" } + ]; + build.configurations.development.fileReplacements = [{ + replace: "src/environments/environment.ts", + with: "src/environments/environment.development.ts" + }]; + architect.test.options = { ...architect.test.options, runnerConfig: true }; + architect["extract-i18n"] = { + builder: "@angular/build:extract-i18n", + options: { buildTarget: `${config.dashName}:build` } + }; + return json; +} + +/** Applies our deviations from the package.json `ng new` and `ng add` generate; `cliPackage` provides the CLI commands */ +export function patchPackageJson(pkg: any, cliPackage: string, cliVersion: string) { + const { dependencies: deps, devDependencies: devDeps } = pkg; + Object.assign(pkg.scripts, { start: "ng serve -o", test: "ng test --browsers=chromium" }); + // `ng add` saves `^`; the component templates are written for the IGNITEUI_ANGULAR_PACKAGE range + const [igxPackage, igxRange] = IGNITEUI_ANGULAR_PACKAGE.split(/@(?=[^\/]+$)/); + deps[igxPackage] = igxRange; + // provideAnimations in the app configs + deps["@angular/animations"] = deps["@angular/core"]; + // tests run in a real browser through @vitest/browser-playwright, which must match vitest exactly + delete devDeps.jsdom; + devDeps["@vitest/browser-playwright"] = devDeps.vitest; + if (cliPackage !== SCHEMATICS_PACKAGE) { + delete devDeps[SCHEMATICS_PACKAGE]; + } + devDeps[cliPackage] = `~${cliVersion}`; + return pkg; +} + +/** Updates a JSON file in place; tsconfig files may have comments, of which the leading ones are kept */ +export function updateJson(file: string, update: (json: any) => any) { + const text = fs.readFileSync(file, "utf8"); + const { config, error } = ts.parseConfigFileTextToJson(file, text); + if (error) { + throw new Error(`Could not parse ${file}: ${ts.flattenDiagnosticMessageText(error.messageText, "\n")}`); + } + const header = (ts.getLeadingCommentRanges(text, 0) || []).map(range => text.slice(range.pos, range.end) + "\n").join(""); + fs.writeFileSync(file, header + JSON.stringify(update(config), null, 2) + "\n"); +} diff --git a/packages/igx-templates/igx-ts/projects/_base_with_home/files/src/favicon.ico b/packages/igx-templates/igx-ts/projects/_base_with_home/files/src/favicon.ico deleted file mode 100644 index 57614f9c96..0000000000 Binary files a/packages/igx-templates/igx-ts/projects/_base_with_home/files/src/favicon.ico and /dev/null differ diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/package.json b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/package.json deleted file mode 100644 index a45da6dbc4..0000000000 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "<%=dashName%>", - "version": "0.0.0", - "scripts": { - "ng": "ng", - "start": "ng serve -o", - "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test --browsers=chromium", - "lint": "ng lint" - }, - "private": true, - "dependencies": { - "@angular/animations": "~22.2.0", - "@angular/common": "~22.2.0", - "@angular/compiler": "~22.2.0", - "@angular/core": "~22.2.0", - "@angular/forms": "~22.2.0", - "@angular/platform-browser": "~22.2.0", - "@angular/platform-browser-dynamic": "~22.2.0", - "@angular/router": "~22.2.0", - "angular-auth-oidc-client": "~22.2.0", - "igniteui-angular": "~22.2.0", - "minireset.css": "~0.0.7", - "rxjs": "~7.8.1", - "tslib": "~2.8.1", - "zone.js": "~0.16.0" - }, - "devDependencies": { - "@angular/build": "~22.2.0", - "@angular/cli": "~22.2.0", - "@angular/compiler-cli": "~22.2.0", - "@angular/language-service": "~22.2.0", - "@eslint/js": "^10.0.1", - "@types/facebook-js-sdk": "~3.3.6", - "@vitest/browser-playwright": "^4.1.0", - "angular-eslint": "~22.0.0", - "eslint": "^10.3.0", - "igniteui-cli": "~<%=cliVersion%>", - "playwright": "^1.58.2", - "prettier": "^3.8.1", - "typescript": "~6.0.2", - "typescript-eslint": "8.60.1", - "vitest": "^4.1.0" - } -} diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/tsconfig.app.json b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/tsconfig.app.json deleted file mode 100644 index 8fea7d1ab5..0000000000 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/tsconfig.app.json +++ /dev/null @@ -1,24 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "lib": [ - "es2019", - "dom" - ], - "outDir": "./out-tsc/app", - "types": [ - "facebook-js-sdk" - ] - }, - "files": [ - "src/main.ts" - ], - "include": [ - "src/**/*.d.ts" - ], - "exclude": [ - "src/**/*.spec.ts" - ] -} diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/tsconfig.spec.json b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/tsconfig.spec.json deleted file mode 100644 index f35045b962..0000000000 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/tsconfig.spec.json +++ /dev/null @@ -1,18 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/spec", - "types": [ - "vitest/globals", - "node", - "facebook-js-sdk" - ], - "module": "esnext" - }, - "include": [ - "src/**/*.d.ts", - "src/**/*.spec.ts" - ] -} diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/index.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/index.ts index a99790d89c..dedbc95279 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/index.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/index.ts @@ -1,7 +1,16 @@ import { ProjectTemplate } from "@igniteui/cli-core"; import * as path from "path"; +import { ProjectExtras } from "../_base"; import { SideNavProject } from "../side-nav"; +/** What the authentication files need, shared with side-nav-mini-auth */ +export const authExtras = (angularMajor: string): ProjectExtras => ({ + // both follow Angular's majors; at their latest they would outrun the project's Angular once a new major ships + dependencies: [`angular-auth-oidc-client@${angularMajor}`], + devDependencies: [`@angular/language-service@${angularMajor}`, "@types/facebook-js-sdk"], + types: ["facebook-js-sdk"] +}); + export class AuthSideProject extends SideNavProject implements ProjectTemplate { public id: string = "side-nav-auth"; public name = "Side navigation + login"; @@ -11,6 +20,7 @@ export class AuthSideProject extends SideNavProject implements ProjectTemplate { public projectType: string = "igx-ts"; public hasExtraConfiguration = false; public isHidden = true; + protected extras = authExtras; public get templatePaths() { return [...super.templatePaths, path.join(__dirname, "files")]; diff --git a/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/index.ts b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/index.ts index 2d5388151a..a256b7d4fc 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/index.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/index.ts @@ -1,6 +1,7 @@ import { ProjectTemplate } from "@igniteui/cli-core"; import * as path from "path"; import { SideNavMiniProject } from "../side-nav-mini"; +import { authExtras } from "../side-nav-auth"; export class SideNavMiniAuthProject extends SideNavMiniProject implements ProjectTemplate { public id: string = "side-nav-mini-auth"; @@ -11,6 +12,7 @@ export class SideNavMiniAuthProject extends SideNavMiniProject implements Projec public projectType: string = "igx-ts"; public hasExtraConfiguration = false; public isHidden = true; + protected extras = authExtras; public get templatePaths() { return [ diff --git a/packages/igx-templates/index.ts b/packages/igx-templates/index.ts index 0d675346bb..f4da21df5b 100644 --- a/packages/igx-templates/index.ts +++ b/packages/igx-templates/index.ts @@ -3,5 +3,6 @@ import standalone from './igx-ts'; export * from './IgniteUIForAngularTemplate'; export * from './AngularTypeScriptFileUpdate'; export * from './types'; +export { SKIP_AI_CONFIG_ENV } from './constants'; export default [standalone]; diff --git a/packages/ng-schematics/README.md b/packages/ng-schematics/README.md index 77ce5227d3..48c8618653 100644 --- a/packages/ng-schematics/README.md +++ b/packages/ng-schematics/README.md @@ -18,6 +18,10 @@ If you want to use Ignite UI for Angular controls right off the bat, you can gen ng new --collection="@igniteui/angular-schematics" ``` +The project is created with the Angular CLI's own `ng new` and `ng add igniteui-angular`, so this needs network access and always installs the packages the project starts with. `--skip-install` only skips the final install, which picks up packages the guided setup adds afterwards, and starting the app. + +`--dry-run` is not supported: the project is created and installed on disk, which a dry run cannot hold back, so the command stops with an error before creating anything. + ### Add to existing project You can add the `@igniteui/angular-schematics` collection to an existing Angular project by running: diff --git a/packages/ng-schematics/src/app-projects/index.ts b/packages/ng-schematics/src/app-projects/index.ts deleted file mode 100644 index 09486255f9..0000000000 --- a/packages/ng-schematics/src/app-projects/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - apply, chain, MergeStrategy, mergeWith, Rule, - SchematicContext, template, Tree, url } from "@angular-devkit/schematics"; -import { Util } from "@igniteui/cli-core"; -import { join } from "path"; -import { NewProjectOptions } from "./schema"; - -export default function(options: NewProjectOptions): Rule { - return (tree: Tree, _context: SchematicContext) => { - let config: { [key: string]: any }; - let templateId = options.templateId; - let templatePaths = options.templatePaths || []; - - if (options.projTemplate) { - config = options.projTemplate.generateConfig(options.name, options.theme); - templateId = options.projTemplate.id; - templatePaths = options.projTemplate.templatePaths; - } else { - if (!options.config || !templatePaths.length) { - throw new Error("Missing app-projects template data."); - } - config = options.config; - } - // update version with schematics one - config.cliVersion = Util.version(join(__dirname, "../../package.json")); - return chain([ - (_tree: Tree, context: SchematicContext) => { - context.logger.debug(`Project template: ${templateId}`); - }, - ...templatePaths.map(templatePath => - mergeWith( - apply(url(Util.relativePath(__filename, templatePath, true)), [ - template(config) - ] - ), MergeStrategy.Overwrite) - ), - (host: Tree, _hostContext: SchematicContext) => { - const content = host.read("./package.json")!.toString(); - host.overwrite("./package.json", content.replace(`"igniteui-cli":`, `"@igniteui/angular-schematics":`)); - } - ]); - }; -} diff --git a/packages/ng-schematics/src/app-projects/index_spec.ts b/packages/ng-schematics/src/app-projects/index_spec.ts deleted file mode 100644 index 3285f7025f..0000000000 --- a/packages/ng-schematics/src/app-projects/index_spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Tree } from "@angular-devkit/schematics"; -import { SchematicTestRunner } from "@angular-devkit/schematics/testing"; -import * as path from "path"; - -const collectionPath = path.join(__dirname, "../collection.json"); - -describe("app-projects", () => { - it("works", async () => { - const runner = new SchematicTestRunner("schematics", collectionPath); - const tree = Tree.empty(); - const mockOptions = { - name: "mock-name", - config: {}, - templateId: "mock-id", - templatePaths: ["mock-template"], - theme: "mock-theme" - }; - spyOn(tree, "read").and.returnValue(Buffer.from(`Mock package content "igniteui-cli":`)); - spyOn(tree, "overwrite"); - await runner.runSchematic("app-projects", mockOptions, tree); - - expect(tree.read).toHaveBeenCalledWith("./package.json"); - expect(tree.overwrite).toHaveBeenCalledWith("./package.json", `Mock package content "@igniteui/angular-schematics":`); - }); -}); diff --git a/packages/ng-schematics/src/app-projects/schema.ts b/packages/ng-schematics/src/app-projects/schema.ts deleted file mode 100644 index dd9322e214..0000000000 --- a/packages/ng-schematics/src/app-projects/schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ProjectTemplate } from "@igniteui/cli-core"; - -export abstract class NewProjectOptions { - public projTemplate?: ProjectTemplate; - public templateId?: string; - public templatePaths?: string[]; - public config?: { [key: string]: any }; - public theme: string; - public name: string; -} diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index c55bdc66d3..615da26a65 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -3,7 +3,7 @@ import { DependencyNotFoundException } from "@angular-devkit/core"; import { chain, FileDoesNotExistException, Rule, SchematicContext, Tree } from "@angular-devkit/schematics"; import { RunSchematicTask } from "@angular-devkit/schematics/tasks"; import { addClassToBody, addMcpServers, AIAgentTarget, AiCodingAssistant, App, copyAgentInstructionFiles, copyAISkillsToProject, FormatSettings, McpServerEntry, NPM_ANGULAR, resolvePackage, TEMPLATE_MANAGER, TypeScriptAstTransformer, TypeScriptUtils } from "@igniteui/cli-core"; -import { AngularTypeScriptFileUpdate } from "@igniteui/angular-templates"; +import { AngularTypeScriptFileUpdate, SKIP_AI_CONFIG_ENV } from "@igniteui/angular-templates"; import { createCliConfig } from "../utils/cli-config"; import { setVirtual } from "../utils/NgFileSystem"; import { addFontsToIndexHtml, getProjects, importDefaultTheme } from "../utils/theme-import"; @@ -153,8 +153,10 @@ export function addAIConfig(options: { agents?: AIAgentTarget[]; assistants?: st export default function (): Rule { return (tree: Tree, context: SchematicContext) => { appInit(tree); - // queue ai-config with prompts: - context.addTask(new RunSchematicTask("ai-config", {})); + // queue ai-config with prompts, unless the project scaffold runs its own after `ng add`: + if (!process.env[SKIP_AI_CONFIG_ENV]) { + context.addTask(new RunSchematicTask("ai-config", {})); + } return chain([ importStyles(), diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index f30b34358e..a1858f9d4f 100644 --- a/packages/ng-schematics/src/cli-config/index_spec.ts +++ b/packages/ng-schematics/src/cli-config/index_spec.ts @@ -4,6 +4,7 @@ import { DependencyNotFoundException } from "@angular-devkit/core"; import { EmptyTree } from "@angular-devkit/schematics"; import { SchematicTestRunner, UnitTestTree } from "@angular-devkit/schematics/testing"; import { App, FEED_ANGULAR, NPM_ANGULAR, TEMPLATE_MANAGER } from "@igniteui/cli-core"; +import { SKIP_AI_CONFIG_ENV } from "@igniteui/angular-templates"; import { SchematicsTemplateManager } from "../SchematicsTemplateManager"; describe("cli-config schematic", () => { @@ -446,6 +447,18 @@ export const appConfig: ApplicationConfig = { expect(taskOptions).toContain(jasmine.objectContaining({ name: "ai-config" })); }); + it("should not schedule the ai-config schematic task when the project scaffold runs its own", async () => { + process.env[SKIP_AI_CONFIG_ENV] = "true"; + try { + await runner.runSchematic("cli-config", {}, tree); + } finally { + delete process.env[SKIP_AI_CONFIG_ENV]; + } + + const taskOptions = runner.tasks.map(task => task.options); + expect(taskOptions).not.toContain(jasmine.objectContaining({ name: "ai-config" })); + }); + describe("ai-config schematic", () => { const mcpFilePath = "/.vscode/mcp.json"; diff --git a/packages/ng-schematics/src/collection.json b/packages/ng-schematics/src/collection.json index 6dfaebdaf5..216954a925 100644 --- a/packages/ng-schematics/src/collection.json +++ b/packages/ng-schematics/src/collection.json @@ -7,11 +7,6 @@ "schema": "./ng-new/schema.json", "hidden": true }, - "app-projects": { - "description": "A schematic using a source and a schema to validate options.", - "factory": "./app-projects/index", - "hidden": true - }, "start": { "description": "Run npm start", "factory": "./start/index", diff --git a/packages/ng-schematics/src/ng-new/index.ts b/packages/ng-schematics/src/ng-new/index.ts index 786df3b23f..f04519502e 100644 --- a/packages/ng-schematics/src/ng-new/index.ts +++ b/packages/ng-schematics/src/ng-new/index.ts @@ -1,10 +1,6 @@ import { - apply, + callRule, chain, - empty, - MergeStrategy, - mergeWith, - move, Rule, schematic, SchematicContext, @@ -12,9 +8,10 @@ import { TaskId, Tree } from "@angular-devkit/schematics"; import { NodePackageInstallTask, RepositoryInitializerTask, RunSchematicTask } from "@angular-devkit/schematics/tasks"; +// not exported from the package root; start/index.ts imports it the same way +import { ScopedTree } from "@angular-devkit/schematics/src/tree/scoped"; import { App, GoogleAnalytics, ProjectLibrary, ProjectTemplate, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; -import { defer, Observable } from "rxjs"; -import { NewProjectOptions } from "../app-projects/schema"; +import { defer, map, Observable } from "rxjs"; import { SchematicsPromptSession } from "../prompt/SchematicsPromptSession"; import { SchematicsTemplateManager } from "../SchematicsTemplateManager"; import { setVirtual } from "../utils/NgFileSystem"; @@ -25,15 +22,31 @@ interface IgxSchematicContext extends SchematicContext { theme: string; } +/** Runs a rule with the project folder as the root of the tree */ +function inProject(getName: () => string, rule: Rule): Rule { + return (tree: Tree, context: SchematicContext) => + callRule(rule, new ScopedTree(tree, getName()), context).pipe(map(() => tree)); +} + +/** Whether the workflow runs with `--dry-run`, which workflows keep to themselves: BaseWorkflow, which the Angular CLI's extends, in `_dryRun` */ +function isDryRun(context: SchematicContext): boolean { + return !!(context.engine.workflow as any)?._dryRun; +} + export function newProject(options: OptionsSchema): Rule { - return (_host: Tree, _hostContext: IgxSchematicContext) => { + return (_host: Tree, hostContext: IgxSchematicContext) => { + // the scaffold writes the project and installs its packages on disk, past the workflow's dry-run sink and task skipping + if (isDryRun(hostContext)) { + throw new SchematicsException("--dry-run is not supported: `ng new` and `ng add igniteui-angular` " + + "create the project and install its packages on disk."); + } App.initialize("angular-cli"); GoogleAnalytics.post({ t: "screenview", cd: "New" }); let projLibrary: ProjectLibrary; - let projectOptions: NewProjectOptions; + let theme: string; const templateManager = new SchematicsTemplateManager(); App.container.set(TEMPLATE_MANAGER, templateManager); const prompt = new SchematicsPromptSession(); @@ -94,22 +107,13 @@ export function newProject(options: OptionsSchema): Rule { throw new SchematicsException(`template with id '${options.template}' not found`); } } - const theme = projLibrary.themes[themeIndex]; + theme = projLibrary.themes[themeIndex]; Util.log(`Project Name: ${options.name}, theme ${theme}`); - // project options: // cache available views and components, same as in component Schematic const components = projLibrary.components; const views = (projLibrary as any).customTemplates; - projectOptions = { - config: projTemplate.generateConfig(options.name, theme), - templateId: projTemplate.id, - templatePaths: [...projTemplate.templatePaths], - theme, - name: options.name - }; - GoogleAnalytics.post({ t: "event", ec: "$ng new", @@ -125,42 +129,36 @@ export function newProject(options: OptionsSchema): Rule { return tree; }); }, - mergeWith( - apply(empty(), [ - // TODO: Task chain based on @schematics/angular ng-new schematic - // externalSchematic("@schematics/angular", "workspace", { name: options.name }), - // externalSchematic("@schematics/angular", "application", { - // projectRoot: "", name: options.name, skipInstall: true, routing: true, style: "scss" - // }), - (tree: Tree, _context: IgxSchematicContext) => { - setVirtual(tree); - // extend project entry point: - return schematic("app-projects", projectOptions); - }, - (tree: Tree, _context: IgxSchematicContext) => { - // extend project entry point: - // tree.create("ignite-ui-cli.json", JSON.stringify({ theme: context.theme })); - if (tree.exists("gitignore")) { - tree.rename("gitignore", ".gitignore"); - } - }, - // run full schematic for args/prompts - schematic("ai-config", {}), - (tree: Tree, context: IgxSchematicContext) => { - if (!nameProvided) { - return defer(async () => { - prompt.setContext(context, tree, options.name as string); - await prompt.chooseActionLoop(projLibrary); - return tree; - }); - } - }, - (_tree: Tree, _context: IgxSchematicContext) => { - // move late so name can be resolved before use - return move(options.name!); + (tree: Tree, _context: IgxSchematicContext): Observable => { + // the same scaffold as `ig new`: the Angular CLI creates the project on disk, the tree reads it from there + return defer(async () => { + const schematicsPackage = require("../../package.json"); + const success = await projTemplate.scaffold!({ + name: options.name!, + theme, + skipInstall: options.skipInstall, + skipGit: true, + // `ng add igniteui-angular` adds this package to the project + extraConfig: { cliPackage: schematicsPackage.name, cliVersion: schematicsPackage.version } + }); + if (!success) { + throw new SchematicsException("Project creation failed."); } - ]), MergeStrategy.Overwrite - ), + return tree; + }); + }, + // run full schematic for args/prompts + inProject(() => options.name!, schematic("ai-config", {})), + inProject(() => options.name!, (projectTree: Tree, context: SchematicContext) => { + if (!nameProvided) { + return defer(async () => { + setVirtual(projectTree); + prompt.setContext(context, projectTree, options.name as string); + await prompt.chooseActionLoop(projLibrary); + return projectTree; + }); + } + }), (tree: Tree, _context: IgxSchematicContext) => { if (prompt.userAnswers && prompt.userAnswers.get("upgradePackages")) { return defer(async () => { diff --git a/packages/ng-schematics/src/ng-new/index_spec.ts b/packages/ng-schematics/src/ng-new/index_spec.ts index b288b24b78..4e24a8471c 100644 --- a/packages/ng-schematics/src/ng-new/index_spec.ts +++ b/packages/ng-schematics/src/ng-new/index_spec.ts @@ -1,15 +1,55 @@ -import { SchematicContext, Tree } from "@angular-devkit/schematics"; +import { virtualFs } from "@angular-devkit/core"; +import { Tree } from "@angular-devkit/schematics"; import { NodePackageTaskOptions } from "@angular-devkit/schematics/tasks/package-manager/options"; import { RepositoryInitializerTaskOptions } from "@angular-devkit/schematics/tasks/repo-init/options"; import { RunSchematicTaskOptions } from "@angular-devkit/schematics/tasks/run-schematic/options"; import { SchematicTestRunner, UnitTestTree } from "@angular-devkit/schematics/testing"; -import { BaseTemplate, GoogleAnalytics, ProjectLibrary, ProjectTemplate, Template } from "@igniteui/cli-core"; +import { NodeWorkflow } from "@angular-devkit/schematics/tools"; +import { BaseTemplate, GoogleAnalytics, ProjectLibrary, ProjectTemplate, ScaffoldOptions, Template } from "@igniteui/cli-core"; import * as path from "path"; -import * as AppProjectSchematic from "../app-projects/index"; +import { lastValueFrom } from "rxjs"; import { SchematicsPromptSession } from "../prompt/SchematicsPromptSession"; const collectionPath = path.join(__dirname, "../collection.json"); +/** Serves a library with a single project template, whose scaffold succeeds without creating anything */ +function mockProjectInLibrary(): Partial { + const mockProject: Partial = { + id: "mock-template-id", + framework: "angular", + projectType: "ts", + templatePaths: ["/path/to/template"], + generateConfig: jasmine.createSpy().and.returnValue({}), + upgradeIgniteUIPackages: jasmine.createSpy().and.returnValue(Promise.resolve(true)), + scaffold: jasmine.createSpy().and.returnValue(Promise.resolve(true)) + }; + + const mockLibrary: Partial = { + name: "mock-library", + themes: ["mock-theme"], + components: [], + projectIds: ["another-mock"], + projects: [mockProject as ProjectTemplate], + templates: [], + projectType: "ts" + }; + + spyOn(SchematicsPromptSession.prototype, "getProjectLibraryByType") + .and.returnValue(Promise.resolve(mockLibrary as ProjectLibrary)); + return mockProject; +} + +/** The scaffold options the schematic passes for a project created through the schematics */ +function expectedScaffoldOptions(name: string, theme: string, skipInstall: boolean): ScaffoldOptions { + return { + name, + theme, + skipInstall, + skipGit: true, + extraConfig: { cliPackage: "@igniteui/angular-schematics", cliVersion: jasmine.any(String) } + }; +} + describe("Schematics ng-new", () => { beforeAll(() => { @@ -43,7 +83,8 @@ describe("Schematics ng-new", () => { const mockProjectTemplate: ProjectTemplate = { ...mockBaseTemplate, installModules: jasmine.createSpy().and.callFake(() => {}), - upgradeIgniteUIPackages: jasmine.createSpy().and.returnValue(Promise.resolve(true)) + upgradeIgniteUIPackages: jasmine.createSpy().and.returnValue(Promise.resolve(true)), + scaffold: jasmine.createSpy().and.returnValue(Promise.resolve(true)) }; const mockTemplate: Template = { @@ -103,8 +144,7 @@ describe("Schematics ng-new", () => { const mockSession = { // regression: AI config (ai-config schematic) must run before the interactive // setup/action loop, otherwise "Next Steps" prints before the AI-agents prompt. - // The project isn't moved into its named subdirectory until after the loop runs, - // so the file still sits at the tree root at this point. + // Both run with the project folder as the root of the tree. chooseActionLoop: spyOn(SchematicsPromptSession.prototype, "chooseActionLoop") .and.callFake(function (this: SchematicsPromptSession) { expect(this.tree.exists(".mcp.json")).toBeTruthy(); @@ -128,18 +168,13 @@ describe("Schematics ng-new", () => { set: () => void 0 }); - spyOn(AppProjectSchematic, "default").and.returnValue((currentTree: Tree, _context: SchematicContext) => { - currentTree.create("gitignore", ""); - return currentTree; - }); - const tree = await runner.runSchematic("ng-new", { version: "8.0.3" }, myTree); for (const mockFunc of Object.entries(mockSession)) { expect(mockFunc[1]).toHaveBeenCalled(); } - expect(AppProjectSchematic.default).toHaveBeenCalled(); + expect(mockProjectTemplate.scaffold).toHaveBeenCalledOnceWith(expectedScaffoldOptions(workingDirectory, "mock-theme", false)); expect(tree.files.length).toBeGreaterThanOrEqual(2); - expect(tree.exists(`${workingDirectory}/.gitignore`)).toBeTruthy(); + expect(tree.files.every(file => file.startsWith(`/${workingDirectory}/`))).toBeTrue(); const taskOptions = runner.tasks.map(task => task.options); const expectedInstall: NodePackageTaskOptions = { command: "install", @@ -174,27 +209,7 @@ describe("Schematics ng-new", () => { const myTree = Tree.empty(); const workingDirectory = "my-test-project"; - const mockProject: Partial = { - id: "mock-template-id", - framework: "angular", - projectType: "ts", - templatePaths: ["/path/to/template"], - generateConfig: jasmine.createSpy().and.returnValue({}), - upgradeIgniteUIPackages: jasmine.createSpy().and.returnValue(Promise.resolve(true)) - }; - - const mockLibrary: Partial = { - name: "mock-library", - themes: ["mock-theme"], - components: [], - projectIds: ["another-mock"], - projects: [mockProject as ProjectTemplate], - templates: [], - projectType: "ts" - }; - - spyOn(SchematicsPromptSession.prototype, "getProjectLibraryByType") - .and.returnValue((Promise.resolve(mockLibrary as ProjectLibrary))); + const mockProject = mockProjectInLibrary(); const userAnswers = new Map(); userAnswers.set("upgradePackages", true); @@ -204,15 +219,10 @@ describe("Schematics ng-new", () => { set: () => void 0 }); - spyOn(AppProjectSchematic, "default").and.returnValue((currentTree: Tree, _context: SchematicContext) => { - currentTree.create("gitignore", ""); - return currentTree; - }); - const tree = await runner.runSchematic("ng-new", { version: "8.0.3", name: workingDirectory }, myTree); - expect(AppProjectSchematic.default).toHaveBeenCalled(); + expect(mockProject.scaffold).toHaveBeenCalledOnceWith(expectedScaffoldOptions(workingDirectory, "mock-theme", false)); expect(tree.files.length).toBeGreaterThanOrEqual(2); - expect(tree.exists(`${workingDirectory}/.gitignore`)).toBeTruthy(); + expect(tree.files.every(file => file.startsWith(`/${workingDirectory}/`))).toBeTrue(); const taskOptions = runner.tasks.map(task => task.options); const expectedInstall: NodePackageTaskOptions = { command: "install", @@ -234,15 +244,35 @@ describe("Schematics ng-new", () => { expect(taskOptions).toContain(expectedInit); }); + it("fails when the project cannot be created", async () => { + const runner = new SchematicTestRunner("schematics", collectionPath); + const mockProject = mockProjectInLibrary(); + (mockProject.scaffold as jasmine.Spy).and.returnValue(Promise.resolve(false)); + + await expectAsync(runner.runSchematic("ng-new", { version: "8.0.3", name: "my-test-project" }, Tree.empty())) + .toBeRejectedWithError(/Project creation failed/); + expect(runner.tasks).withContext("no install, git or start tasks").toEqual([]); + }); + + it("rejects dry runs before creating the project", async () => { + const mockProject = mockProjectInLibrary(); + // how `ng new --dry-run` runs the schematic + const workflow = new NodeWorkflow(new virtualFs.SimpleMemoryHost(), { dryRun: true }); + + await expectAsync(lastValueFrom(workflow.execute({ + collection: collectionPath, + schematic: "ng-new", + options: { version: "8.0.3", name: "my-test-project" } + }), { defaultValue: undefined })).toBeRejectedWithError(/--dry-run is not supported/); + expect(mockProject.scaffold).not.toHaveBeenCalled(); + }); + describe("addAIConfig via ng-new", () => { const workingDirectory = "my-test-project"; const mcpFilePath = `${workingDirectory}/.mcp.json`; function setupAndRun(runner: SchematicTestRunner, myTree: Tree): Promise { - spyOn(AppProjectSchematic, "default").and.returnValue((currentTree: Tree, _context: SchematicContext) => { - currentTree.create("gitignore", ""); - return currentTree; - }); + mockProjectInLibrary(); const userAnswers = new Map(); userAnswers.set("upgradePackages", false); diff --git a/packages/ng-schematics/src/ng-new/schema.json b/packages/ng-schematics/src/ng-new/schema.json index 805148d4b1..7ecb1b95f0 100644 --- a/packages/ng-schematics/src/ng-new/schema.json +++ b/packages/ng-schematics/src/ng-new/schema.json @@ -21,7 +21,7 @@ } }, "skipInstall": { - "description": "Do not automatically install packages.", + "description": "Do not install packages after the project is set up. `ng add igniteui-angular` still installs the packages the new project starts with.", "type": "boolean", "default": false, "alias": "si" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 912a24203d..f1006e03ae 100644 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -8,7 +8,7 @@ set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CLI_ENTRY="$ROOT/packages/cli/bin/execute.js" +CLI_LIB="$ROOT/packages/cli/lib/cli.js" FRAMEWORKS="angular,react,webcomponents" PROJECT="" @@ -100,21 +100,28 @@ count_src_files() { [ -d "$1/src" ] && find "$1/src" -type f | wc -l | tr -d ' ' || echo 0 } +# Runs this checkout's CLI. bin/execute.js is skipped on purpose: it hands off to a +# project-local igniteui-cli when one is installed, and Angular projects get one at `new`. +cli() { + node -e 'require(process.argv[1]).run(process.argv.slice(2)).catch(e => { console.error("Error: " + (e.message || e)); process.exit(1); })' \ + "$CLI_LIB" "$@" +} + # run_cli run_cli() { local cwd="$1" log="$2"; shift 2 - (cd "$cwd" && node "$CLI_ENTRY" "$@") >"$log" 2>&1 + (cd "$cwd" && cli "$@") >"$log" 2>&1 } list_component_ids() { local dir="$1" # a row is one tab then the id; wrapped description lines are indented deeper - (cd "$dir" && node "$CLI_ENTRY" list 2>/dev/null) | strip_ansi | awk '/^\t[^\t]/ {print $1}' + (cd "$dir" && cli list 2>/dev/null) | strip_ansi | awk '/^\t[^\t]/ {print $1}' } list_project_ids() { local fw="$1" - (cd "$OUT" && node "$CLI_ENTRY" list 2>/dev/null) | strip_ansi | awk -v fw="($fw)" ' + (cd "$OUT" && cli list 2>/dev/null) | strip_ansi | awk -v fw="($fw)" ' $0 ~ /^[A-Za-z]/ && index($0, fw) { grab = 1; next } /^[A-Za-z]/ { grab = 0 } grab && /^\t\t/ { sub(/\.\.+.*$/, "", $1); print $1 } @@ -276,7 +283,7 @@ run_framework() { # --- preflight --------------------------------------------------------------- -if [ ! -f "$ROOT/packages/cli/lib/cli.js" ]; then +if [ ! -f "$CLI_LIB" ]; then echo "CLI is not built. Run 'npm run build' in $ROOT first." >&2 exit 1 fi @@ -318,7 +325,7 @@ mkdir -p "$LOGS" : >"$SENTINEL" printf 'framework\tstep\ttemplate\tstatus\tseconds\n' >"$RESULTS" -echo "CLI: $CLI_ENTRY" +echo "CLI: $CLI_LIB" echo "Out: $OUT" # --- run --------------------------------------------------------------------- diff --git a/scripts/versionScript.ts b/scripts/versionScript.ts index f2eb514c70..916462ab18 100644 --- a/scripts/versionScript.ts +++ b/scripts/versionScript.ts @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync } from "fs"; import * as path from "path"; +import { IGNITEUI_ANGULAR_PACKAGE } from "../packages/igx-templates/constants"; import { generateChangelog } from "./changelogScript"; let version: string; @@ -11,8 +12,8 @@ const coreVersionedPackages = ["@igniteui/cli-core", "@igniteui/mcp-server"]; const DEP_RANGE_PREFIX = "~"; function getVersion() { - const igxPackageJson = JSON.parse(readFileSync("packages/igx-templates/igx-ts/projects/_base/files/package.json", "utf-8")); - const versionIG = igxPackageJson.dependencies["igniteui-angular"].replace(/^[^0-9]+/, ""); + // the igniteui-angular range the templates use, e.g. "igniteui-angular@~22.2.0" + const versionIG = IGNITEUI_ANGULAR_PACKAGE.split("@").pop()!.replace(/^[^0-9]+/, ""); const majorMinor = versionIG.split(".").slice(0, 2).join("."); const versionCLI = JSON.parse(readFileSync("packages/core/package.json").toString()); coreVersion = versionCLI.version; diff --git a/spec/acceptance/help-spec.ts b/spec/acceptance/help-spec.ts index 20dbaafc52..77a2de6210 100644 --- a/spec/acceptance/help-spec.ts +++ b/spec/acceptance/help-spec.ts @@ -72,7 +72,8 @@ describe("Help command", () => { [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] + --skip-install, --si Do not install packages after scaffolding (Angular + projects install them anyway: \`ng add\` needs them) [boolean] --template Project template [string] --agents AI agents/tools to generate configuration files for [array] [choices: "generic", "claude", "copilot", "cursor", "codex", diff --git a/spec/acceptance/new-spec.ts b/spec/acceptance/new-spec.ts index eb6e9b1123..24bd7e3e16 100644 --- a/spec/acceptance/new-spec.ts +++ b/spec/acceptance/new-spec.ts @@ -3,6 +3,7 @@ import { } from "@igniteui/cli-core"; import * as fs from "fs"; import * as cli from "../../packages/cli/lib/cli"; +import { stubAngularCli } from "../helpers/angular-cli-stub"; import { deleteAll, filesDiff, resetSpy } from "../helpers/utils"; describe("New command", () => { @@ -13,6 +14,8 @@ describe("New command", () => { spyOn(console, "error"); spyOn(GoogleAnalytics, "post"); spyOn(PackageManager, "installPackages"); + // Angular projects are created by the Angular CLI + stubAngularCli(); // isolate from the user's global config, e.g. custom templates that log errors (extra GA posts) spyOn(ProjectConfig, "globalConfig").and.returnValue({} as Config); spyOn(InquirerWrapper, "exclusiveCheckbox").and.returnValue(Promise.resolve(["none"])); @@ -79,6 +82,8 @@ describe("New command", () => { const packageText = fs.readFileSync("./Ignite UI for Angular/package.json", "utf-8"); expect(JSON.parse(packageText).name).toEqual("ignite-ui-for-angular"); expect(fs.existsSync("./Ignite UI for Angular/.gitignore")).toBeTruthy(); + // the scaffold installs the packages + expect(JSON.parse(fs.readFileSync("./Ignite UI for Angular/ignite-ui-cli.json", "utf-8")).packagesInstalled).toBeTrue(); testFolder = "./Ignite UI for Angular"; }); @@ -144,13 +149,15 @@ describe("New command", () => { testFolder = "./angularProj"; }); - it("Skip Git/Install with command option", async () => { + it("Skip Git with command option; Angular projects install packages even with --skip-install", async () => { spyOn(Util, "gitInit"); + spyOn(console, "warn"); const projectName = "angularProj"; await cli.run(["new", projectName, "--framework=angular", "--type=igx-ts", "--skip-git", "--skip-install", "--theme=default"]); expect(Util.gitInit).not.toHaveBeenCalled(); expect(fs.existsSync("./" + projectName + "/.git")).not.toBeTruthy(); - expect(PackageManager.installPackages).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith(jasmine.stringMatching(/--skip-install does not apply to the initial setup/)); + expect(Util.execSync).toHaveBeenCalledWith(jasmine.stringMatching(/^npm install /), jasmine.anything()); testFolder = projectName; }); diff --git a/spec/helpers/angular-cli-stub.ts b/spec/helpers/angular-cli-stub.ts new file mode 100644 index 0000000000..2ec25bf7fa --- /dev/null +++ b/spec/helpers/angular-cli-stub.ts @@ -0,0 +1,94 @@ +import { Util } from "@igniteui/cli-core"; +import { ExecSyncOptions } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; + +const NG = /^npx -y @angular\/cli@\S+ /; +const TSCONFIG_HEADER = + "/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */\n"; + +/** `npm view` answers, in the shape npm gives for a range several versions match */ +const NPM_VIEW: { [spec: string]: object } = { + "igniteui-angular": [{ "@angular/core": "21" }, { "@angular/core": "22" }], + "@angular/core": ["22.0.0", "22.2.3"] +}; + +/** + * Stands in for the Angular CLI and npm commands the Angular project scaffold runs. + * `ng new` writes the project as it looks after `ng new`, `ng add angular-eslint` and `ng add igniteui-angular`, + * with the AI config `ng add` writes; `npm view` answers for igniteui-angular 22; `ng add` and `npm install` do nothing. + * Other commands run as usual. + */ +export function stubAngularCli(): jasmine.Spy { + const execSync = Util.execSync; + return spyOn(Util, "execSync").and.callFake((command: string, options?: ExecSyncOptions) => { + const ng = command.match(NG); + if (ng) { + const ngNew = command.slice(ng[0].length).match(/^new (\S+) --directory (\S+)/); + if (ngNew) { + writeGeneratedProject(path.join(options!.cwd!.toString(), ngNew[2]), ngNew[1]); + } + return Buffer.from(""); + } + const npmView = command.match(/^npm view "(@?[^@"]+)@/); + if (npmView) { + return JSON.stringify(NPM_VIEW[npmView[1]]); + } + if (command.startsWith("npm install ")) { + return Buffer.from(""); + } + return execSync.call(Util, command, options); + }); +} + +function writeGeneratedProject(dir: string, dashName: string) { + const files: { [file: string]: string | object } = { + ".gitignore": "/node_modules\n", + ".mcp.json": { mcpServers: {} }, + ".agents/skills/igniteui-angular-components/SKILL.md": "", + ".claude/CLAUDE.md": "", + "AGENTS.md": "", + "ignite-ui-cli.json": { project: { projectTemplate: "ng-cli" } }, + "package.json": { + name: dashName, + scripts: { ng: "ng", start: "ng serve", build: "ng build", test: "ng test", lint: "ng lint" }, + dependencies: { + "@angular/common": "^22.2.0", + "@angular/core": "^22.2.0", + "@igniteui/material-icons-extended": "^3.1.0", + "igniteui-angular": "^22.2.3", + "minireset.css": "~0.0.4" + }, + devDependencies: { + "@angular/cli": "^22.2.3", + "@igniteui/angular-schematics": "~22.2.1580", + "angular-eslint": "22.6.0", + "jsdom": "^30.0.0", + "vitest": "^5.0.0" + } + }, + "angular.json": { + projects: { + [dashName]: { + architect: { + build: { + options: { assets: [{ glob: "**/*", input: "public" }], styles: ["src/styles.scss"] }, + configurations: { + production: { budgets: [{ type: "initial", maximumWarning: "500kB", maximumError: "1MB" }] }, + development: { optimization: false } + } + }, + test: { builder: "@angular/build:unit-test" } + } + } + } + }, + "tsconfig.app.json": TSCONFIG_HEADER + JSON.stringify({ compilerOptions: { types: [] } }, null, 2), + "tsconfig.spec.json": TSCONFIG_HEADER + JSON.stringify({ compilerOptions: { types: ["vitest/globals"] } }, null, 2), + "src/styles.scss": "@use 'igniteui-angular/theming' as *;\n" + }; + for (const [file, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), typeof content === "string" ? content : JSON.stringify(content, null, 2)); + } +} diff --git a/spec/templates/angular-scaffold-spec.ts b/spec/templates/angular-scaffold-spec.ts new file mode 100644 index 0000000000..8a8a5b12c4 --- /dev/null +++ b/spec/templates/angular-scaffold-spec.ts @@ -0,0 +1,215 @@ +import { GoogleAnalytics, Util } from "@igniteui/cli-core"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { IGNITEUI_ANGULAR_PACKAGE, SKIP_AI_CONFIG_ENV } from "../../packages/igx-templates/constants"; +import emptyProject from "../../packages/igx-templates/igx-ts/projects/empty"; +import authProject from "../../packages/igx-templates/igx-ts/projects/side-nav-auth"; +import { stubAngularCli } from "../helpers/angular-cli-stub"; + +describe("Angular project scaffold", () => { + const name = "Test Project"; + const dashName = "test-project"; + let cwd: string; + let tmp: string; + let execSync: jasmine.Spy; + + const filePath = (file: string) => path.join(tmp, name, file); + const read = (file: string) => fs.readFileSync(filePath(file), "utf8"); + const readJson = (file: string) => JSON.parse(read(file)); + const architect = () => readJson("angular.json").projects[dashName].architect; + const commands = (): string[] => execSync.calls.allArgs().map(([command]) => command); + + beforeEach(() => { + spyOn(console, "log"); + spyOn(console, "warn"); + spyOn(console, "error"); + spyOn(GoogleAnalytics, "post"); + execSync = stubAngularCli(); + cwd = process.cwd(); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "igx-scaffold-")); + process.chdir(tmp); + }); + + afterEach(() => { + process.chdir(cwd); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("creates the project with the Angular major the templates' igniteui-angular supports, and lays the template files over it", async () => { + expect(await emptyProject.scaffold({ name, theme: "Default" })).toBeTrue(); + + expect(commands()).toEqual([ + `npm view "${IGNITEUI_ANGULAR_PACKAGE}" peerDependencies --json`, + `npm view "@angular/core@22" version --json`, + `npx -y @angular/cli@22 new ${dashName} --directory ${dashName} --style=scss --routing --ssr=false ` + + "--zoneless=false --ai-config=none --skip-git --skip-install --package-manager=npm --defaults --interactive=false", + "npx -y @angular/cli@22 add angular-eslint@22 --skip-confirmation --defaults --interactive=false", + `npx -y @angular/cli@22 add ${IGNITEUI_ANGULAR_PACKAGE} --skip-confirmation --defaults --interactive=false`, + "npm install --save-dev playwright --no-audit --no-fund" + ]); + for (const entry of [".agents", ".claude", "AGENTS.md", ".mcp.json"]) { + expect(fs.existsSync(filePath(entry))).withContext(`${entry} from ng add is left to ig new`).toBeFalse(); + } + expect(fs.existsSync(filePath("src/app/error-routing/not-found/not-found.ts"))).toBeTrue(); + expect(fs.existsSync(filePath("src/app/home/home.ts"))).toBeTrue(); + expect(read("README.md")).toContain(`# ${name}`); + expect(readJson("ignite-ui-cli.json").project).toEqual(jasmine.objectContaining({ + projectTemplate: "empty", + theme: "Default", + themePath: "node_modules/igniteui-angular/styles/igniteui-angular.css" + })); + }); + + it("patches the generated angular.json", async () => { + await emptyProject.scaffold({ name, theme: "Default" }); + + const { build, test } = architect(); + expect(build.options.assets).toEqual([{ glob: "**/*", input: "public" }, "src/assets"]); + expect(build.options.styles).toEqual(["src/styles.scss", "node_modules/igniteui-angular/styles/igniteui-angular.css"]); + expect(build.configurations.production.budgets).toEqual([ + { type: "initial", maximumWarning: "5mb", maximumError: "40mb" }, + { type: "anyComponentStyle", maximumWarning: "8kB", maximumError: "2mb" } + ]); + expect(build.configurations.development).toEqual({ + optimization: false, + fileReplacements: [{ + replace: "src/environments/environment.ts", + with: "src/environments/environment.development.ts" + }] + }); + expect(test.options).toEqual({ runnerConfig: true }); + expect(architect()["extract-i18n"]).toEqual({ + builder: "@angular/build:extract-i18n", + options: { buildTarget: `${dashName}:build` } + }); + }); + + it("patches the generated package.json", async () => { + await emptyProject.scaffold({ name, theme: "Default" }); + + const { scripts, dependencies, devDependencies } = readJson("package.json"); + expect(scripts).toEqual(jasmine.objectContaining({ start: "ng serve -o", test: "ng test --browsers=chromium" })); + expect(`igniteui-angular@${dependencies["igniteui-angular"]}`) + .withContext("the range the component templates are written for, not the ^ ng add saves") + .toBe(IGNITEUI_ANGULAR_PACKAGE); + expect(dependencies["@angular/animations"]).toBe(dependencies["@angular/core"]); + expect(devDependencies["@vitest/browser-playwright"]).toBe(devDependencies.vitest); + expect(devDependencies.jsdom).toBeUndefined(); + expect(devDependencies["igniteui-cli"]).toBe(`~${Util.version()}`); + expect(devDependencies["@igniteui/angular-schematics"]).toBeUndefined(); + }); + + it("puts the Custom theme in styles.scss instead of angular.json", async () => { + await emptyProject.scaffold({ name, theme: "Custom" }); + + expect(read("src/styles.scss")).toContain("@include theme($app-palette);"); + expect(architect().build.options.styles).toEqual(["src/styles.scss"]); + expect(readJson("ignite-ui-cli.json").project.themePath).toBe("Custom"); + }); + + it("adds the packages and types the authentication templates use, at the project's Angular major where they follow it", async () => { + await authProject.scaffold({ name, theme: "Default" }); + + expect(commands()) + .toContain("npm install --save-dev playwright @angular/language-service@22 @types/facebook-js-sdk --no-audit --no-fund"); + expect(commands()).toContain("npm install angular-auth-oidc-client@22 --no-audit --no-fund"); + const tsconfig = (file: string) => { + const text = read(file); + expect(text).withContext(`${file} keeps its leading comments`).toMatch(/^\/\* To learn more/); + return JSON.parse(text.slice(text.indexOf("{"))); + }; + expect(tsconfig("tsconfig.app.json").compilerOptions.types).toEqual(["facebook-js-sdk"]); + expect(tsconfig("tsconfig.spec.json").compilerOptions.types).toEqual(["vitest/globals", "facebook-js-sdk"]); + expect(fs.existsSync(filePath("src/app/authentication/login/login.ts"))).toBeTrue(); + }); + + it("keeps @igniteui/angular-schematics, at the running version, for projects created through the schematics", async () => { + await emptyProject.scaffold({ + name, + theme: "Default", + extraConfig: { cliPackage: "@igniteui/angular-schematics", cliVersion: "1.2.3" } + }); + + const { devDependencies } = readJson("package.json"); + expect(devDependencies["@igniteui/angular-schematics"]).toBe("~1.2.3"); + expect(devDependencies["igniteui-cli"]).toBeUndefined(); + expect(readJson("ignite-ui-cli.json").version).toBe("1.2.3"); + }); + + it("installs packages even with skipInstall, and says so", async () => { + expect(await emptyProject.scaffold({ name, theme: "Default", skipInstall: true })).toBeTrue(); + + expect(console.warn).toHaveBeenCalledWith(jasmine.stringMatching(/--skip-install does not apply to the initial setup/)); + expect(commands()).toContain("npm install --save-dev playwright --no-audit --no-fund"); + }); + + it("tells the Angular CLI calls to skip ng add's ai-config, which ig new and the ng-new schematic run themselves", async () => { + await emptyProject.scaffold({ name, theme: "Default" }); + + for (const [command, options] of execSync.calls.allArgs().filter(([command]) => command.startsWith("npx"))) { + expect(options.env[SKIP_AI_CONFIG_ENV]).withContext(command).toBe("true"); + } + }); + + it("runs ng new outside the current folder, which may be inside an Angular workspace", async () => { + await emptyProject.scaffold({ name, theme: "Default" }); + + const [, options] = execSync.calls.allArgs().find(([command]) => / new /.test(command)); + expect(options.cwd).not.toBe(process.cwd()); + expect(fs.existsSync(options.cwd)).withContext("the temp folder is removed").toBeFalse(); + expect(fs.existsSync(filePath("angular.json"))).toBeTrue(); + }); + + it("reports a failed Angular CLI command and removes the project folder", async () => { + execSync.withArgs(jasmine.stringMatching(/ add igniteui-angular/), jasmine.anything()) + .and.throwError(new Error("Command failed: ng add")); + + expect(await emptyProject.scaffold({ name, theme: "Default" })).toBeFalse(); + expect(console.error).toHaveBeenCalledWith(jasmine.stringMatching(/Project creation failed: Command failed: ng add/)); + expect(fs.existsSync(path.join(tmp, name))).withContext("a retry starts clean").toBeFalse(); + }); + + it("reports a project folder it could not remove instead of throwing", async () => { + execSync.withArgs(jasmine.stringMatching(/ add igniteui-angular/), jasmine.anything()) + .and.throwError(new Error("Command failed: ng add")); + // the module itself: `import * as fs` gives a read-only namespace + spyOn(require("fs"), "rmSync").and.callThrough() + .withArgs(path.join(process.cwd(), name), jasmine.anything()).and.throwError(new Error("EBUSY")); + + expect(await emptyProject.scaffold({ name, theme: "Default" })).toBeFalse(); + expect(console.warn).toHaveBeenCalledWith(jasmine.stringMatching(/Could not remove "Test Project"/)); + expect(console.error).toHaveBeenCalledWith(jasmine.stringMatching(/Project creation failed: Command failed: ng add/)); + }); + + it("keeps a project whose packages failed to install, with the installs left to run", async () => { + execSync.withArgs(jasmine.stringMatching(/^npm install --save-dev/), jasmine.anything()) + .and.throwError(new Error("Command failed: npm")); + + expect(await authProject.scaffold({ name, theme: "Default" })).toBeFalse(); + expect(console.error).toHaveBeenCalledWith(jasmine.stringMatching(/Project creation failed: Command failed: npm/)); + expect(fs.existsSync(filePath("angular.json"))).toBeTrue(); + expect(console.warn).toHaveBeenCalledWith(jasmine.stringMatching( + /"Test Project" is kept; .*npm install --save-dev playwright .* && npm install angular-auth-oidc-client@22 /)); + }); + + it("leaves only the installs that failed or did not run to the user", async () => { + execSync.withArgs(jasmine.stringMatching(/^npm install angular-auth-oidc-client/), jasmine.anything()) + .and.throwError(new Error("Command failed: npm")); + + expect(await authProject.scaffold({ name, theme: "Default" })).toBeFalse(); + const [message] = (console.warn as jasmine.Spy).calls.mostRecent().args; + expect(message).toContain("npm install angular-auth-oidc-client@22"); + expect(message).not.toContain("--save-dev"); + }); + + it("leaves an existing folder alone", async () => { + fs.mkdirSync(path.join(tmp, name)); + fs.writeFileSync(filePath("keep.txt"), ""); + + expect(await emptyProject.scaffold({ name, theme: "Default" })).toBeFalse(); + expect(console.error).toHaveBeenCalledWith(jasmine.stringMatching(/already exists/)); + expect(commands()).toEqual([]); + expect(fs.existsSync(filePath("keep.txt"))).toBeTrue(); + }); +}); diff --git a/spec/templates/angular-spec.ts b/spec/templates/angular-spec.ts index d436438f3e..29d7cb8311 100644 --- a/spec/templates/angular-spec.ts +++ b/spec/templates/angular-spec.ts @@ -38,29 +38,27 @@ describe("Angular templates", () => { } }); - it("Igx templates and project package.json files should use the same igniteui-angular version as the const", async () => { + it("Igx templates should use the same igniteui-angular version as the const", async () => { const angularFramework: Framework = require(templatesLocation); const projLibrary = angularFramework.projectLibraries.find(x => x.projectType === "igx-ts"); - const [pkg, version] = IGNITEUI_ANGULAR_PACKAGE.split("@"); + const [pkg] = IGNITEUI_ANGULAR_PACKAGE.split("@"); projLibrary.templates .flatMap(t => t.packages || []) .filter(p => typeof p === "string" && p.startsWith(`${pkg}@`)) .forEach(p => expect(p).toBe(IGNITEUI_ANGULAR_PACKAGE)); + }); - const roots = [ - ...new Set(projLibrary.projects.flatMap(p => p.templatePaths)) - ]; - - for (const root of roots) { - const file = path.resolve(process.cwd(), root, "package.json"); - if (!fs.existsSync(file)) continue; - - const { dependencies = {}, devDependencies = {} } = require(file); - const deps = { ...dependencies, ...devDependencies }; + it("Igx project templates should not carry the files the Angular CLI generates; the scaffold patches those", () => { + const angularFramework: Framework = require(templatesLocation); + const projLibrary = angularFramework.projectLibraries.find(x => x.projectType === "igx-ts"); + const generated = ["angular.json", "package.json", "tsconfig.json", "tsconfig.app.json", "tsconfig.spec.json", "src/main.ts"]; - if (deps[pkg]) { - expect(deps[pkg]).toBe(version); + for (const root of new Set(projLibrary.projects.flatMap(p => p.templatePaths))) { + for (const file of generated) { + expect(fs.existsSync(path.join(root, file))) + .withContext(`${root}/${file} would replace the generated one`) + .toBeFalse(); } } }); diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index d59c08c43a..abfa995fe7 100644 --- a/spec/unit/PromptSession-spec.ts +++ b/spec/unit/PromptSession-spec.ts @@ -880,77 +880,41 @@ describe("Unit - PromptSession", () => { expect(upgrade.upgrade).toHaveBeenCalledTimes(1); expect(upgrade.upgrade).toHaveBeenCalledWith({ skipInstall: true, _: ["upgrade"], $0: "upgrade" }); }); - it("start - Should fire correctly with Angular Custom theme selected", async () => { - const mockProjectConfig = { - customTemplates: [], - project: { - isShowcase: true - } - } as unknown as Config; - spyOn(ProjectConfig, "getConfig").and.returnValue(mockProjectConfig); - App.initialize(); - App.container.set(TEMPLATE_MANAGER, new TemplateManager()); - const mockSession = new PromptSession(); - spyOn(Util, "isAlphanumericExt").and.callThrough(); - spyOn(Util, "gitInit"); - spyOn(Util, "log"); - spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); - spyOn(InquirerWrapper, "input").and.returnValues(Promise.resolve("Test1")); - spyOn(InquirerWrapper, "select").and.returnValues( - Promise.resolve("Angular"), - Promise.resolve("Side navigation default"), - Promise.resolve("Custom") - ); - spyOn(InquirerWrapper, "confirm").and.returnValue(Promise.resolve(false)); - spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); - spyOn(mockSession, "chooseActionLoop").and.returnValue(Promise.resolve()); - spyOn(process, "chdir"); - await mockSession.start(); - const CUSTOM_THEME = ` - /* See: https://www.infragistics.com/products/ignite-ui-angular/angular/components/themes/sass/index */ - @use "igniteui-angular/theming" as *; - $primary: #09f !default; - $secondary: #4db8ff !default; - $surface: #fff !default; - $app-palette: palette($primary, $secondary, $surface); - /* autoprefixer grid: on */ - @include core(); - @include typography($font-family: $material-typeface, $type-scale: $material-type-scale); - @include theme($app-palette);`; - const actualCall = (Util.processTemplates as jasmine.Spy).calls.argsFor(0)[2]["CustomTheme"].replace(/\s/g, ""); - const expectedCall = CUSTOM_THEME.replace(/\s/g, ""); - expect(actualCall).toEqual(expectedCall); - }); - it("start - Should fire correctly with Angular Default theme selected", async () => { - const mockProjectConfig = { - customTemplates: [], - project: { - isShowcase: true - } - } as unknown as Config; - spyOn(ProjectConfig, "getConfig").and.returnValue(mockProjectConfig); - App.initialize(); - App.container.set(TEMPLATE_MANAGER, new TemplateManager()); - const mockSession = new PromptSession(); - spyOn(Util, "isAlphanumericExt").and.callThrough(); - spyOn(Util, "gitInit"); - spyOn(Util, "log"); - spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); - spyOn(InquirerWrapper, "input").and.returnValues(Promise.resolve("Test1")); - spyOn(InquirerWrapper, "select").and.returnValues( - Promise.resolve("Angular"), - Promise.resolve("Side navigation default"), - Promise.resolve("Default")); - spyOn(InquirerWrapper, "confirm").and.returnValue(Promise.resolve(false)); - spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); - spyOn(mockSession, "chooseActionLoop").and.returnValue(Promise.resolve()); - spyOn(process, "chdir"); - await mockSession.start(); - const DEFAULT_THEME = `,"node_modules/igniteui-angular/styles/igniteui-angular.css"`; - const actualCall = (Util.processTemplates as jasmine.Spy).calls.argsFor(0)[2]["DefaultTheme"].replace(/\s/g, ""); - const expectedCall = DEFAULT_THEME.replace(/\s/g, ""); - expect(actualCall).toEqual(expectedCall); - }); + // what each theme produces is covered by the Angular project scaffold spec + for (const theme of ["Custom", "Default"]) { + it(`start - Should scaffold the Angular project with the ${theme} theme selected`, async () => { + const mockProjectConfig = { + customTemplates: [], + project: { + isShowcase: true + } + } as unknown as Config; + spyOn(ProjectConfig, "getConfig").and.returnValue(mockProjectConfig); + App.initialize(); + const templateManager = new TemplateManager(); + App.container.set(TEMPLATE_MANAGER, templateManager); + const sideNav = templateManager.getProjectLibrary("angular", "igx-ts").getProject("side-nav"); + spyOn(sideNav, "scaffold").and.returnValue(Promise.resolve(true)); + const mockSession = new PromptSession(); + spyOn(Util, "isAlphanumericExt").and.callThrough(); + spyOn(Util, "gitInit"); + spyOn(Util, "log"); + spyOn(Util, "processTemplates").and.returnValue(Promise.resolve(true)); + spyOn(InquirerWrapper, "input").and.returnValues(Promise.resolve("Test1")); + spyOn(InquirerWrapper, "select").and.returnValues( + Promise.resolve("Angular"), + Promise.resolve("Side navigation default"), + Promise.resolve(theme) + ); + spyOn(InquirerWrapper, "confirm").and.returnValue(Promise.resolve(false)); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); + spyOn(mockSession, "chooseActionLoop").and.returnValue(Promise.resolve()); + spyOn(process, "chdir"); + await mockSession.start(); + expect(sideNav.scaffold).toHaveBeenCalledOnceWith(jasmine.objectContaining({ name: "Test1", theme })); + expect(Util.processTemplates).not.toHaveBeenCalled(); + }); + } it("getProjectTemplate - should exclude isHidden templates from choices and resolve the selected visible one", async () => { const mockBaseTemplate = createMockBaseTemplate(); const hiddenProject = createMockProjectTemplate({ ...mockBaseTemplate, name: "base", isHidden: true }); diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 050aea2097..b9b6873191 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -548,11 +548,6 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); - // srcFs reads (FsFileSystem.prototype) for source content - spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === angularSkillsDir ? [skillFile] : [] - ); - spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content"); configureSkills(["claude"], "angular"); @@ -581,11 +576,6 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); - // srcFs reads (FsFileSystem.prototype) for source content - spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === angularSkillsDir ? [skillFile] : [] - ); - spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content); configureSkills(["claude"], "angular"); @@ -610,11 +600,6 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); - // srcFs reads (FsFileSystem.prototype) for source content - spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === angularSkillsDir ? [skillFile] : [] - ); - spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content"); configureSkills(["claude"], "angular"); @@ -639,10 +624,6 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); - spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === angularSkillsDir ? [skillFile] : [] - ); - spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content"); configureSkills(["claude"], "angular"); @@ -656,7 +637,9 @@ describe("Unit - ai-config command", () => { fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => p === "ignite-ui-cli.json" || p === destFile ), - readFile: jasmine.createSpy("readFile").and.returnValue("old content"), + readFile: jasmine.createSpy("readFile").and.callFake((p: string) => + p === skillFile ? "new content" : "old content" + ), writeFile: jasmine.createSpy("writeFile"), directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => p === angularSkillsDir @@ -667,10 +650,6 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); - spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === angularSkillsDir ? [skillFile] : [] - ); - spyOn(FsFileSystem.prototype, "readFile").and.returnValue("new content"); configureSkills(["claude"], "angular"); @@ -696,10 +675,6 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); - spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === angularSkillsDir ? [skillFile] : [] - ); - spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content); configureSkills(["claude"], "angular"); @@ -714,7 +689,12 @@ describe("Unit - ai-config command", () => { fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => p === "ignite-ui-cli.json" ), - readFile: jasmine.createSpy("readFile").and.returnValue("skill content"), + readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { + if (p === "node_modules/igniteui-angular/package.json") { + return JSON.stringify({ version: "18.2.0" }); + } + return "skill content"; + }), writeFile: jasmine.createSpy("writeFile"), directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => p === angularSkillsDir @@ -725,15 +705,6 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); - spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === angularSkillsDir ? [skillFile] : [] - ); - spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { - if (p === "node_modules/igniteui-angular/package.json") { - return JSON.stringify({ version: "18.2.0" }); - } - return "skill content"; - }); configureSkills(["claude"], "angular"); @@ -775,7 +746,12 @@ describe("Unit - ai-config command", () => { fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => p === "ignite-ui-cli.json" ), - readFile: jasmine.createSpy("readFile").and.returnValue("skill content"), + readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { + if (p === "node_modules/igniteui-angular/package.json") { + return JSON.stringify({ version: "18.2.0" }); + } + return "skill content"; + }), writeFile: jasmine.createSpy("writeFile"), directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => p === angularSkillsDir @@ -786,15 +762,6 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); - spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === angularSkillsDir ? [skillFile] : [] - ); - spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { - if (p === "node_modules/igniteui-angular/package.json") { - return JSON.stringify({ version: "18.2.0" }); - } - return "skill content"; - }); configureSkills(["claude"], "angular", false); diff --git a/spec/unit/ai-skills-spec.ts b/spec/unit/ai-skills-spec.ts index 1b17377c68..aa76f8a6ae 100644 --- a/spec/unit/ai-skills-spec.ts +++ b/spec/unit/ai-skills-spec.ts @@ -24,7 +24,7 @@ function mockTemplateManager(templatePaths: string[]) { return mockTm; } -/** Creates a mock for the destination FS (injected via container, may be virtual Tree) */ +/** Creates a mock for the container FS (may be virtual Tree): the destination, and the source of package skills */ function makeDestFs(overrides: Partial = {}): IFileSystem { return { fileExists: jasmine.createSpy("destFs.fileExists").and.returnValue(false), @@ -37,7 +37,7 @@ function makeDestFs(overrides: Partial = {}): IFileSystem { } /** - * Spies on FsFileSystem.prototype methods to mock the source FS (always real disk). + * Spies on FsFileSystem.prototype methods to mock the real disk, the source of bundled template skills. * Returns spies dict so tests can configure callFake / returnValue. */ function spySrcFs(overrides: { @@ -71,27 +71,51 @@ describe("Unit - copyAISkillsToProject", () => { const skillFilePath = skillFile("igniteui-angular", "angular.md"); const mockSkillContent = "# Ignite UI for Angular skills"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((dir: string) => dir === angularSkillsDir ? [skillFilePath] : [] ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { if (p === skillFilePath) return mockSkillContent; return ""; }) }); + App.container.set(FS_TOKEN, destFs); + + const result = copyAISkillsToProject(["claude"], "angular"); + + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", mockSkillContent); + expect(result.details).toEqual([{ path: ".claude/skills/angular.md", action: "created" }]); + }); + + it("should read package skills through the container FS, which may not be rooted at the cwd", () => { + // Simulates `ng new`: the tree is scoped to the new project, the cwd is the folder it is created in + const angularSkillsDir = skillsDir("igniteui-angular"); + const treeSkillFilePath = `/${skillFile("igniteui-angular", "angular.md")}`; + const content = "# Angular skills"; + const srcSpies = spySrcFs(); const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === angularSkillsDir + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [treeSkillFilePath] : [] + ), + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => + p === treeSkillFilePath ? content : "" ) }); App.container.set(FS_TOKEN, destFs); - const result = copyAISkillsToProject(["claude"], "angular"); + copyAISkillsToProject(["claude"], "angular"); - expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", mockSkillContent); - expect(result.details).toEqual([{ path: ".claude/skills/angular.md", action: "created" }]); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", content); + expect(srcSpies.glob).not.toHaveBeenCalled(); + expect(srcSpies.readFile).not.toHaveBeenCalled(); }); it("should prefer the licensed @infragistics/igniteui-angular package if installed", () => { @@ -99,14 +123,7 @@ describe("Unit - copyAISkillsToProject", () => { const angularSkillsDir = skillsDir(licensedPkg); const skillFilePath = skillFile(licensedPkg, "angular.md"); - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => - dir === angularSkillsDir ? [skillFilePath] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content") - }); - - // resolvePackage + directoryExists use the container FS (destFs) + // resolvePackage, directoryExists and the package skill reads use the container FS (destFs) const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === angularSkillsDir @@ -114,8 +131,12 @@ describe("Unit - copyAISkillsToProject", () => { fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => p === "./package.json" ), + glob: jasmine.createSpy("destFs.glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { if (p === "./package.json") return JSON.stringify({ dependencies: { [licensedPkg]: "^18.0.0" } }); + if (p === skillFilePath) return "skill content"; return ""; }) }); @@ -131,11 +152,6 @@ describe("Unit - copyAISkillsToProject", () => { const skillFilePath = skillFile("igniteui-angular", "angular.md"); const newContent = "# Updated Angular skills"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.returnValue([skillFilePath]), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(newContent) - }); - const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === angularSkillsDir @@ -143,7 +159,10 @@ describe("Unit - copyAISkillsToProject", () => { fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => p === ".claude/skills/angular.md" ), - readFile: jasmine.createSpy("destFs.readFile").and.returnValue("") // older content + glob: jasmine.createSpy("destFs.glob").and.returnValue([skillFilePath]), + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => + p === skillFilePath ? newContent : "" // older content + ) }); App.container.set(FS_TOKEN, destFs); @@ -158,13 +177,6 @@ describe("Unit - copyAISkillsToProject", () => { const skillFilePath = skillFile("igniteui-angular", "angular.md"); const existingContent = "# Ignite UI for Angular skills"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => - dir === angularSkillsDir ? [skillFilePath] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(existingContent) - }); - const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === angularSkillsDir @@ -172,6 +184,9 @@ describe("Unit - copyAISkillsToProject", () => { fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => p === ".claude/skills/angular.md" ), + glob: jasmine.createSpy("destFs.glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), readFile: jasmine.createSpy("destFs.readFile").and.returnValue(existingContent) }); App.container.set(FS_TOKEN, destFs); @@ -194,21 +209,18 @@ describe("Unit - copyAISkillsToProject", () => { const file = skillFile(reactPkg, "overview.md"); const content = "# React overview"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((d: string) => d === dir ? [file] : [] ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { if (p === file) return content; return ""; }) }); - - const destFs = makeDestFs({ - directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => - p === dir - ) - }); App.container.set(FS_TOKEN, destFs); copyAISkillsToProject(["claude"], "react"); @@ -224,21 +236,18 @@ describe("Unit - copyAISkillsToProject", () => { const file = skillFile(wcPkg, "webcomponents.md"); const content = "# Ignite UI WebComponents skills"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((d: string) => d === dir ? [file] : [] ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { if (p === file) return content; return ""; }) }); - - const destFs = makeDestFs({ - directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => - p === dir - ) - }); App.container.set(FS_TOKEN, destFs); copyAISkillsToProject(["claude"], "webcomponents"); @@ -270,14 +279,11 @@ describe("Unit - copyAISkillsToProject", () => { it("should silently return when skills directory exists but is empty", () => { const dir = skillsDir("igniteui-angular"); - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.returnValue([]) - }); - const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === dir - ) + ), + glob: jasmine.createSpy("destFs.glob").and.returnValue([]) }); App.container.set(FS_TOKEN, destFs); @@ -293,17 +299,14 @@ describe("Unit - copyAISkillsToProject", () => { const dir = skillsDir(pkg); const file = skillFile(pkg, "angular.md"); - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === dir ? [file] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content") - }); - const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === dir ), + glob: jasmine.createSpy("destFs.glob").and.callFake((d: string) => + d === dir ? [file] : [] + ), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue("skill content"), writeFile: jasmine.createSpy("destFs.writeFile").and.throwError("EACCES: permission denied") }); App.container.set(FS_TOKEN, destFs); @@ -322,13 +325,6 @@ describe("Unit - copyAISkillsToProject", () => { const file = skillFile(pkg, "angular.md"); const destFile = ".claude/skills/angular.md"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === dir ? [file] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("new content") - }); - const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === dir @@ -336,7 +332,12 @@ describe("Unit - copyAISkillsToProject", () => { fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => p === destFile ), - readFile: jasmine.createSpy("destFs.readFile").and.returnValue("old content"), + glob: jasmine.createSpy("destFs.glob").and.callFake((d: string) => + d === dir ? [file] : [] + ), + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => + p === file ? "new content" : "old content" + ), writeFile: jasmine.createSpy("destFs.writeFile").and.throwError("EACCES: permission denied") }); App.container.set(FS_TOKEN, destFs); @@ -355,18 +356,15 @@ describe("Unit - copyAISkillsToProject", () => { const file1 = skillFile(pkg, "angular.md"); const file2 = skillFile(pkg, "components.md"); - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === dir ? [file1, file2] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content") - }); - let writeCallCount = 0; const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === dir ), + glob: jasmine.createSpy("destFs.glob").and.callFake((d: string) => + d === dir ? [file1, file2] : [] + ), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue("skill content"), writeFile: jasmine.createSpy("destFs.writeFile").and.callFake(() => { writeCallCount++; if (writeCallCount === 2) { @@ -457,8 +455,8 @@ describe("Unit - copyAISkillsToProject", () => { expect(mockTm.getFrameworkById).not.toHaveBeenCalledWith("angular"); }); - it("should read skill sources from real disk FS even when destFs is virtual", () => { - // Simulates the schematics scenario: srcFs (FsFileSystem) reads from disk, + it("should read bundled skill sources from real disk FS even when destFs is virtual", () => { + // Simulates the schematics scenario: template files outside the project are read from disk, // destFs (NgTreeFileSystem) writes into the virtual Tree. const ABS_TEMPLATE_PATH = path.resolve("/usr/lib/node_modules/fake-templates/base/files"); const SKILLS_ROOT = path.join(ABS_TEMPLATE_PATH, "skills"); @@ -483,7 +481,7 @@ describe("Unit - copyAISkillsToProject", () => { expect(srcSpies.readFile).toHaveBeenCalledWith(skillFilePath); // Dest writes go to the injected FS (virtual Tree) expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", content); - // destFs.glob should NOT have been called — source ops use srcFs + // destFs.glob should NOT have been called — bundled source ops use srcFs expect(destFs.glob).not.toHaveBeenCalled(); }); }); @@ -495,17 +493,14 @@ describe("Unit - copyAISkillsToProject", () => { const nestedFile = skillFile(pkg, "grids/grid.md"); const content = "# Grid skills"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === dir ? [nestedFile] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) - }); - const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === dir - ) + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((d: string) => + d === dir ? [nestedFile] : [] + ), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue(content) }); App.container.set(FS_TOKEN, destFs); @@ -521,17 +516,14 @@ describe("Unit - copyAISkillsToProject", () => { const skillFilePath = skillFile("igniteui-angular", "angular.md"); const content = "# Angular skills"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => - dir === angularSkillsDir ? [skillFilePath] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) - }); - const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === angularSkillsDir - ) + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue(content) }); App.container.set(FS_TOKEN, destFs); @@ -545,17 +537,14 @@ describe("Unit - copyAISkillsToProject", () => { const skillFilePath = skillFile("igniteui-angular", "angular.md"); const content = "# Angular skills"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => - dir === angularSkillsDir ? [skillFilePath] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) - }); - const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === angularSkillsDir - ) + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue(content) }); App.container.set(FS_TOKEN, destFs); @@ -570,17 +559,14 @@ describe("Unit - copyAISkillsToProject", () => { const file = skillFile(reactPkg, "overview.md"); const content = "# React overview"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === dir ? [file] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) - }); - const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === dir - ) + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((d: string) => + d === dir ? [file] : [] + ), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue(content) }); App.container.set(FS_TOKEN, destFs); @@ -594,17 +580,14 @@ describe("Unit - copyAISkillsToProject", () => { const skillFilePath = skillFile("igniteui-angular", "angular.md"); const content = "# Angular skills"; - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => - dir === angularSkillsDir ? [skillFilePath] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) - }); - const destFs = makeDestFs({ directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === angularSkillsDir - ) + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((dir: string) => + dir === angularSkillsDir ? [skillFilePath] : [] + ), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue(content) }); App.container.set(FS_TOKEN, destFs); @@ -634,23 +617,20 @@ describe("Unit - copyAISkillsToProject sources metadata", () => { const angularSkillsDir = skillsDir("igniteui-angular"); const skillFilePath = skillFile("igniteui-angular", "angular.md"); - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((dir: string) => dir === angularSkillsDir ? [skillFilePath] : [] ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { if (p === "node_modules/igniteui-angular/package.json") { return JSON.stringify({ name: "igniteui-angular", version: "18.2.0" }); } return "# skill content"; }) }); - - const destFs = makeDestFs({ - directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => - p === angularSkillsDir - ) - }); App.container.set(FS_TOKEN, destFs); const result = copyAISkillsToProject(["claude"], "angular"); @@ -689,23 +669,20 @@ describe("Unit - copyAISkillsToProject sources metadata", () => { const angularSkillsDir = skillsDir("igniteui-angular"); const skillFilePath = skillFile("igniteui-angular", "angular.md"); - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + glob: jasmine.createSpy("destFs.glob").and.callFake((dir: string) => dir === angularSkillsDir ? [skillFilePath] : [] ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { if (p === "node_modules/igniteui-angular/package.json") { throw new Error("ENOENT"); } return "# skill content"; }) }); - - const destFs = makeDestFs({ - directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => - p === angularSkillsDir - ) - }); App.container.set(FS_TOKEN, destFs); const result = copyAISkillsToProject(["claude"], "angular");