diff --git a/specs/017-scaffold-gaps-fix/checklists/requirements.md b/specs/017-scaffold-gaps-fix/checklists/requirements.md new file mode 100644 index 0000000..ee099e5 --- /dev/null +++ b/specs/017-scaffold-gaps-fix/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Scaffold gaps fix (1.29.0 audit) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-25 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- This spec describes infrastructure-level fixes to a code-scaffolding CLI. "Implementation details" here mean *output project* tech specifics (frameworks, files, packages). They are unavoidable in acceptance scenarios because the spec describes what files/dependencies a generator must or must not produce. All such mentions are constraints on the generated artifact, not on the implementation of the generator itself. +- All three rejected audit items (#9, #10, #12) are explicitly listed in the Out of scope section with rationale (Constitution rule #6). +- Items marked incomplete require spec updates before `/sk.plan`. diff --git a/specs/017-scaffold-gaps-fix/plan.md b/specs/017-scaffold-gaps-fix/plan.md new file mode 100644 index 0000000..21df9c5 --- /dev/null +++ b/specs/017-scaffold-gaps-fix/plan.md @@ -0,0 +1,141 @@ +# Implementation Plan: Scaffold gaps fix (1.29.0 audit) + +**Branch**: `017-scaffold-gaps-fix` | **Date**: 2026-04-25 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/017-scaffold-gaps-fix/spec.md` + +> **Plan detail**: `low` — research.md, data-model.md, contracts/, quickstart.md intentionally omitted. All technical details are already grounded in the spec, the conversation history, and the verified codebase audit. + +## Summary + +Six independent fixes to the ForgeKit CLI generators, sequenced to be parallelizable. Three P1 user stories fix issues that break the most common flows (default scaffold, `--ui none`, no-database backends). Two P2 stories address non-interactive/CI usage and the missing `ng test` target. One P3 story emits a dev-server proxy when both layers are scaffolded together. + +Approach: surgical edits to the existing generators and templates. No new abstractions, no helper extraction (Constitution rule #6) unless 3+ callsites are confirmed during implementation. One new field on `ProjectConfig` (`database`), one new field on `ResolvedVersions` (`angularCli`), one new TypeScript cap mirroring the existing vite cap pattern. + +## Technical Context + +**Language/Version**: TypeScript 5.9 on Node.js ≥20 +**Primary Dependencies**: Commander 14 (CLI), Inquirer 8 (prompts), Handlebars 4 (templates) +**Storage**: N/A — code-generation CLI; output is files on disk under the user's chosen project path +**Testing**: Vitest 4 + @vitest/coverage-v8; existing test layout in `src/__tests__/` and `src/generators/**/__tests__/` +**Target Platform**: Cross-platform Node CLI (macOS / Linux / Windows) +**Project Type**: CLI / library +**Performance Goals**: Existing scaffolding completes in seconds — no regression +**Constraints**: All file writes must remain under `Promise.all` batches in each generator (Constitution rule #10); network failures must remain silent (rule #5) +**Scale/Scope**: 6 fix areas across ~12 files, including 1 new field on `ProjectConfig`, 1 new field on `ResolvedVersions`, 4 new template files, 0 new helpers (no rule-#6 violations expected). + +## Constitution Check + +*Re-evaluated post-design — all gates pass. No violations to justify.* + +| Rule | Gate | How this plan complies | +|---|---|---| +| #1 — Each generator owns one layer | `proxy.conf.json` is written exclusively by the frontend generator (reads `config.backendType` from `ProjectConfig` SSOT, writes only inside `frontend/`). Backend generator never touches frontend output. | ✅ | +| #2 — Templates contain zero logic | `angular.json.hbs` `styles[]` becomes a flat array passed by the generator. No new `{{#if}}` over collections. The component-template `{{#if uiPrimeNG}}` gating already exists in `styles.scss.hbs` — extension to four sibling templates is a property toggle, not new logic. | ✅ | +| #3 — ProjectConfig is SSOT | `database: 'postgres' \| 'none'` added to `ProjectConfig`; flows top-down through `LAYER_CONFIG_MAP` and `runLayerGenerator`. No filesystem/env derivation. | ✅ | +| #4 — Fail fast, rollback completely | No change. Existing rollback path in the generator drivers covers all new file writes. | ✅ (no change) | +| #5 — Network failures silent | New `@angular/cli` fetch and the TypeScript cap both go through the existing `fetchNpmVersion` + `fetchWithTimeout` plumbing, which already returns `null` and falls back. | ✅ | +| #6 — No speculative abstractions | TTY-detection: inline at the 2 callsites (`commands/new.ts`, `commands/add.ts`). Only extract if a 3rd callsite appears during implementation. Backend port mapping for proxy: 4 entries in a literal `Record` inside the frontend generator — too small/local to extract. | ✅ | +| #7 — Tests declare all fixture fields | New unit tests in `src/__tests__/versions.test.ts` and `src/generators/**/__tests__/` will use full `ProjectConfig` and `ResolvedVersions` fixtures (the new fields force this — TS will fail otherwise). | ✅ | +| #8 — CLI detection synchronous and early | No change to CLI detection. | ✅ (no change) | +| #9 — Release only through pipeline | No change. Existing `git tag vX.Y.Z` flow continues. | ✅ (no change) | +| #10 — I/O parallelized | New `proxy.conf.json` write joins the existing `Promise.all` batch in `src/generators/frontend/index.ts`. New typescript+angularCli fetches join the existing `Promise.all` in `src/versions.ts:resolveVersions`. | ✅ | + +## Project Structure + +### Documentation (this feature) + +```text +specs/017-scaffold-gaps-fix/ +├── plan.md # this file +├── spec.md # feature spec +├── checklists/ +│ └── requirements.md # spec quality checklist (passed) +└── tasks.md # generated next, by sk:tasks +``` + +### Source code touched by this feature (repository root) + +```text +src/ +├── versions.ts # FR-1.1, FR-1.2 (add angularCli, cap typescript) +├── types.ts # FR-3.1 (add database to ProjectConfig) +├── commands/ +│ ├── new.ts # FR-3, FR-4 (--database, --no-auth, --yes, TTY default) +│ └── add.ts # FR-3, FR-4 (--database, --no-auth, --yes, TTY default; LAYER_CONFIG_MAP entry if needed) +├── generators/ +│ ├── frontend/ +│ │ └── index.ts # FR-2 (build flat styles[]); FR-6 (emit proxy.conf.json conditional on backendType) +│ └── backend/ +│ └── index.ts # FR-3 (pass database to template data) +└── templates/ + ├── frontend/ + │ ├── package.json.hbs # FR-1.1 (use versions.angularCli); FR-5 (karma+jasmine devDeps) + │ ├── angular.json.hbs # FR-2 (consume flat styles[]); FR-5 (test target); FR-6 (proxyConfig pointer) + │ ├── home.component.ts.hbs # FR-2.2 (gate --p-* tokens) + │ ├── layout.component.ts.hbs # FR-2.2 + │ ├── topbar.component.ts.hbs # FR-2.2 + │ ├── sidebar.component.ts.hbs # FR-2.2 + │ ├── proxy.conf.json.hbs # FR-6 (NEW) + │ ├── tsconfig.spec.json.hbs # FR-5 (NEW) + │ ├── test.ts.hbs # FR-5 (NEW — Karma bootstrap) + │ └── app.spec.ts.hbs # FR-5 (NEW — minimal sample spec) + └── backend/ + ├── pom.xml.hbs # FR-3.2 (gate JPA + postgresql + flyway behind {{#if database}}) + ├── application.yml.hbs # FR-3.3 (gate datasource + flyway settings) + └── application-dev.yml.hbs # FR-3.3 + +tests added (Vitest): +src/__tests__/versions.test.ts # FR-1 cases +src/generators/frontend/__tests__/index.test.ts # FR-2 (styles[]), FR-6 (proxy emission) +src/generators/backend/__tests__/ # FR-3 (pom diff under database='none' vs 'postgres') +src/commands/__tests__/ # FR-4 (--no-auth, --yes, TTY default) +``` + +**Structure Decision**: existing single-project ForgeKit layout (CLI + generators + templates + tests) — no structural change. Each generator continues to own exactly one layer (rule #1). + +## Phased delivery (parallelizable) + +The 6 fix areas form a near-DAG. Three batches the implementer can run sequentially; within each batch the work is independent: + +```text +batch A — 100% parallel + ├─ FR-1 versions.ts + package.json.hbs (angularCli + ts cap) + ├─ FR-2 angular.json.hbs styles[] + 4 component templates (UI gating) + └─ FR-3 ProjectConfig database flag + pom.xml.hbs + application*.yml.hbs + +batch B — depends on FR-3 landing first (touches commands/{new,add}.ts that FR-3 already edits) + └─ FR-4 --no-auth, --yes, non-TTY default + +batch C — independent of A/B except for the implicit `frontend generator + angular.json` surface + ├─ FR-5 ng test target + sample spec + └─ FR-6 proxy.conf.json + angular.json proxyConfig pointer +``` + +If batches A and C are run in parallel, the only merge surface is `angular.json.hbs` and `package.json.hbs`. Document this in `tasks.md` so the implementer either serializes those two files or applies both changes in one commit. + +## Test strategy (fast-mode, tdd=false) + +- **FR-1**: extend `src/__tests__/versions.test.ts` — mock `fetchNpmVersion` to return a TS 6.x string; assert `versions.typescript` stays `<6.0` when `frontend === 'angular'`. Add a case where `@angular/cli` returns `21.2.8` while `@angular/core` returns `21.2.10`; assert `versions.angularCli !== versions.angular`. Add the silent-fallback path: simulate `fetch` returning `null`; assert `versions.angularCli === FALLBACK_VERSIONS.angularCli` and no exception thrown. +- **FR-2**: in `src/generators/frontend/__tests__/`, render the frontend generator twice (`ui: 'primeng'` and `ui: 'none'`), parse the produced `angular.json`, assert `styles` array shape. Render each of the 4 component templates with `uiNone: true` and assert no `--p-` token appears in the output. +- **FR-3**: render `pom.xml.hbs` with `database: 'none'` and `database: 'postgres'`; assert presence/absence of `spring-boot-starter-data-jpa`, `org.postgresql:postgresql`, `flyway-core`. Same for `application.yml` (datasource keys). +- **FR-4**: in `src/commands/__tests__/`, use `commander` parsing in isolation: assert `--no-auth` sets `options.auth === false`. Spawn the CLI with `stdin` closed (`{ stdio: ['ignore', ...] }`) and assert no `ExitPromptError`. Assert `--yes` short-circuits inquirer prompts. +- **FR-5**: e2e — generate an Angular project to a tmp dir, run `npm install --no-audit --prefer-offline` and `npm test --silent`; assert exit 0. Gate this test on `process.env.FORGEKIT_E2E === '1'` to keep CI fast on PRs (existing pattern in the project — confirm during implementation). +- **FR-6**: render the frontend generator with `backendType: 'spring-boot'`; assert `proxy.conf.json` is in the produced file set with port `8080`. Repeat for `'fastapi'` (8000), `'nestjs'` (3000), `'nextjs'` (3000), `'laravel'` (8000), and `null` (file absent). + +Verification mode = `minimal` → per-task checks are `npm run build` + `npm run lint` (+ scoped tests on changed files). The full Vitest suite runs once in Phase 3 of the workflow. + +## Out of scope + +- audit #9 default JSON logs — Constitution rule #6. +- audit #10 default correlation-ID plumbing — Constitution rule #6. +- audit #12 Spring Security marker dependency — Constitution rule #6. +- Regenerating an existing project with a different `--database` value. +- Databases other than Postgres in the new `database` field. + +## Complexity Tracking + +> No Constitution Check violations. Section intentionally empty. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|---|---|---| +| _(none)_ | _(n/a)_ | _(n/a)_ | diff --git a/specs/017-scaffold-gaps-fix/spec.md b/specs/017-scaffold-gaps-fix/spec.md new file mode 100644 index 0000000..fc39e1a --- /dev/null +++ b/specs/017-scaffold-gaps-fix/spec.md @@ -0,0 +1,202 @@ +# Feature Specification: Scaffold gaps fix (1.29.0 audit) + +**Feature Branch**: `017-scaffold-gaps-fix` +**Created**: 2026-04-25 +**Status**: Draft +**Input**: User description: "Fix structural gaps observed during a real ForgeKit 1.29.0 scaffold (`forgekit new --spring-boot --angular`), based on a verified 12-issue audit." + +## Context + +A real-world scaffold session against ForgeKit 1.29.0 surfaced 12 issues. Six were verified against the codebase and are in scope for this feature. Three were rejected as speculative abstractions per Constitution rule #6 and are out of scope: + +- Default JSON-logs encoder for backends (rejected — opt-in only if added later). +- Default correlation-ID filter + interceptor (rejected — same reason). +- Spring Security marker dependency "for later" (rejected — `--auth` already adds it when actually needed). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Default scaffold installs without manual fixes (Priority: P1) + +A developer scaffolds a new full-stack project with the most common combination (`forgekit new --spring-boot --angular`) and expects `npm install` and `mvn install` to succeed on the generated project, today, without editing any file. Currently, both fail because the Angular CLI version is pinned to a non-existent npm release and TypeScript is pinned to a major incompatible with the Angular build peer range. + +**Why this priority**: This is the most common entry point into the tool. If the default `new` command produces a project that does not install, the tool is broken from the user's first attempt. + +**Independent Test**: Run `forgekit new demo --spring-boot --angular` against a clean directory in CI, then run `npm install` in `demo/frontend` and `mvn -DskipTests install` in `demo/backend`. Both must exit 0. + +**Acceptance Scenarios**: + +1. **Given** a clean working directory with network access, **When** the user runs `forgekit new demo --spring-boot --angular`, **Then** every dependency in the generated `frontend/package.json` resolves to a real published version on npm. +2. **Given** the same project, **When** the user runs `npm install` inside `frontend/`, **Then** the install exits 0 with no `ETARGET` and no `ERESOLVE` errors. +3. **Given** the same project, **When** the user runs `mvn -DskipTests install` inside `backend/`, **Then** Maven resolves all declared dependencies and the build exits 0. +4. **Given** a temporarily unreachable npm registry, **When** ForgeKit cannot fetch the latest published versions, **Then** generation still succeeds using fallback versions and the produced `package.json` still installs against npm later. + +--- + +### User Story 2 — `--ui none` produces a project that builds and looks correct (Priority: P1) + +A developer who does not want the PrimeNG component library expects `forgekit add angular --ui none` to produce a frontend that: +- Does not require any PrimeNG, primeicons, or primeflex packages to be installed. +- Builds with `ng build` exit 0. +- Renders with neutral, working styles — no broken color palette caused by undefined CSS variables. + +Currently the generator emits PrimeNG stylesheet entries in `angular.json` and `--p-*` CSS tokens in component templates regardless of UI choice. With `--ui none`, the build fails on missing modules; if the user removes those entries manually, components silently render with undefined colors. + +**Why this priority**: The flag is documented and offered to users. A flag whose output silently breaks visuals is worse than no flag at all. + +**Independent Test**: Run `forgekit add angular --ui none` in a fresh project, then run `npm install && ng build` inside `frontend/`. Build must exit 0. Inspect rendered components in a browser — no missing/black/white-on-white text. + +**Acceptance Scenarios**: + +1. **Given** the user passes `--ui none`, **When** the project is generated, **Then** `angular.json` `styles[]` contains only `src/styles.scss`. +2. **Given** the user passes `--ui none`, **When** the project is generated, **Then** no component template references `--p-*` CSS custom properties. +3. **Given** the user passes `--ui primeng` (or default), **When** the project is generated, **Then** PrimeNG-related stylesheet entries and tokens are present and functional, exactly as before this feature. + +--- + +### User Story 3 — Backend without a database boots cleanly (Priority: P1) + +A developer scaffolding a backend that does not need a database (e.g., a thin proxy, a stateless API gateway, a quick prototype) expects to be able to opt out of all database-layer dependencies, not just the migration tool. Currently `--no-flyway` removes Flyway but keeps JPA + the Postgres driver, which causes Spring Boot to try to connect to a non-existent local database at startup and crash. + +**Why this priority**: Users who don't want a database have no path to a running app today. + +**Independent Test**: Run `forgekit add spring-boot --database none` in a fresh project, then run `./mvnw spring-boot:run` inside `backend/`. The application must start (HTTP up on the configured port) without a running database. + +**Acceptance Scenarios**: + +1. **Given** the user passes `--database none`, **When** the project is generated, **Then** `pom.xml` contains no JPA starter, no Postgres driver, and no Flyway dependencies. +2. **Given** the user passes `--database none`, **When** the project is generated, **Then** `application.yml` (or equivalent) contains no `spring.datasource` or `spring.flyway` configuration. +3. **Given** the user passes `--database none`, **When** the developer runs `./mvnw spring-boot:run`, **Then** the application starts and responds on the configured port without any database running on the host. +4. **Given** the user does not pass `--database` (default), **When** the project is generated, **Then** the output is identical to today — JPA + Postgres driver + Flyway included. + +--- + +### User Story 4 — Non-interactive callers (CI, agents) get predictable output (Priority: P2) + +A CI pipeline, an AI agent, or a developer running through a pipe (`yes | forgekit add angular ...`) expects to be able to drive the CLI without any interactive prompt. Today, `forgekit add` always asks confirmation questions, and when stdin is piped, every prompt is auto-answered "y" — including "include authentication?", which silently turns auth on against intent. + +**Why this priority**: Important for the tool's usability in automation but does not block the happy interactive path. Existing scripts can work around it with patches; new ones cannot use the tool reliably. + +**Independent Test**: From a non-TTY shell, run `yes | forgekit add angular --no-auth` and confirm the produced project contains no auth-related files. Run `forgekit add angular --yes` from a TTY and confirm no prompt is shown. + +**Acceptance Scenarios**: + +1. **Given** the user passes `--no-auth`, **When** the project is generated, **Then** auth-related files (guards, services, login pages) are not emitted regardless of any other input. +2. **Given** stdin is not a TTY and no relevant flag is passed, **When** the CLI runs, **Then** all interactive prompts are skipped and configuration defaults are applied. +3. **Given** the user passes `--yes` from a TTY, **When** the CLI runs, **Then** no confirmation prompt is shown and defaults are applied for unspecified options. + +--- + +### User Story 5 — `ng test` works on a fresh project (Priority: P2) + +A developer expects `npm test` to run without errors on a freshly scaffolded Angular project. Currently `package.json` declares `"test": "ng test"`, but `angular.json` has no `test` target — the command fails with `Project does not have a 'test' target`. + +**Why this priority**: Doesn't break the happy path of building/serving, but is the second action most users take after scaffolding. + +**Independent Test**: Run `forgekit add angular` in a fresh project, then `npm install && npm test` inside `frontend/`. Test runner must exit 0 with at least one passing spec. + +**Acceptance Scenarios**: + +1. **Given** a freshly scaffolded Angular project, **When** the developer runs `npm test`, **Then** the test runner starts, executes at least one passing spec, and exits 0. +2. **Given** a freshly scaffolded Angular project, **When** the developer inspects the generated files, **Then** the project ships exactly one minimal sample spec — not a full test suite. + +--- + +### User Story 6 — Cross-layer dev-server proxy works out of the box (Priority: P3) + +A developer scaffolding both a backend and an Angular frontend expects to be able to run `ng serve` and have requests to `/api/...` reach the backend without manual CORS configuration or proxy wiring. Today no proxy file is emitted; the frontend hits `http://localhost:4200/api/...` and 404s. + +**Why this priority**: Affects only the dual-layer combination and only the dev-time experience. Production builds are unaffected. + +**Independent Test**: Run `forgekit new demo --spring-boot --angular`, start the backend, then run `ng serve` from `frontend/`, then `curl http://localhost:4200/api/...`. The request must reach the backend without CORS errors. + +**Acceptance Scenarios**: + +1. **Given** both a backend and an Angular frontend are scaffolded, **When** the project is generated, **Then** `frontend/proxy.conf.json` exists and routes `/api/**` to the matching backend port. +2. **Given** the same project, **When** the developer inspects `angular.json`, **Then** `serve.options.proxyConfig` references the generated proxy file. +3. **Given** only an Angular frontend is scaffolded (no backend), **When** the project is generated, **Then** no proxy file is emitted and `angular.json` is unchanged from today. + +--- + +### Edge Cases + +- **Network failure during version resolution**: ForgeKit must fall back silently to `FALLBACK_VERSIONS`, and the produced `package.json` must still install successfully against the live npm registry once it is reachable. +- **TypeScript releases a new major (e.g. 6.x) before Angular bumps its peer range**: ForgeKit must continue to pin TypeScript inside the supported range without manual intervention. +- **`@angular/cli` lags behind `@angular/core` (asymmetric publishing)**: ForgeKit must not pin them to the same version expression. +- **Existing project regenerated with `--database none` after originally being scaffolded with a database**: out of scope (regeneration semantics are not part of this feature). +- **User specifies `--ui none` but also passes a UI-specific flag (e.g. `--theme dark`)**: out of scope (no such flag exists today). +- **Non-TTY environment with no flags at all**: prompts are skipped, defaults are used. Output must remain deterministic across runs. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### FR-1 — Version resolution (covers audit #1, #2) + +- **FR-1.1**: ForgeKit MUST resolve `@angular/cli` independently from `@angular/core` and pin the generated `frontend/package.json` to a published `@angular/cli` version. +- **FR-1.2**: ForgeKit MUST pin TypeScript to a range compatible with the `@angular/build` peer-dependency declaration of the version it ships. +- **FR-1.3**: ForgeKit MUST keep the existing silent-fallback behavior on network failure: when version resolution cannot reach the registry, generation succeeds using the bundled fallback values. + +#### FR-2 — UI-aware Angular output (covers audit #3, #4) + +- **FR-2.1**: When `ui=none`, the generated `angular.json` `styles[]` array MUST contain only the project's own stylesheet entry — no PrimeNG, primeicons, or primeflex references. +- **FR-2.2**: When `ui=none`, no generated component template MAY emit references to `--p-*` CSS custom properties. +- **FR-2.3**: When `ui=primeng` (or the default), generated output MUST be functionally identical to the pre-feature output — same packages, same styles, same tokens. + +#### FR-3 — Database opt-out (covers audit #5) + +- **FR-3.1**: ForgeKit MUST expose a project-level configuration field that lets the user opt out of the entire database layer for Spring Boot backends. Allowed values: a default ("postgres") and an opt-out value ("none"). +- **FR-3.2**: When the database is opted out, the generated `pom.xml` MUST NOT include the JPA starter, the Postgres driver, or Flyway. +- **FR-3.3**: When the database is opted out, the generated application configuration MUST NOT contain datasource or migration settings. +- **FR-3.4**: When the user opts out of the database, opting in to Flyway MUST be impossible (the migration tool requires a database). +- **FR-3.5**: Default behavior (no flag) MUST match the pre-feature output exactly. + +#### FR-4 — Non-interactive UX (covers audit #7, #8) + +- **FR-4.1**: ForgeKit MUST accept a `--no-auth` flag on every command that today accepts `--auth`, and the negation MUST suppress the auth-inclusion prompt and produce a project without auth scaffolding. +- **FR-4.2**: ForgeKit MUST accept a `--yes` (alias `-y`) flag that suppresses every confirmation prompt and applies configured defaults to optional questions. +- **FR-4.3**: When stdin is not a TTY, ForgeKit MUST behave as if `--yes` was passed — no interactive prompts, defaults applied. +- **FR-4.4**: When stdin is a TTY and no `--yes` is passed, the existing interactive flow MUST be preserved unchanged. + +#### FR-5 — Angular `test` target (covers audit #11) + +- **FR-5.1**: A freshly scaffolded Angular project MUST contain a working `test` target that, combined with the project's existing `npm test` script, exits 0 when run from a clean install. +- **FR-5.2**: ForgeKit MUST ship exactly one minimal sample spec — enough to make the runner exit 0, not enough to be considered a test suite (Constitution rule #6). + +#### FR-6 — Cross-layer dev-server proxy (covers audit #6) + +- **FR-6.1**: When the project includes both a backend and an Angular frontend, the generated `frontend/proxy.conf.json` MUST route requests under `/api/**` to the host and port of the scaffolded backend. +- **FR-6.2**: The Angular generator MUST NOT write outside its own output directory; the proxy file lives under `frontend/` and is referenced from `frontend/angular.json`. +- **FR-6.3**: When no backend is scaffolded, ForgeKit MUST NOT emit a proxy file, and `angular.json` MUST remain unchanged from today. + +### Key Entities + +- **ProjectConfig**: the single source of truth carried top-down through every generator (Constitution rule #3). Gains one new field (`database`) for FR-3 and continues to carry `backendType`, `frontend`, `ui`, `auth`, etc., used by FR-2 / FR-4 / FR-6. +- **Resolved versions**: gains one new field (`angularCli`) for FR-1.1; the TypeScript field gains a frontend-aware cap for FR-1.2. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A default `forgekit new --spring-boot --angular` produces a project where both `npm install` (in `frontend/`) and `mvn -DskipTests install` (in `backend/`) exit 0 on a fresh machine — measured by an end-to-end smoke run on CI. +- **SC-002**: `forgekit add angular --ui none` followed by `npm install && ng build` exits 0 with zero PrimeNG packages installed — measured by inspecting the lockfile + a clean `ng build` run. +- **SC-003**: `forgekit add spring-boot --database none` produces a backend that starts via `./mvnw spring-boot:run` with no database server running on the host — measured by an HTTP probe against the configured port within 60 seconds of startup. +- **SC-004**: A non-TTY invocation (`yes | forgekit add angular --no-auth`) completes without writing any auth-related file — measured by a directory-listing assertion in CI. +- **SC-005**: `npm test` on a freshly scaffolded Angular project exits 0 with at least one passing spec — measured by parsing the runner exit code. +- **SC-006**: A request to `http://localhost:4200/api/health` reaches the running backend in a default `forgekit new --spring-boot --angular` setup, with no manual configuration — measured by an end-to-end probe in dev mode. +- **SC-007**: The full existing test suite (Vitest unit + e2e) keeps passing after this feature is merged, with new unit tests covering each of the six in-scope fixes. + +## Assumptions + +- The current `package.json.hbs` reuse of `versions.angular` for `@angular/cli` is the simplest existing mechanism to fix and replace; introducing a separate field is consistent with the project's existing per-package version-resolution pattern. +- The existing vite-version cap pattern in `src/versions.ts` is the precedent to mirror for the TypeScript cap. +- The existing `LAYER_CONFIG_MAP` / `runLayerGenerator` plumbing is the canonical extension point for adding the `database` field to `ProjectConfig`, per the project's own auto-memory checklist. +- The Angular generator can read `config.backendType` (already in `ProjectConfig`) without violating Constitution rule #1, because it writes only inside its own output directory. +- The existing `--auth` flag on Commander does not auto-generate a `--no-auth` negation; it must be declared explicitly. + +## Out of scope + +- Default structured (JSON) logs in Spring Boot output — speculative per Constitution rule #6. +- Default correlation-ID propagation primitive (backend filter + frontend interceptor) — speculative per Constitution rule #6. +- Spring Security "marker" dependency added by default for projects that intend to add auth later — speculative per Constitution rule #6. +- Regenerating an existing project with a different `--database` value (semantics of regeneration are not part of this feature). +- Supporting databases other than Postgres in the new `database` field (only "postgres" and "none" are exposed in this iteration). diff --git a/specs/017-scaffold-gaps-fix/tasks.md b/specs/017-scaffold-gaps-fix/tasks.md new file mode 100644 index 0000000..4c6a9c0 --- /dev/null +++ b/specs/017-scaffold-gaps-fix/tasks.md @@ -0,0 +1,173 @@ +--- + +description: "Task list for 017-scaffold-gaps-fix — verified 1.29.0 audit fixes" +--- + +# Tasks: Scaffold gaps fix (1.29.0 audit) + +**Input**: Design documents from `/Users/salimomrani/code/_AI/forgekit/specs/017-scaffold-gaps-fix/` +**Prerequisites**: spec.md (required), plan.md (required). No research.md / data-model.md / contracts/ — `plan-detail=low`. + +**Tests**: tests=true, **tdd=false** → implementation file written first, Vitest unit tests written **immediately after in the same task** (no separate RED-first task per skill exception under fast-mode). Constitution rule #7 enforced: any change to a typed config object (`ProjectConfig`, `ResolvedVersions`) updates **all** existing fixtures in the same task — no partial fixtures. + +**Organization**: Tasks are grouped by user story (matches spec.md priorities P1–P3). Setup and Foundational phases are intentionally empty — this feature ships against an existing 17-feature repo with no infrastructure to bootstrap. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: file-disjoint with other [P]-marked tasks in this list (could run in parallel if staffed) +- **[Story]**: maps to user stories from spec.md (US1–US6) + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: none — this feature lands on an established repo with no project init or new tooling. No tasks. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: none — `ProjectConfig` and `ResolvedVersions` extensions live inside the user-story tasks that need them (T001 adds `angularCli`, T003 adds `database`). Each of those tasks updates **all** existing fixtures in the same commit per Constitution rule #7. + +--- + +## Phase 3: User Story 1 — Default scaffold installs without manual fixes (Priority: P1) 🎯 MVP + +**Goal**: `forgekit new --spring-boot --angular` produces a project where `npm install` (frontend) and `mvn -DskipTests install` (backend) both exit 0 on a fresh machine. + +**Independent Test**: From a clean checkout, run `forgekit new demo --spring-boot --angular` then `cd demo/frontend && npm install --no-audit` — must exit 0 with no `ETARGET` and no `ERESOLVE` errors. + +### Implementation for User Story 1 + +- [x] T001 [P] [US1] **FR-1 (versions caps)** — In `src/versions.ts`: (a) add `angularCli: string` to the `ResolvedVersions` interface and to `FALLBACK_VERSIONS` (with a renovate datasource comment matching the existing pattern), (b) inside the `if (opts.frontend === "angular")` block, add `fetchNpmVersion("@angular/cli").then(set("angularCli"))` to the `tasks` array, (c) add a TypeScript cap mirroring the vite cap at `versions.ts:269` — when `frontend === "angular"`, override the `set("typescript")` callback so a fetched value starting with `6.` (or higher) is rejected and the fallback `5.9.0` is kept. In `src/templates/frontend/package.json.hbs:32`, change `"@angular/cli": "^{{versions.angular}}"` to `"@angular/cli": "^{{versions.angularCli}}"`. **Fixture rule #7**: update every `ResolvedVersions` literal across the test suite (find with `grep -rln "FALLBACK_VERSIONS\|ResolvedVersions" src/`) so each one declares `angularCli`. Add Vitest unit tests in `src/__tests__/versions.test.ts` covering: (i) `@angular/cli` is fetched separately and may differ from `@angular/core`, (ii) when the fetched typescript version is `6.0.0`, the resolved value stays `<6.0` for Angular projects but is unrestricted for non-Angular frontends, (iii) silent fallback path (mocked `fetch` returning `null`) leaves `versions.angularCli === FALLBACK_VERSIONS.angularCli` and never throws. + +**Checkpoint**: After T001, `forgekit new demo --spring-boot --angular` should produce a `frontend/package.json` whose `npm install` succeeds. + +--- + +## Phase 4: User Story 2 — `--ui none` produces a project that builds and looks correct (Priority: P1) + +**Goal**: `forgekit add angular --ui none` emits no PrimeNG/primeflex assets and no `--p-*` CSS tokens; the resulting project builds with `ng build` and renders neutral, working visuals. + +**Independent Test**: `forgekit add angular --ui none` then `cd frontend && npm install --no-audit && npx ng build` — exit 0 with no PrimeNG packages in `package.json` and zero `--p-` matches in `dist/**/*.css`. + +### Implementation for User Story 2 + +- [x] T002 [US2] **FR-2 (UI gating)** — In `src/generators/frontend/index.ts`, build the `angular.json` `styles[]` array as a plain string array on the generator side (driven by `config.uiFramework`): when UI is PrimeNG, include `node_modules/primeicons/primeicons.css`, `node_modules/primeflex/primeflex.css`, `src/styles.scss`; when UI is none/tailwind, include only `src/styles.scss` (or its CSS counterpart). In the generator, build `stylesJson = JSON.stringify(styles, null, 14)` (or whatever indent matches the existing `angular.json.hbs` block — verify by inspecting the file) and pass it as a single flat string field. In `src/templates/frontend/angular.json.hbs:21-24`, replace the hard-coded `styles[]` lines with a single triple-stash `{{{stylesJson}}}` placeholder at the correct indentation. No Handlebars logic over the array (Constitution rule #2). In each of `src/templates/frontend/home.component.ts.hbs`, `layout.component.ts.hbs`, `topbar.component.ts.hbs`, `sidebar.component.ts.hbs`, wrap every block that emits `--p-surface-*` / `--p-primary-color` / `--p-text-color` / `--p-*` tokens inside `{{#if uiPrimeNG}}…{{/if}}` (mirror the gating pattern already used in `styles.scss.hbs`). Provide an alternate neutral declaration inside `{{#if uiNone}}` (sans-serif font, default colours — mirror styles.scss `uiNone` block). Add Vitest tests in `src/generators/frontend/__tests__/index.test.ts` (or a new sibling) that render the generator with `uiFramework: 'primeng'`, then `'none'`, parse the produced `angular.json`, and assert the `styles` array shape; render each of the 4 component templates with `uiNone: true` and assert the rendered output contains zero `--p-` substrings. + +**Checkpoint**: After T002, `forgekit add angular --ui none` produces a project where `ng build` exits 0 and no `--p-*` token reaches `dist/`. + +--- + +## Phase 5: User Story 3 — Backend without a database boots cleanly (Priority: P1) + +**Goal**: `forgekit add spring-boot --database none` produces a backend that starts via `./mvnw spring-boot:run` with no database server on the host. + +**Independent Test**: `forgekit add spring-boot --database none && cd backend && ./mvnw spring-boot:run &` — within 60 s the process listens on the configured port; `pom.xml` contains zero matches of `data-jpa`, `postgresql`, `flyway`. + +### Implementation for User Story 3 + +- [x] T003 [US3] **FR-3 (database opt-out)** — In `src/types.ts`, add `database: 'postgres' | 'none'` to `ProjectConfig`. **Fixture rule #7**: update every `ProjectConfig` literal across the test suite (`grep -rln "ProjectConfig\b" src/__tests__ src/generators src/commands`) to declare `database`. In `src/templates/backend/pom.xml.hbs`, gate the `spring-boot-starter-data-jpa` dependency (lines 36-39), the `org.postgresql:postgresql` dependency (lines 55-60), and the existing flyway block (lines 61-70) inside a single `{{#if databasePostgres}}…{{/if}}` block. In `src/templates/backend/application.yml.hbs` and `application-dev.yml.hbs`, gate the `spring.datasource.*` and `spring.flyway.*` keys behind the same flag. In `src/generators/backend/index.ts`, derive `databasePostgres = config.database === 'postgres'` and `flyway = config.flyway && databasePostgres` (forces flyway off when database is none — FR-3.4) and pass both to the template data. In `src/commands/new.ts` and `src/commands/add.ts`, add `.option("--database ", "Type de base de données (postgres, none)", "postgres")` and an Inquirer prompt with the two choices defaulting to `postgres`; thread the value through `defaults`/`updatedConfig`/`LAYER_CONFIG_MAP` per the project's auto-memory checklist. Default behaviour with no flag must remain `database: 'postgres'` so output is byte-identical to today (FR-3.5). Add Vitest tests in `src/generators/backend/__tests__/` rendering `pom.xml.hbs` and `application.yml.hbs` with `database: 'none'` and `database: 'postgres'`, asserting presence/absence of the gated dependencies and config keys; add a regression case asserting that `flyway: true` + `database: 'none'` resolves to flyway off (no flyway-core in pom). + +**Checkpoint**: After T003, all three P1 stories are complete and the MVP slice ships. Hold/deploy here for an interim release if desired. + +--- + +## Phase 6: User Story 4 — Non-interactive callers get predictable output (Priority: P2) + +**Goal**: `--no-auth`, `--yes`, and non-TTY stdin all produce deterministic, prompt-free runs with sane defaults. + +**Independent Test**: `yes | forgekit add angular --no-auth` from a non-TTY shell completes without prompts and produces a project with no auth files; `forgekit add angular --yes` from a TTY runs with no confirmation prompt. + +### Implementation for User Story 4 + +- [x] T004 [US4] **FR-4 (non-interactive UX)** — In `src/commands/add.ts` and `src/commands/new.ts`: (a) replace `.option("--auth", ...)` with `.option("--auth", "Inclure l'authentification").option("--no-auth", "Exclure l'authentification")` so Commander generates the boolean negation, (b) add `.option("-y, --yes", "Skip all confirmation prompts and use defaults", false)`, (c) compute `nonInteractive = options.yes === true || !process.stdin.isTTY` once per command entry point, (d) at every existing Inquirer call site, when `nonInteractive` is true, skip the prompt and apply the configured default (the value already in `defaults`/`config`). Inline the TTY check at both call sites — do not extract a helper (only 2 callsites today; Constitution rule #6). The final confirmation prompt ("Is this correct?") is also skipped when `nonInteractive`. Add Vitest tests in `src/commands/__tests__/` (create the dir if absent) that: (i) parse the Commander program with `['--no-auth']` argv and assert `options.auth === false`, (ii) parse with `['--yes']` and assert `options.yes === true`, (iii) mock `process.stdin.isTTY = false` and a default `ProjectConfig` and confirm the auth prompt is skipped (use Inquirer's prompt registry or a lightweight stub). + +**Checkpoint**: After T004, CI pipelines and AI agents can drive ForgeKit without any interactive workaround. + +--- + +## Phase 7: User Story 5 — `ng test` works on a fresh project (Priority: P2) + +**Goal**: A freshly scaffolded Angular project's `npm test` exits 0 with at least one passing spec. + +**Independent Test**: `forgekit add angular && cd frontend && npm install --no-audit && npm test --silent` exits 0. + +### Implementation for User Story 5 + +- [x] T005 [US5] **FR-5 (Angular test target)** — In `src/templates/frontend/angular.json.hbs`, add a `"test"` architect target using `@angular/build:karma` (Angular 21+ ships the unified `@angular/build` builder for Karma) with `polyfills: ["zone.js", "zone.js/testing"]`, `tsConfig: "tsconfig.spec.json"`, and a `"styles"` reference pointing at the same flat array used by `build` (reuse the placeholder introduced in T002). In `src/templates/frontend/package.json.hbs` `devDependencies`, **hard-pin** Karma deps directly in the template (no `versions.*` plumbing — these versions move slowly and 7 new fields would bloat `ResolvedVersions` for marginal benefit; rule #6 supports this choice): `karma`, `karma-chrome-launcher`, `karma-coverage`, `karma-jasmine`, `karma-jasmine-html-reporter`, `jasmine-core`, `@types/jasmine`. Add a renovate annotation comment per pin (`// renovate: datasource=npm depName=`) so version bumps still flow through the existing automation. Hard-pinning these is **more** robust under network failure than fetching (rule #5), since the values are always present in the generated `package.json`. Create `src/templates/frontend/tsconfig.spec.json.hbs` with `extends: "./tsconfig.json"`, `compilerOptions.types: ["jasmine"]`, `include: ["src/**/*.spec.ts", "src/**/*.d.ts"]`. Create `src/templates/frontend/karma.conf.js.hbs` (only if Angular 21 still requires it — verify against `@angular/build:karma` v21 docs at implementation time; the modern builder may auto-configure). Create `src/templates/frontend/app.spec.ts.hbs` with one Jasmine spec asserting truthiness (e.g. `expect(true).toBe(true)`). Wire the new templates into `src/generators/frontend/index.ts` write batch (Promise.all per Constitution rule #10). Add a Vitest unit test in `src/generators/frontend/__tests__/` confirming the new template files are emitted and the generated `angular.json` contains a `test` target. + +**Checkpoint**: After T005, `npm test` works out of the box on every freshly scaffolded Angular project. + +--- + +## Phase 8: User Story 6 — Cross-layer dev-server proxy (Priority: P3) + +**Goal**: When both a backend and an Angular frontend are scaffolded, `frontend/proxy.conf.json` exists and `angular.json` references it; `ng serve` routes `/api/**` to the backend. + +**Independent Test**: `forgekit new demo --spring-boot --angular`, start backend on 8080, run `ng serve` in `frontend/`, hit `http://localhost:4200/api/health` — request reaches backend. + +### Implementation for User Story 6 + +- [x] T006 [US6] **FR-6 (cross-layer proxy)** — Create `src/templates/frontend/proxy.conf.json.hbs` containing a single mapping object: `{"/api/**": {"target": "http://localhost:{{backendPort}}", "secure": false, "changeOrigin": true, "logLevel": "debug"}}`. In `src/generators/frontend/index.ts`, define an inline literal `Record` (`{ "spring-boot": 8080, "fastapi": 8000, "nestjs": 3000, "nextjs": 3000, "laravel": 8000 }`) — do not extract a helper (rule #6, single callsite). When `config.backendType !== null`, add the proxy template render to the existing Promise.all I/O batch with `backendPort` resolved from the map, and set a `proxyConfig: 'proxy.conf.json'` flag in the data passed to `angular.json.hbs`. When `config.backendType === null`, do not emit the proxy file and pass `proxyConfig: null`. In `src/templates/frontend/angular.json.hbs`, inside the `serve.options` block (currently absent — add it), conditionally emit `"proxyConfig": "proxy.conf.json"` driven by the new flag (Handlebars `{{#if proxyConfig}}` over a single property is acceptable as it consumes flat data; alternatively, build the entire `serve` block on the generator side and triple-stash it for full rule #2 conformance — pick the path that keeps the diff smallest). Add Vitest tests in `src/generators/frontend/__tests__/` covering: (i) each `backendType` value emits `proxy.conf.json` with the matching port, (ii) `backendType: null` does NOT emit the file and `angular.json` stays without `proxyConfig`. + +**Checkpoint**: After T006, all six in-scope FRs ship. + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +- [x] T007 **Verification** — Run the full suite: `npm run lint`, `npm run typecheck`, `npm run build`, `npm test`. Resolve any regressions surfaced by the new fields in `ProjectConfig` / `ResolvedVersions`. Run an end-to-end smoke: `node dist/index.js new /tmp/forgekit-smoke-$$ --spring-boot --angular` followed by `cd /tmp/forgekit-smoke-$$/frontend && npm install --no-audit --prefer-offline && npm run build && npm test --silent` — all must exit 0. Repeat with `--ui none`, `--database none`, and a non-TTY invocation. Capture the smoke results in the PR description (per `verification-before-completion` evidence requirement). + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phases 1 and 2 are empty — start at Phase 3. +- Phases 3 (T001), 4 (T002), 5 (T003) are all P1 stories and pairwise independent **at the spec level**, but at the **file level** T002 / T005 / T006 all touch `src/templates/frontend/angular.json.hbs` and T001 / T005 touch `src/templates/frontend/package.json.hbs`. Order T002 before T005 before T006 to keep diffs clean. T001 can interleave anywhere before T005. +- T004 depends on T003 (both edit `commands/new.ts` and `commands/add.ts`; do T003 first to avoid a manual merge of the `--database` and `--no-auth` flag declarations). +- T007 must run last. + +### Within Each User Story + +- One task per story (per user request: 8-10 max). Each task contains its own implementation + Vitest unit tests + fixture updates. +- Tests are written **after** the implementation file in the same task (tdd=false). + +### Parallel Opportunities + +With one developer (subagents=false): execute T001 → T002 → T003 → T004 → T005 → T006 → T007 sequentially. The [P] marker on T001 documents file-disjoint potential, not a recommendation to fork work in this session. + +--- + +## Implementation Strategy + +### MVP First (User Story 1 only) + +1. Complete **T001** (FR-1 versions caps). +2. Run smoke: `forgekit new demo --spring-boot --angular && cd demo/frontend && npm install`. +3. If green → ship as a patch release (1.29.x) and stop here. T001 alone fixes the most painful regression. + +### Incremental Delivery (recommended) + +1. T001 → patch release covering audit #1 + #2. +2. T002 → minor release covering audit #3 + #4 (`--ui none` works). +3. T003 → minor release covering audit #5 (`--database none`). +4. T004 → minor release covering audit #7 + #8 (CI/agent ergonomics). +5. T005 + T006 together → minor release covering audit #11 + #6. +6. T007 closes the feature. + +### Single-PR delivery + +Land T001..T007 as one PR if the team prefers fewer integration points. Verification (T007) must still run and pass before merge. + +--- + +## Notes + +- [P] = file-disjoint with other [P] tasks; not a parallelism mandate. +- All tasks include their own Vitest tests and fixture updates per Constitution rule #7. +- Constitution rule #6 (no speculative abstractions) explicitly checked in T004 (TTY helper rejected — 2 callsites only) and T006 (port-map rejected — single callsite). +- Out of scope: audit #9 (default JSON logs), #10 (default correlation-ID), #12 (Spring Security marker dep). Not in tasks.md by design. +- Release semantics: per Constitution rule #9, version bumps are pipeline-driven via `git tag`. No `npm version` / manual bump task in this list. diff --git a/src/__tests__/fixtures.ts b/src/__tests__/fixtures.ts index 2940765..3026e4a 100644 --- a/src/__tests__/fixtures.ts +++ b/src/__tests__/fixtures.ts @@ -10,6 +10,7 @@ export function makeBaseConfig( description: "Test", backendType: null, frontend: null, + database: "postgres", flyway: false, openapi: false, auth: false, @@ -40,6 +41,7 @@ export const BASE_VERSIONS: ResolvedVersions = { sanctum: "4.3.1", scramble: "0.13.16", angular: "21.0.0", + angularCli: "21.0.0", angularBuild: "21.0.0", primeng: "21.1.1", primeuixThemes: "2.0.3", diff --git a/src/__tests__/versions.test.ts b/src/__tests__/versions.test.ts index 720a0ed..37957ef 100644 --- a/src/__tests__/versions.test.ts +++ b/src/__tests__/versions.test.ts @@ -66,6 +66,94 @@ describe("resolveVersions", () => { }); }); + describe("@angular/cli is fetched independently from @angular/core", () => { + it("should resolve angularCli to a value distinct from angular when the registry returns asymmetric versions", async () => { + vi.stubGlobal("fetch", (url: string) => { + const m = url.match( + /registry\.npmjs\.org\/([^/]+(?:\/[^/]+)?)\/latest/, + ); + const pkg = m ? decodeURIComponent(m[1]) : ""; + const versionsByPkg: Record = { + "@angular/core": "21.2.10", + "@angular/cli": "21.2.8", + }; + const v = versionsByPkg[pkg]; + return Promise.resolve( + v + ? ({ + ok: true, + json: () => Promise.resolve({ version: v }), + } as Response) + : ({ ok: false } as Response), + ); + }); + + const result = await resolveVersions({ + backendType: null, + frontend: "angular", + }); + + expect(result.angular).toBe("21.2.10"); + expect(result.angularCli).toBe("21.2.8"); + expect(result.angularCli).not.toBe(result.angular); + }); + + it("should fall back to FALLBACK_VERSIONS.angularCli when the @angular/cli fetch fails", async () => { + vi.stubGlobal("fetch", () => Promise.resolve({ ok: false } as Response)); + + const result = await resolveVersions({ + backendType: null, + frontend: "angular", + }); + + expect(result.angularCli).toBe(FALLBACK_VERSIONS.angularCli); + }); + }); + + describe("typescript is capped on the Angular peer range", () => { + it("should keep the fallback typescript value when the registry returns a 6.x release for an Angular project", async () => { + vi.stubGlobal("fetch", (url: string) => { + const isTypescript = url.includes("/typescript/latest"); + return Promise.resolve( + isTypescript + ? ({ + ok: true, + json: () => Promise.resolve({ version: "6.0.0" }), + } as Response) + : ({ ok: false } as Response), + ); + }); + + const result = await resolveVersions({ + backendType: null, + frontend: "angular", + }); + + expect(result.typescript).toBe(FALLBACK_VERSIONS.typescript); + }); + + it("should adopt the fetched typescript value when it is within the Angular peer range", async () => { + vi.stubGlobal("fetch", (url: string) => { + const isTypescript = url.includes("/typescript/latest"); + return Promise.resolve( + isTypescript + ? ({ + ok: true, + json: () => Promise.resolve({ version: "5.9.3" }), + } as Response) + : ({ ok: false } as Response), + ); + }); + + const result = await resolveVersions({ + backendType: null, + frontend: "angular", + }); + + expect(result.typescript).toBe("5.9.3"); + }); + }); + describe("fallback warning (win 2)", () => { it("prints a warning when all fetches fail and fallbacks are used", async () => { vi.stubGlobal("fetch", () => Promise.resolve({ ok: false } as Response)); diff --git a/src/commands/__tests__/options.test.ts b/src/commands/__tests__/options.test.ts new file mode 100644 index 0000000..3424207 --- /dev/null +++ b/src/commands/__tests__/options.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { Command } from "commander"; + +function buildAddCommand(): Command { + return new Command() + .name("add") + .argument("") + .option("--auth", "Inclure l'authentification") + .option("--no-auth", "Exclure l'authentification") + .option("-y, --yes", "Skip confirmations and apply defaults") + .option("--database ", "Base de données : postgres | none"); +} + +function buildNewCommand(): Command { + return new Command() + .name("new") + .argument("[name]") + .option("--auth", "Inclure l'authentification") + .option("--no-auth", "Exclure l'authentification") + .option("-y, --yes", "Skip confirmations and apply defaults") + .option("--database ", "Base de données : postgres | none"); +} + +describe("CLI flag parsing — non-interactive UX (FR-4)", () => { + describe("--no-auth", () => { + it("should set auth to false on the add command when --no-auth is passed", () => { + const cmd = buildAddCommand(); + cmd.parse(["node", "add", "spring-boot", "--no-auth"]); + expect(cmd.opts().auth).toBe(false); + }); + + it("should set auth to true on the add command when --auth is passed", () => { + const cmd = buildAddCommand(); + cmd.parse(["node", "add", "spring-boot", "--auth"]); + expect(cmd.opts().auth).toBe(true); + }); + + it("should leave auth unset when neither flag is provided", () => { + const cmd = buildAddCommand(); + cmd.parse(["node", "add", "spring-boot"]); + expect(cmd.opts().auth).toBeUndefined(); + }); + + it("should set auth to false on the new command when --no-auth is passed", () => { + const cmd = buildNewCommand(); + cmd.parse(["node", "new", "demo", "--no-auth"]); + expect(cmd.opts().auth).toBe(false); + }); + }); + + describe("--yes / -y", () => { + it("should set yes to true on the add command when --yes is passed", () => { + const cmd = buildAddCommand(); + cmd.parse(["node", "add", "spring-boot", "--yes"]); + expect(cmd.opts().yes).toBe(true); + }); + + it("should set yes to true on the add command when -y is passed", () => { + const cmd = buildAddCommand(); + cmd.parse(["node", "add", "spring-boot", "-y"]); + expect(cmd.opts().yes).toBe(true); + }); + + it("should leave yes undefined when no flag is provided", () => { + const cmd = buildAddCommand(); + cmd.parse(["node", "add", "spring-boot"]); + expect(cmd.opts().yes).toBeUndefined(); + }); + + it("should set yes to true on the new command when --yes is passed", () => { + const cmd = buildNewCommand(); + cmd.parse(["node", "new", "demo", "--yes"]); + expect(cmd.opts().yes).toBe(true); + }); + }); +}); + +describe("Non-interactive detection rule (FR-4.3)", () => { + it("should treat a process with --yes as non-interactive even when stdin is a TTY", () => { + const yes = true; + const isTTY = true; + const nonInteractive = yes === true || isTTY !== true; + expect(nonInteractive).toBe(true); + }); + + it("should treat a process with non-TTY stdin as non-interactive even without --yes", () => { + const yes: boolean | undefined = undefined; + const isTTY: boolean | undefined = undefined; + const nonInteractive = yes === true || isTTY !== true; + expect(nonInteractive).toBe(true); + }); + + it("should treat a TTY without --yes as interactive", () => { + const yes: boolean | undefined = undefined; + const isTTY = true; + const nonInteractive = yes === true || isTTY !== true; + expect(nonInteractive).toBe(false); + }); +}); + +describe("promptProjectConfig — non-interactive defaults (FR-4.3)", () => { + it("should resolve all defaults without invoking any inquirer prompt when nonInteractive is true", async () => { + const { promptProjectConfig } = await import("../../prompts/project.js"); + + const result = await promptProjectConfig( + { + name: "demo", + description: "Demo", + backendType: "spring-boot", + frontend: "angular", + }, + { nonInteractive: true }, + ); + + expect(result.name).toBe("demo"); + expect(result.backendType).toBe("spring-boot"); + expect(result.frontend).toBe("angular"); + expect(result.database).toBe("postgres"); + expect(result.auth).toBe(false); + expect(result.flyway).toBe(true); + }); + + it("should honour --no-auth when nonInteractive is true (auth=false from defaults)", async () => { + const { promptProjectConfig } = await import("../../prompts/project.js"); + + const result = await promptProjectConfig( + { + name: "demo", + description: "Demo", + backendType: "spring-boot", + frontend: "angular", + auth: false, + }, + { nonInteractive: true }, + ); + + expect(result.auth).toBe(false); + }); +}); diff --git a/src/commands/add.ts b/src/commands/add.ts index 213c314..4697b52 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -203,6 +203,9 @@ export const addCommand = new Command("add") .argument("", `Layer à ajouter : ${VALID_LAYERS.join(", ")}`) .option("--group ", "Group ID Java") .option("--auth", "Inclure l'authentification") + .option("--no-auth", "Exclure l'authentification") + .option("-y, --yes", "Skip confirmations and apply defaults") + .option("--database ", "Base de données : postgres | none") .option("--flyway", "Inclure Flyway (migrations SQL)") .option("--no-flyway", "Exclure Flyway") .option("--openapi", "Inclure OpenAPI / Swagger UI") @@ -251,6 +254,8 @@ Exemples: .action(async (layer: string, options: Record) => { console.log(chalk.bold.hex("#FF6B35")("\n🔨 ForgeKit — Add layer\n")); + const nonInteractive = options.yes === true || process.stdin.isTTY !== true; + // Validate layer if (!VALID_LAYERS.includes(layer)) { console.log( @@ -288,6 +293,7 @@ Exemples: description: "", backendType: null, frontend: null, + database: "postgres", flyway: false, openapi: false, auth: false, @@ -311,7 +317,7 @@ Exemples: } // Filesystem fallback confirmation - if (detectionSource === "filesystem") { + if (detectionSource === "filesystem" && !nonInteractive) { console.log(chalk.yellow("No forgekit.json found. Detected config:\n")); if (existingConfig.backendType) console.log(chalk.gray(` Backend: ${existingConfig.backendType}`)); @@ -348,6 +354,17 @@ Exemples: const defaults: Partial = {}; if (options.group) defaults.groupId = options.group as string; if (typeof options.auth === "boolean") defaults.auth = options.auth; + if (typeof options.database === "string") { + if (options.database !== "postgres" && options.database !== "none") { + console.log( + chalk.red( + `\n✖ Valeur invalide pour --database : "${options.database}". Attendu : postgres | none.`, + ), + ); + process.exit(1); + } + defaults.database = options.database; + } if (typeof options.flyway === "boolean") defaults.flyway = options.flyway; if (typeof options.openapi === "boolean") defaults.openapi = options.openapi; @@ -362,7 +379,12 @@ Exemples: // Prompt for layer-specific options let layerConfig: Partial; try { - layerConfig = await promptAddLayerConfig(layer, existingConfig, defaults); + layerConfig = await promptAddLayerConfig( + layer, + existingConfig, + defaults, + { nonInteractive }, + ); } catch (error) { if ( error instanceof Error && diff --git a/src/commands/new.ts b/src/commands/new.ts index c81dd66..f240790 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -180,6 +180,9 @@ export const newCommand = new Command("new") .option("--react", "Inclure le frontend React (Vite + Tailwind)") .option("--angular", "Inclure le frontend Angular (standalone, OnPush)") .option("--auth", "Inclure l'authentification") + .option("--no-auth", "Exclure l'authentification") + .option("-y, --yes", "Skip confirmations and apply defaults") + .option("--database ", "Base de données : postgres | none") .option("--flyway", "Inclure Flyway (migrations SQL)") .option("--no-flyway", "Exclure Flyway") .option("--openapi", "Inclure OpenAPI / Swagger UI") @@ -247,6 +250,17 @@ Exemples: defaults.frontend = "angular" as FrontendType; else if (options.frontend === false) defaults.frontend = null; if (typeof options.auth === "boolean") defaults.auth = options.auth; + if (typeof options.database === "string") { + if (options.database !== "postgres" && options.database !== "none") { + console.log( + chalk.red( + `\n✖ Valeur invalide pour --database : "${options.database}". Attendu : postgres | none.`, + ), + ); + process.exit(1); + } + defaults.database = options.database; + } if (typeof options.flyway === "boolean") defaults.flyway = options.flyway; if (typeof options.openapi === "boolean") defaults.openapi = options.openapi; @@ -280,9 +294,12 @@ Exemples: defaults.workflowMode = options.workflow as WorkflowMode; if (isExplicit("git")) defaults.gitInit = options.git as boolean; + const nonInteractive = + options.yes === true || process.stdin.isTTY !== true; + let config; try { - config = await promptProjectConfig(defaults); + config = await promptProjectConfig(defaults, { nonInteractive }); } catch { console.log(chalk.yellow("\n\n👋 Génération annulée.")); process.exit(0); diff --git a/src/generators/backend/__tests__/index.test.ts b/src/generators/backend/__tests__/index.test.ts new file mode 100644 index 0000000..7ae26cc --- /dev/null +++ b/src/generators/backend/__tests__/index.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs-extra"; +import path from "node:path"; +import os from "node:os"; +import { generateBackend } from "../index.js"; +import { makeBaseConfig, BASE_VERSIONS } from "../../../__tests__/fixtures.js"; + +describe("generateBackend — database opt-out (FR-3)", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "forgekit-backend-test-")); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + describe("pom.xml dependencies (FR-3.2)", () => { + it("should include JPA, postgresql and flyway when database is postgres and flyway is true", async () => { + const config = makeBaseConfig({ + backendType: "spring-boot", + database: "postgres", + flyway: true, + }); + + await generateBackend(tmpDir, config, BASE_VERSIONS); + const pom = await fs.readFile( + path.join(tmpDir, "backend", "pom.xml"), + "utf-8", + ); + + expect(pom).toContain("spring-boot-starter-data-jpa"); + expect(pom).toContain("postgresql"); + expect(pom).toContain("flyway-core"); + }); + + it("should exclude JPA, postgresql and flyway when database is none", async () => { + const config = makeBaseConfig({ + backendType: "spring-boot", + database: "none", + flyway: false, + }); + + await generateBackend(tmpDir, config, BASE_VERSIONS); + const pom = await fs.readFile( + path.join(tmpDir, "backend", "pom.xml"), + "utf-8", + ); + + expect(pom).not.toContain("spring-boot-starter-data-jpa"); + expect(pom).not.toContain("postgresql"); + expect(pom).not.toContain("flyway-core"); + }); + + it("should force flyway off when database is none even if flyway flag is true (FR-3.4)", async () => { + const config = makeBaseConfig({ + backendType: "spring-boot", + database: "none", + flyway: true, + }); + + await generateBackend(tmpDir, config, BASE_VERSIONS); + const pom = await fs.readFile( + path.join(tmpDir, "backend", "pom.xml"), + "utf-8", + ); + const dbMigrationDir = path.join( + tmpDir, + "backend", + "src/main/resources/db/migration", + ); + + expect(pom).not.toContain("flyway-core"); + expect(await fs.pathExists(dbMigrationDir)).toBe(false); + }); + }); + + describe("application.yml configuration (FR-3.3)", () => { + it("should include datasource and jpa keys when database is postgres", async () => { + const config = makeBaseConfig({ + backendType: "spring-boot", + database: "postgres", + flyway: true, + }); + + await generateBackend(tmpDir, config, BASE_VERSIONS); + const appYml = await fs.readFile( + path.join(tmpDir, "backend", "src/main/resources/application.yml"), + "utf-8", + ); + + expect(appYml).toMatch(/^\s+datasource:/m); + expect(appYml).toMatch(/^\s+jpa:/m); + expect(appYml).toMatch(/^\s+flyway:/m); + }); + + it("should exclude datasource, jpa and flyway keys when database is none", async () => { + const config = makeBaseConfig({ + backendType: "spring-boot", + database: "none", + flyway: false, + }); + + await generateBackend(tmpDir, config, BASE_VERSIONS); + const appYml = await fs.readFile( + path.join(tmpDir, "backend", "src/main/resources/application.yml"), + "utf-8", + ); + const appDevYml = await fs.readFile( + path.join(tmpDir, "backend", "src/main/resources/application-dev.yml"), + "utf-8", + ); + + expect(appYml).not.toMatch(/datasource:/); + expect(appYml).not.toMatch(/^\s+jpa:/m); + expect(appYml).not.toMatch(/flyway:/); + expect(appDevYml).not.toMatch(/jpa:/); + }); + }); + + describe("default behavior is unchanged (FR-3.5)", () => { + it("should produce a pom.xml containing JPA when no database flag is overridden", async () => { + const config = makeBaseConfig({ + backendType: "spring-boot", + flyway: true, + }); + + await generateBackend(tmpDir, config, BASE_VERSIONS); + const pom = await fs.readFile( + path.join(tmpDir, "backend", "pom.xml"), + "utf-8", + ); + + expect(pom).toContain("spring-boot-starter-data-jpa"); + expect(pom).toContain("postgresql"); + }); + }); +}); diff --git a/src/generators/backend/index.ts b/src/generators/backend/index.ts index d7d9274..656dcb5 100644 --- a/src/generators/backend/index.ts +++ b/src/generators/backend/index.ts @@ -52,7 +52,10 @@ class BackendGenerator extends BaseGenerator { path.join(backendDir, ".mvn/wrapper"), ]; - if (this.config.flyway) { + const databasePostgres = this.config.database === "postgres"; + const flyway = this.config.flyway && databasePostgres; + + if (flyway) { dirs.push(path.join(resourcesDir, "db/migration")); } @@ -65,7 +68,9 @@ class BackendGenerator extends BaseGenerator { name: this.config.name, description: this.config.description, auth: this.config.auth, - flyway: this.config.flyway, + database: this.config.database, + databasePostgres, + flyway, openapi: this.config.openapi, mapstruct: this.config.mapstruct, dbName: this.dbName, @@ -132,7 +137,7 @@ class BackendGenerator extends BaseGenerator { path.join(backendDir, "mvnw.cmd"), data, ), - ...(this.config.flyway + ...(flyway ? [ fs.writeFile( path.join(resourcesDir, "db/migration/V1__init.sql"), diff --git a/src/generators/frontend/__tests__/index.test.ts b/src/generators/frontend/__tests__/index.test.ts index 8e9191a..9a5a011 100644 --- a/src/generators/frontend/__tests__/index.test.ts +++ b/src/generators/frontend/__tests__/index.test.ts @@ -187,4 +187,179 @@ describe("generateFrontend router", () => { expect(tsStaged).toContain("eslint --fix"); expect(tsStaged).toContain("prettier --write"); }); + + describe("Angular: angular.json styles array reflects UI choice (FR-2.1)", () => { + it("should include primeicons and primeflex stylesheets when uiFramework is primeng", async () => { + const config = { + ...baseConfig, + frontend: "angular" as const, + uiFramework: "primeng" as const, + }; + await generateFrontend(tmpDir, config, baseVersions); + const angularJson = await fs.readJson( + path.join(tmpDir, "frontend", "angular.json"), + ); + const styles = + angularJson.projects[Object.keys(angularJson.projects)[0]].architect + .build.options.styles; + expect(styles).toEqual([ + "node_modules/primeicons/primeicons.css", + "node_modules/primeflex/primeflex.css", + "src/styles.scss", + ]); + }); + + it("should include only src/styles.scss when uiFramework is none", async () => { + const config = { + ...baseConfig, + frontend: "angular" as const, + uiFramework: "none" as const, + }; + await generateFrontend(tmpDir, config, baseVersions); + const angularJson = await fs.readJson( + path.join(tmpDir, "frontend", "angular.json"), + ); + const styles = + angularJson.projects[Object.keys(angularJson.projects)[0]].architect + .build.options.styles; + expect(styles).toEqual(["src/styles.scss"]); + }); + + it("should include only src/styles.scss when uiFramework is tailwind", async () => { + const config = { + ...baseConfig, + frontend: "angular" as const, + uiFramework: "tailwind" as const, + }; + await generateFrontend(tmpDir, config, baseVersions); + const angularJson = await fs.readJson( + path.join(tmpDir, "frontend", "angular.json"), + ); + const styles = + angularJson.projects[Object.keys(angularJson.projects)[0]].architect + .build.options.styles; + expect(styles).toEqual(["src/styles.scss"]); + }); + }); + + describe("Angular: dev-server proxy reflects backend pairing (FR-6)", () => { + it.each([ + ["spring-boot" as const, 8080], + ["fastapi" as const, 8000], + ["nestjs" as const, 3000], + ["nextjs" as const, 3000], + ["laravel" as const, 8000], + ])( + "should emit proxy.conf.json with port %i when backendType is %s", + async (backendType, expectedPort) => { + const config = { + ...baseConfig, + frontend: "angular" as const, + backendType, + }; + await generateFrontend(tmpDir, config, baseVersions); + const proxyPath = path.join(tmpDir, "frontend", "proxy.conf.json"); + expect(await fs.pathExists(proxyPath)).toBe(true); + const proxy = await fs.readJson(proxyPath); + expect(proxy["/api/**"].target).toBe( + `http://localhost:${expectedPort}`, + ); + const angularJson = await fs.readJson( + path.join(tmpDir, "frontend", "angular.json"), + ); + const serveOptions = + angularJson.projects[Object.keys(angularJson.projects)[0]].architect + .serve.options; + expect(serveOptions.proxyConfig).toBe("proxy.conf.json"); + }, + ); + + it("should not emit proxy.conf.json when no backend is scaffolded", async () => { + const config = { + ...baseConfig, + frontend: "angular" as const, + backendType: null, + }; + await generateFrontend(tmpDir, config, baseVersions); + expect( + await fs.pathExists(path.join(tmpDir, "frontend", "proxy.conf.json")), + ).toBe(false); + const angularJson = await fs.readJson( + path.join(tmpDir, "frontend", "angular.json"), + ); + const serveOptions = + angularJson.projects[Object.keys(angularJson.projects)[0]].architect + .serve.options; + expect(serveOptions?.proxyConfig).toBeUndefined(); + }); + }); + + describe("Angular: ng test target is configured out of the box (FR-5)", () => { + it("should declare a test architect target backed by @angular/build:karma", async () => { + const config = { ...baseConfig, frontend: "angular" as const }; + await generateFrontend(tmpDir, config, baseVersions); + const angularJson = await fs.readJson( + path.join(tmpDir, "frontend", "angular.json"), + ); + const testTarget = + angularJson.projects[Object.keys(angularJson.projects)[0]].architect + .test; + expect(testTarget).toBeDefined(); + expect(testTarget.builder).toBe("@angular/build:karma"); + expect(testTarget.options.tsConfig).toBe("tsconfig.spec.json"); + expect(testTarget.options.polyfills).toContain("zone.js/testing"); + }); + + it("should ship tsconfig.spec.json and app.component.spec.ts so ng test exits 0", async () => { + const config = { ...baseConfig, frontend: "angular" as const }; + await generateFrontend(tmpDir, config, baseVersions); + expect( + await fs.pathExists( + path.join(tmpDir, "frontend", "tsconfig.spec.json"), + ), + ).toBe(true); + expect( + await fs.pathExists( + path.join(tmpDir, "frontend", "src/app/app.component.spec.ts"), + ), + ).toBe(true); + }); + + it("should pin Karma + Jasmine devDependencies so npm install resolves them", async () => { + const config = { ...baseConfig, frontend: "angular" as const }; + await generateFrontend(tmpDir, config, baseVersions); + const pkg = await fs.readJson( + path.join(tmpDir, "frontend", "package.json"), + ); + expect(pkg.devDependencies.karma).toBeDefined(); + expect(pkg.devDependencies["karma-jasmine"]).toBeDefined(); + expect(pkg.devDependencies["karma-chrome-launcher"]).toBeDefined(); + expect(pkg.devDependencies["jasmine-core"]).toBeDefined(); + expect(pkg.devDependencies["@types/jasmine"]).toBeDefined(); + }); + }); + + describe("Angular: component templates do not reference --p-* tokens (FR-2.2)", () => { + it.each([ + ["src/app/features/home/home.component.ts"], + ["src/app/layout/layout.component.ts"], + ["src/app/layout/topbar/topbar.component.ts"], + ["src/app/layout/sidebar/sidebar.component.ts"], + ])( + "should emit no --p-* token in %s when uiFramework is none", + async (relPath) => { + const config = { + ...baseConfig, + frontend: "angular" as const, + uiFramework: "none" as const, + }; + await generateFrontend(tmpDir, config, baseVersions); + const content = await fs.readFile( + path.join(tmpDir, "frontend", relPath), + "utf-8", + ); + expect(content).not.toMatch(/var\(--p-/); + }, + ); + }); }); diff --git a/src/generators/frontend/index.ts b/src/generators/frontend/index.ts index 9fe89df..1a89f7a 100644 --- a/src/generators/frontend/index.ts +++ b/src/generators/frontend/index.ts @@ -2,11 +2,21 @@ import path from "node:path"; import fs from "fs-extra"; import { renderAndWrite } from "../../utils/template-engine.js"; import { BaseGenerator } from "../base-generator.js"; -import type { ProjectConfig } from "../../types.js"; +import type { BackendType, ProjectConfig } from "../../types.js"; import type { ResolvedVersions } from "../../versions.js"; import { generateReactViteFrontend } from "./react-vite.js"; import { generateVueFrontend } from "./vue.js"; +// Local port map for the dev-server proxy (FR-6). +// Inline literal — single callsite, rule #6 forbids extracting a helper. +const BACKEND_DEV_PORTS: Record, number> = { + "spring-boot": 8080, + fastapi: 8000, + nestjs: 3000, + nextjs: 3000, + laravel: 8000, +}; + class FrontendGenerator extends BaseGenerator { private readonly versions: ResolvedVersions; private readonly projectName: string; @@ -49,9 +59,19 @@ class FrontendGenerator extends BaseGenerator { const devDeps: Record = { "@angular/build": `^${this.versions.angularBuild}`, - "@angular/cli": `^${this.versions.angular}`, + "@angular/cli": `^${this.versions.angularCli}`, "@angular/compiler-cli": `^${this.versions.angular}`, typescript: `~${this.versions.typescript}`, + // Karma + Jasmine — hard-pinned (renovate-tracked). + // Versions move slowly; adding 7 fields to ResolvedVersions for + // marginal benefit would violate constitution rule #6. + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.1.0", + karma: "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", }; if (this.config.uiFramework === "tailwind") { @@ -137,6 +157,20 @@ class FrontendGenerator extends BaseGenerator { await this.ensureDirs(dirs); + const styles = + this.config.uiFramework === "primeng" + ? [ + "node_modules/primeicons/primeicons.css", + "node_modules/primeflex/primeflex.css", + "src/styles.scss", + ] + : ["src/styles.scss"]; + + const backendType = this.config.backendType; + const backendPort = + backendType !== null ? BACKEND_DEV_PORTS[backendType] : null; + const proxyConfig = backendPort !== null ? "proxy.conf.json" : null; + const data = { projectName: this.projectName, name: this.config.name, @@ -149,6 +183,9 @@ class FrontendGenerator extends BaseGenerator { versions: this.versions, eslintWithPrettier: this.config.eslint && this.config.prettier, isAngular: true, + stylesJson: JSON.stringify(styles), + backendPort, + proxyConfig, }; await Promise.all([ @@ -162,6 +199,15 @@ class FrontendGenerator extends BaseGenerator { path.join(frontendDir, "angular.json"), data, ), + ...(proxyConfig + ? [ + renderAndWrite( + "frontend/proxy.conf.json.hbs", + path.join(frontendDir, "proxy.conf.json"), + data, + ), + ] + : []), renderAndWrite( "frontend/tsconfig.json.hbs", path.join(frontendDir, "tsconfig.json"), @@ -172,6 +218,16 @@ class FrontendGenerator extends BaseGenerator { path.join(frontendDir, "tsconfig.app.json"), data, ), + renderAndWrite( + "frontend/tsconfig.spec.json.hbs", + path.join(frontendDir, "tsconfig.spec.json"), + data, + ), + renderAndWrite( + "frontend/app.component.spec.ts.hbs", + path.join(appDir, "app.component.spec.ts"), + data, + ), renderAndWrite( "frontend/gitignore.hbs", path.join(frontendDir, ".gitignore"), diff --git a/src/prompts/add.ts b/src/prompts/add.ts index 015485f..f1a9d1d 100644 --- a/src/prompts/add.ts +++ b/src/prompts/add.ts @@ -1,33 +1,40 @@ import { input, confirm, checkbox, select } from "@inquirer/prompts"; import { loadConfig } from "../config.js"; import { validateGroupId } from "../utils/validation.js"; -import type { ProjectConfig, UIFramework, PrimeNGPreset } from "../types.js"; +import type { + ProjectConfig, + UIFramework, + PrimeNGPreset, + DatabaseType, +} from "../types.js"; export async function promptAddLayerConfig( layer: string, existingConfig: ProjectConfig, defaults: Partial = {}, + options: { nonInteractive?: boolean } = {}, ): Promise> { + const nonInteractive = options.nonInteractive === true; if (layer === "spring-boot") { - return promptSpringBoot(defaults); + return promptSpringBoot(defaults, nonInteractive); } if (layer === "fastapi") { - return promptAuth(defaults); + return promptAuth(defaults, nonInteractive); } if (layer === "nextjs") { - return promptNextJs(defaults); + return promptNextJs(defaults, nonInteractive); } if (layer === "laravel") { - return promptLaravel(defaults); + return promptLaravel(defaults, nonInteractive); } if (layer === "angular") { - return promptAngular(defaults); + return promptAngular(defaults, nonInteractive); } if (layer === "react") { - return promptAuth(defaults); + return promptAuth(defaults, nonInteractive); } if (layer === "vue") { - return promptVue(defaults); + return promptVue(defaults, nonInteractive); } if (layer === "prettier") { if (existingConfig.frontend === null) { @@ -50,23 +57,39 @@ export async function promptAddLayerConfig( async function promptSpringBoot( defaults: Partial, + nonInteractive: boolean, ): Promise> { const saved = await loadConfig(); const groupId = defaults.groupId ?? - (await input({ - message: "Group ID", - default: saved.groupId ?? "com.example", - validate: validateGroupId, - })); + (nonInteractive + ? (saved.groupId ?? "com.example") + : await input({ + message: "Group ID", + default: saved.groupId ?? "com.example", + validate: validateGroupId, + })); + let database: DatabaseType = defaults.database ?? "postgres"; let flyway = defaults.flyway ?? true; let openapi = defaults.openapi ?? true; let auth = defaults.auth ?? false; let mapstruct = defaults.mapstruct ?? true; + if (!nonInteractive && defaults.database === undefined) { + database = await select({ + message: "Base de données", + choices: [ + { name: "PostgreSQL (par défaut)", value: "postgres" }, + { name: "Aucune (pas de JPA, pas de driver)", value: "none" }, + ], + default: "postgres", + }); + } + if ( + !nonInteractive && defaults.flyway === undefined && defaults.openapi === undefined && defaults.auth === undefined && @@ -87,17 +110,18 @@ async function promptSpringBoot( mapstruct = features.includes("mapstruct"); } - return { groupId, flyway, openapi, auth, mapstruct }; + return { groupId, database, flyway, openapi, auth, mapstruct }; } async function promptAngular( defaults: Partial, + nonInteractive: boolean, ): Promise> { let uiFramework: UIFramework = defaults.uiFramework ?? "primeng"; let primeNGPreset: PrimeNGPreset = defaults.primeNGPreset ?? "Aura"; let ngrx = defaults.ngrx ?? false; - if (defaults.uiFramework === undefined) { + if (!nonInteractive && defaults.uiFramework === undefined) { uiFramework = await select({ message: "Framework UI", choices: [ @@ -109,7 +133,11 @@ async function promptAngular( }); } - if (uiFramework === "primeng" && defaults.primeNGPreset === undefined) { + if ( + !nonInteractive && + uiFramework === "primeng" && + defaults.primeNGPreset === undefined + ) { primeNGPreset = await select({ message: "Preset PrimeNG", choices: [ @@ -121,24 +149,29 @@ async function promptAngular( }); } - if (defaults.ngrx === undefined) { + if (!nonInteractive && defaults.ngrx === undefined) { ngrx = await confirm({ message: "Inclure NgRx SignalStore ?", default: false, }); } - const authResult = await promptAuth(defaults); + const authResult = await promptAuth(defaults, nonInteractive); return { uiFramework, primeNGPreset, ngrx, ...authResult }; } async function promptLaravel( defaults: Partial, + nonInteractive: boolean, ): Promise> { let auth = defaults.auth ?? false; let openapi = defaults.openapi ?? false; - if (defaults.auth === undefined && defaults.openapi === undefined) { + if ( + !nonInteractive && + defaults.auth === undefined && + defaults.openapi === undefined + ) { const features = await checkbox({ message: "Fonctionnalités Laravel", choices: [ @@ -163,12 +196,14 @@ async function promptLaravel( async function promptNextJs( defaults: Partial, + nonInteractive: boolean, ): Promise> { let auth = defaults.auth ?? false; let prisma = defaults.prisma ?? false; let openapi = defaults.openapi ?? false; if ( + !nonInteractive && defaults.auth === undefined && defaults.prisma === undefined && defaults.openapi === undefined @@ -199,10 +234,11 @@ async function promptNextJs( async function promptAuth( defaults: Partial, + nonInteractive: boolean, ): Promise> { let auth = defaults.auth ?? false; - if (defaults.auth === undefined) { + if (!nonInteractive && defaults.auth === undefined) { auth = await confirm({ message: "Inclure l'authentification ?", default: false, @@ -214,6 +250,7 @@ async function promptAuth( async function promptVue( defaults: Partial, + nonInteractive: boolean, ): Promise> { - return promptAuth(defaults); + return promptAuth(defaults, nonInteractive); } diff --git a/src/prompts/project.ts b/src/prompts/project.ts index a1eb3d7..a54a8ca 100644 --- a/src/prompts/project.ts +++ b/src/prompts/project.ts @@ -17,72 +17,98 @@ import type { GitStrategy, SpeckitPreset, AITool, + DatabaseType, } from "../types.js"; export async function promptProjectConfig( defaults: Partial = {}, + options: { nonInteractive?: boolean } = {}, ): Promise { const saved = await loadConfig(); const currentDir = path.basename(process.cwd()); + const nonInteractive = options.nonInteractive === true; + const ask = (promptFn: () => Promise, fallback: T): Promise => + nonInteractive ? Promise.resolve(fallback) : promptFn(); // ── Section 1: Projet ───────────────────────────────────────────────────── const name = defaults.name ?? - (await input({ - message: "Nom du projet", - default: currentDir, - validate: validateProjectName, - })); + (await ask( + () => + input({ + message: "Nom du projet", + default: currentDir, + validate: validateProjectName, + }), + currentDir, + )); const description = defaults.description ?? - (await input({ - message: "Description", - default: "Mon application", - })); + (await ask( + () => + input({ + message: "Description", + default: "Mon application", + }), + "Mon application", + )); // ── Section 2: Stack ────────────────────────────────────────────────────── const backendType: BackendType = defaults.backendType !== undefined ? defaults.backendType - : await select({ - message: "Backend", - choices: [ - { name: "Spring Boot (Java 21)", value: "spring-boot" }, - { name: "FastAPI (Python)", value: "fastapi" }, - { name: "Laravel (PHP 8.3)", value: "laravel" }, - { name: "NestJS (Node.js/TypeScript)", value: "nestjs" }, - { name: "Next.js (Node.js)", value: "nextjs" }, - { name: "Aucun", value: null }, - ], - default: "spring-boot", - }); + : await ask( + () => + select({ + message: "Backend", + choices: [ + { name: "Spring Boot (Java 21)", value: "spring-boot" }, + { name: "FastAPI (Python)", value: "fastapi" }, + { name: "Laravel (PHP 8.3)", value: "laravel" }, + { name: "NestJS (Node.js/TypeScript)", value: "nestjs" }, + { name: "Next.js (Node.js)", value: "nextjs" }, + { name: "Aucun", value: null }, + ], + default: "spring-boot", + }), + "spring-boot", + ); const frontend: FrontendType = defaults.frontend !== undefined ? defaults.frontend - : await select({ - message: "Frontend", - choices: [ - { name: "Angular (standalone, OnPush)", value: "angular" }, - { name: "React (Vite + Tailwind)", value: "react-vite" }, - { name: "Vue.js (Vite + Tailwind)", value: "vue" }, - { name: "Aucun", value: null }, - ], - default: "angular", - }); + : await ask( + () => + select({ + message: "Frontend", + choices: [ + { name: "Angular (standalone, OnPush)", value: "angular" }, + { name: "React (Vite + Tailwind)", value: "react-vite" }, + { name: "Vue.js (Vite + Tailwind)", value: "vue" }, + { name: "Aucun", value: null }, + ], + default: "angular", + }), + "angular", + ); const groupId = backendType === "spring-boot" ? (defaults.groupId ?? - (await input({ - message: "Group ID", - default: saved.groupId ?? "com.example", - validate: validateGroupId, - }))) + (await ask( + () => + input({ + message: "Group ID", + default: saved.groupId ?? "com.example", + validate: validateGroupId, + }), + saved.groupId ?? "com.example", + ))) : "com.example"; // ── Section 3: Backend features ─────────────────────────────────────────── + let database: DatabaseType = defaults.database ?? "postgres"; let flyway = defaults.flyway ?? true; let openapi = defaults.openapi ?? true; let auth = defaults.auth ?? false; @@ -90,6 +116,22 @@ export async function promptProjectConfig( let prisma = defaults.prisma ?? false; if ( + !nonInteractive && + backendType === "spring-boot" && + defaults.database === undefined + ) { + database = await select({ + message: "Base de données", + choices: [ + { name: "PostgreSQL (par défaut)", value: "postgres" }, + { name: "Aucune (pas de JPA, pas de driver)", value: "none" }, + ], + default: "postgres", + }); + } + + if ( + !nonInteractive && backendType === "spring-boot" && defaults.flyway === undefined && defaults.openapi === undefined && @@ -112,6 +154,7 @@ export async function promptProjectConfig( } if ( + !nonInteractive && backendType === "laravel" && defaults.auth === undefined && defaults.openapi === undefined @@ -136,6 +179,7 @@ export async function promptProjectConfig( } if ( + !nonInteractive && backendType === "nestjs" && defaults.auth === undefined && defaults.prisma === undefined && @@ -167,6 +211,7 @@ export async function promptProjectConfig( } if ( + !nonInteractive && backendType === "nextjs" && defaults.auth === undefined && defaults.prisma === undefined && @@ -203,7 +248,7 @@ export async function promptProjectConfig( let ngrx = defaults.ngrx ?? false; if (frontend === "angular") { - if (defaults.uiFramework === undefined) { + if (!nonInteractive && defaults.uiFramework === undefined) { uiFramework = await select({ message: "Framework UI", choices: [ @@ -215,7 +260,11 @@ export async function promptProjectConfig( }); } - if (uiFramework === "primeng" && defaults.primeNGPreset === undefined) { + if ( + !nonInteractive && + uiFramework === "primeng" && + defaults.primeNGPreset === undefined + ) { primeNGPreset = await select({ message: "Preset PrimeNG", choices: [ @@ -227,7 +276,7 @@ export async function promptProjectConfig( }); } - if (defaults.ngrx === undefined) { + if (!nonInteractive && defaults.ngrx === undefined) { ngrx = await confirm({ message: "Inclure NgRx SignalStore ?", default: false, @@ -250,6 +299,7 @@ export async function promptProjectConfig( let eslint = defaults.eslint ?? false; if ( + !nonInteractive && defaults.docker === undefined && defaults.ci === undefined && defaults.speckit === undefined && @@ -302,7 +352,7 @@ export async function promptProjectConfig( eslint = infra.includes("eslint"); } - if (defaults.aiTool === undefined) { + if (!nonInteractive && defaults.aiTool === undefined) { const claudeDetected = isClaudeInstalled(); const codexDetected = isCodexInstalled(); aiTool = await select({ @@ -326,7 +376,11 @@ export async function promptProjectConfig( }); } - if (aiTool !== "none" && defaults.workflowMode === undefined) { + if ( + !nonInteractive && + aiTool !== "none" && + defaults.workflowMode === undefined + ) { workflowMode = await select({ message: `Workflow mode (${aiTool === "claude" ? "Claude Code" : "Codex CLI"})`, choices: [ @@ -343,6 +397,7 @@ export async function promptProjectConfig( let speckitPreset: SpeckitPreset | null = defaults.speckitPreset ?? null; if ( + !nonInteractive && aiTool === "claude" && workflowMode === "speckit" && defaults.speckitPreset === undefined @@ -395,6 +450,7 @@ export async function promptProjectConfig( description, backendType, frontend, + database, flyway, openapi, auth, diff --git a/src/templates/backend/application-dev.yml.hbs b/src/templates/backend/application-dev.yml.hbs index c6d4de1..59dbf74 100644 --- a/src/templates/backend/application-dev.yml.hbs +++ b/src/templates/backend/application-dev.yml.hbs @@ -1,7 +1,9 @@ +{{#if databasePostgres}} spring: jpa: show-sql: true +{{/if}} logging: level: {{#if auth}} diff --git a/src/templates/backend/application.yml.hbs b/src/templates/backend/application.yml.hbs index 0a1d86a..d13ae8f 100644 --- a/src/templates/backend/application.yml.hbs +++ b/src/templates/backend/application.yml.hbs @@ -4,6 +4,7 @@ spring: profiles: active: dev +{{#if databasePostgres}} datasource: url: {{datasourceUrl}} username: ${DB_USERNAME:postgres} @@ -23,6 +24,7 @@ spring: locations: classpath:db/migration {{/if}} +{{/if}} server: port: 8080 diff --git a/src/templates/backend/pom.xml.hbs b/src/templates/backend/pom.xml.hbs index b97a0c1..b15498f 100644 --- a/src/templates/backend/pom.xml.hbs +++ b/src/templates/backend/pom.xml.hbs @@ -33,10 +33,12 @@ org.springframework.boot spring-boot-starter-web + {{#if databasePostgres}} org.springframework.boot spring-boot-starter-data-jpa + {{/if}} {{#if auth}} org.springframework.boot @@ -52,6 +54,7 @@ spring-boot-starter-actuator + {{#if databasePostgres}} org.postgresql @@ -68,6 +71,7 @@ flyway-database-postgresql {{/if}} + {{/if}} {{#if openapi}} diff --git a/src/templates/frontend/angular.json.hbs b/src/templates/frontend/angular.json.hbs index f82a065..3f5e464 100644 --- a/src/templates/frontend/angular.json.hbs +++ b/src/templates/frontend/angular.json.hbs @@ -17,11 +17,7 @@ "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", - "styles": [ - "node_modules/primeicons/primeicons.css", - "node_modules/primeflex/primeflex.css", - "src/styles.scss" - ] + "styles": {{{stylesJson}}} }, "configurations": { "production": { @@ -55,6 +51,9 @@ }, "serve": { "builder": "@angular/build:dev-server", + "options": { + {{#if proxyConfig}}"proxyConfig": "{{proxyConfig}}"{{/if}} + }, "configurations": { "production": { "buildTarget": "{{projectName}}:build:production" @@ -64,6 +63,15 @@ } }, "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:karma", + "options": { + "polyfills": ["zone.js", "zone.js/testing"], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "styles": {{{stylesJson}}} + } } } } diff --git a/src/templates/frontend/app.component.spec.ts.hbs b/src/templates/frontend/app.component.spec.ts.hbs new file mode 100644 index 0000000..ad0d70d --- /dev/null +++ b/src/templates/frontend/app.component.spec.ts.hbs @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + providers: [provideRouter([])], + }).compileComponents(); + }); + + it('should create the AppComponent instance', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); +}); diff --git a/src/templates/frontend/home.component.ts.hbs b/src/templates/frontend/home.component.ts.hbs index 0203e0f..f80024e 100644 --- a/src/templates/frontend/home.component.ts.hbs +++ b/src/templates/frontend/home.component.ts.hbs @@ -46,24 +46,24 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; .welcome-card { text-align: center; padding: 3rem 2rem; - background: var(--p-surface-0); - border-radius: var(--p-content-border-radius); - border: 1px solid var(--p-surface-200); + background: var(--app-card-bg); + border-radius: var(--app-radius); + border: 1px solid var(--app-border); margin-bottom: 2rem; } .welcome-icon { font-size: 3rem; - color: var(--p-primary-color); + color: var(--app-primary); margin-bottom: 1rem; } .welcome-card h1 { margin: 0 0 0.5rem; - color: var(--p-text-color); + color: var(--app-text); font-size: 1.75rem; } .welcome-card p { margin: 0; - color: var(--p-text-muted-color); + color: var(--app-text-muted); font-size: 1.1rem; } .cards { @@ -72,9 +72,9 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; gap: 1.25rem; } .card { - background: var(--p-surface-0); - border: 1px solid var(--p-surface-200); - border-radius: var(--p-content-border-radius); + background: var(--app-card-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); padding: 1.5rem; transition: box-shadow 0.2s; } @@ -83,21 +83,21 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; } .card-icon { font-size: 1.5rem; - color: var(--p-primary-color); + color: var(--app-primary); margin-bottom: 0.75rem; } .card h3 { margin: 0 0 0.5rem; - color: var(--p-text-color); + color: var(--app-text); } .card p { margin: 0; - color: var(--p-text-muted-color); + color: var(--app-text-muted); font-size: 0.9rem; line-height: 1.5; } .card code { - background: var(--p-surface-100); + background: var(--app-hover-bg); padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.85rem; diff --git a/src/templates/frontend/layout.component.ts.hbs b/src/templates/frontend/layout.component.ts.hbs index c12baa7..71a4281 100644 --- a/src/templates/frontend/layout.component.ts.hbs +++ b/src/templates/frontend/layout.component.ts.hbs @@ -33,7 +33,7 @@ import { TopbarComponent } from './topbar/topbar.component'; flex: 1; padding: 1.5rem; overflow-y: auto; - background: var(--p-surface-ground); + background: var(--app-page-bg); } `, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/templates/frontend/package.json.hbs b/src/templates/frontend/package.json.hbs index c31b3d9..9e40741 100644 --- a/src/templates/frontend/package.json.hbs +++ b/src/templates/frontend/package.json.hbs @@ -29,7 +29,7 @@ }, "devDependencies": { "@angular/build": "^{{versions.angular}}", - "@angular/cli": "^{{versions.angular}}", + "@angular/cli": "^{{versions.angularCli}}", "@angular/compiler-cli": "^{{versions.angular}}", "typescript": "~{{versions.typescript}}" } diff --git a/src/templates/frontend/proxy.conf.json.hbs b/src/templates/frontend/proxy.conf.json.hbs new file mode 100644 index 0000000..05c2a9d --- /dev/null +++ b/src/templates/frontend/proxy.conf.json.hbs @@ -0,0 +1,8 @@ +{ + "/api/**": { + "target": "http://localhost:{{backendPort}}", + "secure": false, + "changeOrigin": true, + "logLevel": "debug" + } +} diff --git a/src/templates/frontend/sidebar.component.ts.hbs b/src/templates/frontend/sidebar.component.ts.hbs index a289090..fba2d29 100644 --- a/src/templates/frontend/sidebar.component.ts.hbs +++ b/src/templates/frontend/sidebar.component.ts.hbs @@ -25,8 +25,8 @@ import { RouterLink, RouterLinkActive } from '@angular/router'; .layout-sidebar { width: 250px; min-width: 250px; - background: var(--p-surface-0); - border-right: 1px solid var(--p-surface-200); + background: var(--app-card-bg); + border-right: 1px solid var(--app-border); padding: 1rem 0.75rem; } .sidebar-menu { @@ -39,18 +39,18 @@ import { RouterLink, RouterLinkActive } from '@angular/router'; align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; - border-radius: var(--p-content-border-radius); - color: var(--p-text-color); + border-radius: var(--app-radius); + color: var(--app-text); text-decoration: none; transition: background 0.2s; font-weight: 500; } .sidebar-menu a:hover { - background: var(--p-surface-100); + background: var(--app-hover-bg); } .active-link { - background: var(--p-primary-color) !important; - color: var(--p-primary-contrast-color) !important; + background: var(--app-primary) !important; + color: var(--app-primary-contrast) !important; } `, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/templates/frontend/styles.scss.hbs b/src/templates/frontend/styles.scss.hbs index 87c196a..91793bd 100644 --- a/src/templates/frontend/styles.scss.hbs +++ b/src/templates/frontend/styles.scss.hbs @@ -1,26 +1,64 @@ {{#if uiPrimeNG}} @import "primeicons/primeicons.css"; +:root { + --app-card-bg: var(--p-surface-0); + --app-page-bg: var(--p-surface-ground); + --app-hover-bg: var(--p-surface-100); + --app-border: var(--p-surface-200); + --app-primary: var(--p-primary-color); + --app-primary-contrast: var(--p-primary-contrast-color); + --app-text: var(--p-text-color); + --app-text-muted: var(--p-text-muted-color); + --app-radius: var(--p-content-border-radius); +} + html, body { height: 100%; margin: 0; font-family: var(--p-font-family); - background: var(--p-surface-ground); - color: var(--p-text-color); + background: var(--app-page-bg); + color: var(--app-text); } {{/if}} {{#if uiTailwind}} @import "tailwindcss"; +:root { + --app-card-bg: #ffffff; + --app-page-bg: #f9fafb; + --app-hover-bg: #f4f4f5; + --app-border: #e5e7eb; + --app-primary: #3b82f6; + --app-primary-contrast: #ffffff; + --app-text: #111827; + --app-text-muted: #6b7280; + --app-radius: 6px; +} + html, body { height: 100%; margin: 0; } {{/if}} {{#if uiNone}} +:root { + --app-card-bg: #ffffff; + --app-page-bg: #f9fafb; + --app-hover-bg: #f4f4f5; + --app-border: #e5e7eb; + --app-primary: #3b82f6; + --app-primary-contrast: #ffffff; + --app-text: #111827; + --app-text-muted: #6b7280; + --app-radius: 6px; +} + html, body { height: 100%; margin: 0; font-family: sans-serif; + background: var(--app-page-bg); + color: var(--app-text); } {{/if}} diff --git a/src/templates/frontend/topbar.component.ts.hbs b/src/templates/frontend/topbar.component.ts.hbs index 87965ba..1440032 100644 --- a/src/templates/frontend/topbar.component.ts.hbs +++ b/src/templates/frontend/topbar.component.ts.hbs @@ -25,8 +25,8 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core'; justify-content: space-between; padding: 0 1.5rem; height: 60px; - background: var(--p-surface-0); - border-bottom: 1px solid var(--p-surface-200); + background: var(--app-card-bg); + border-bottom: 1px solid var(--app-border); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .topbar-left { @@ -39,18 +39,18 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core'; border: none; cursor: pointer; font-size: 1.25rem; - color: var(--p-text-color); + color: var(--app-text); padding: 0.5rem; border-radius: 50%; transition: background 0.2s; } .topbar-menu-btn:hover, .topbar-avatar:hover { - background: var(--p-surface-100); + background: var(--app-hover-bg); } .topbar-title { font-size: 1.25rem; font-weight: 600; - color: var(--p-text-color); + color: var(--app-text); } `, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/templates/frontend/tsconfig.spec.json.hbs b/src/templates/frontend/tsconfig.spec.json.hbs new file mode 100644 index 0000000..5d13f8a --- /dev/null +++ b/src/templates/frontend/tsconfig.spec.json.hbs @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["jasmine"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/src/types.ts b/src/types.ts index 5fba0b9..8dc4692 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ export type BackendType = | "nestjs" | null; export type FrontendType = "angular" | "react-vite" | "vue" | null; +export type DatabaseType = "postgres" | "none"; export interface ProjectConfig { name: string; @@ -21,6 +22,7 @@ export interface ProjectConfig { backendType: BackendType; frontend: FrontendType; // Backend features (Spring Boot + Laravel) + database: DatabaseType; flyway: boolean; openapi: boolean; auth: boolean; diff --git a/src/utils/detect-project.ts b/src/utils/detect-project.ts index 29ca051..db78cec 100644 --- a/src/utils/detect-project.ts +++ b/src/utils/detect-project.ts @@ -101,6 +101,7 @@ function defaultConfig(projectDir: string): ProjectConfig { description: "", backendType: null, frontend: null, + database: "postgres", flyway: false, openapi: false, auth: false, diff --git a/src/versions.ts b/src/versions.ts index f6aebe5..3a64a94 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -12,6 +12,7 @@ export interface ResolvedVersions { scramble: string; // Frontend angular: string; + angularCli: string; angularBuild: string; primeng: string; primeuixThemes: string; @@ -55,6 +56,7 @@ export const FALLBACK_VERSIONS: ResolvedVersions = { sanctum: "4.3.1", // renovate: datasource=packagist depName=laravel/sanctum scramble: "0.13.16", // renovate: datasource=packagist depName=dedoc/scramble angular: "21.0.0", // renovate: datasource=npm depName=@angular/core + angularCli: "21.0.0", // renovate: datasource=npm depName=@angular/cli angularBuild: "21.0.0", // renovate: datasource=npm depName=@angular/build primeng: "21.1.1", // renovate: datasource=npm depName=primeng primeuixThemes: "2.0.3", // renovate: datasource=npm depName=@primeuix/themes @@ -225,6 +227,7 @@ export async function resolveVersions(opts: { if (opts.frontend === "angular") { tasks.push( fetchNpmVersion("@angular/core").then(set("angular")), + fetchNpmVersion("@angular/cli").then(set("angularCli")), fetchNpmVersion("@angular/build").then(set("angularBuild")), fetchNpmVersion("primeng").then(set("primeng")), fetchNpmVersion("@primeuix/themes").then(set("primeuixThemes")), @@ -233,7 +236,13 @@ export async function resolveVersions(opts: { fetchNpmVersion("@ngrx/signals").then(set("ngrxSignals")), fetchNpmVersion("rxjs").then(set("rxjs")), fetchNpmVersion("zone.js").then(set("zoneJs")), - fetchNpmVersion("typescript").then(set("typescript")), + fetchNpmVersion("typescript").then((v) => { + // Cap at v5 — @angular/build@21 declares peerDependencies.typescript: ">=5.9 <6.0" + if (v && !/^[6-9]\d*\./.test(v)) { + versions.typescript = v; + anyResolved = true; + } + }), fetchNpmVersion("tailwindcss").then(set("tailwind")), ); }