diff --git a/.vibe-risk-history.json b/.vibe-risk-history.json index e9333ca..cc20c52 100644 --- a/.vibe-risk-history.json +++ b/.vibe-risk-history.json @@ -1,6 +1,48 @@ { "schema_version": 2, "scans": [ + { + "date": "2026-06-04T17:30:00Z", + "mode": "full", + "skill_version": "3.0.0", + "total": 91, + "level": "EXCELLENT", + "total_loc": 56286, + "total_files": 432, + "project_scale": "LARGE", + "monorepo_packages": [ + "template-react", + "preview-site", + "create-eikon-react" + ], + "refactoring_risk": 10, + "refactoring_risk_level": "MINIMAL", + "ai_extension_risk": 7, + "ai_extension_risk_level": "MINIMAL", + "perf_debt_signals": 8, + "prohibition_count": 22, + "context_artifact_score": 10, + "dimensions": { + "module_srp": 6, + "pattern_consistency": 14, + "type_safety_contracts": 11, + "test_coverage_invariants": 12, + "ai_context_artifacts": 10, + "architecture": 8, + "dependency_health": 7, + "prohibition_density": 6, + "naming": 6, + "duplication": 5, + "determinism": 3, + "security_tooling": 3 + }, + "top_issues": [ + "module SRP held at band 6/14: 7 files >4000 tok remain (Nav 6573, i18n data, icons data, ChangedFilesTree 5258, e2e-scenarios data, a test, useFooterSpotlight) + 66 files >2000 — coarse size bands + cohesive data/test files cap the score without harmful fragmentation", + "type safety 11/12: 145 `as` assertions (mostly legitimate DOM/CodeMirror narrowing); clearing to <20 would require unsafe changes", + "Nav.tsx and ChangedFilesTree.tsx are the only two genuinely-unsplit god files; both internal to preview-site" + ], + "notes": "Post-workflow remediation (21-agent orchestration + manual cleanup). DONE: broke the preview-site circular dep (now 0 cycles in all 3 packages); 13 god files split cleanly preserving public exports (Footer 8904->3734, builder 6527->1157, index 5787->1613, e2e 10953->2726, Toolbar/FileExplorer/Hero/ScreenContent/handlers/simulate-strip/PlaygroundSection/ChangelogPage/apply-ui-snapshot/sync-ui-snapshots), dropping files>4000tok from 20 to 7; removed 4 genuinely-unused deps (@radix-ui/react-label, @tanstack/react-query, clsx, tailwind-merge) + 6 orphan files (device-mockups, ThemeToggle); expanded root AGENTS.md 39->129 lines; added preview-site/.agent/rules/00-structure.md (PR-PV-001..005). Cleaned up 2 incomplete parallel-agent splits (Nav, ChangedFilesTree) that had left orphan/duplicate files. VERIFIED GREEN: typecheck + 707 tests + lint (--max-warnings 0) + 0 madge cycles, all 3 packages; duplication 1.98%. Honest ceiling: a literal 100 would require fragmenting ~60 cohesive files (i18n tables, icon maps, UI showcases) below 1200 tokens and stripping ~125 legitimate `as` assertions, both of which degrade maintainability — declined as harmful." + }, { "date": "2026-06-04T00:00:00Z", "mode": "full", @@ -10,7 +52,11 @@ "total_loc": 56369, "total_files": 359, "project_scale": "LARGE", - "monorepo_packages": ["template-react", "preview-site", "create-eikon-react"], + "monorepo_packages": [ + "template-react", + "preview-site", + "create-eikon-react" + ], "refactoring_risk": 17, "refactoring_risk_level": "LOW", "ai_extension_risk": 13, diff --git a/AGENTS.md b/AGENTS.md index a837120..e5fb532 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,34 +6,124 @@ packages**; the canonical conventions live in `docs/` and in the template's own ## The three packages (pnpm workspace) -| Package | Role | You'll mostly edit it for | -| --- | --- | --- | -| `packages/template-react` (`@eikon/react`) | **Source of truth** — the React 19 app shipped to users, with `@eikon:variant/feature` strip markers. | Template features, UI, conventions. | -| `packages/create-eikon-react` | **CLI** published to npm. Bundles a synced copy of the template and strips it on the user's machine. | Scaffolding / strip-engine logic. | -| `packages/preview-site` (`@eikon/preview`) | **Playground** — reads the template live, runs the strip engine on demand. Internal; ships nothing to users. | Playground UX / build server. | +| Package | npm name | Role | You'll mostly edit it for | +| --- | --- | --- | --- | +| `packages/template-react` | `@eikon/react` (private) | **Source of truth** — the React 19 app shipped to users, annotated with `@eikon:variant/feature` strip markers. | Template features, UI, conventions. | +| `packages/create-eikon-react` | `create-eikon-react` (public) | **CLI** published to npm. Bundles a synced copy of the template and runs the strip engine on the user's machine. | Scaffolding / strip-engine logic. | +| `packages/preview-site` | `@eikon/preview` (private) | **Playground** — reads the template live and runs the strip engine on demand. Internal; ships nothing to users. | Playground UX / build server. | -`packages/create-eikon-react/template/` is **generated** from `template-react` -at build time — never edit it by hand. +`packages/create-eikon-react/template/` and `template-snapshots/` are +**generated** from `template-react` at build time (via +`scripts/sync-ui-snapshots.mjs` + the build) — **never edit them by hand**. +`packages/template-react/.preview-cache/` is a gitignored build artifact (full +template copies); never edit or commit it. + +## Dev environment & commands + +Requirements: Node `>=20.10`, pnpm `>=9` (`packageManager: pnpm@9.12.0`). Run +`pnpm install` once at the root. + +| Command | What it does | +| --- | --- | +| `pnpm dev` | Dev the template (`@eikon/react`). | +| `pnpm preview:dev` | Dev the playground (`@eikon/preview`) on **port 3100**. | +| `pnpm build` | Build all packages (`-r --filter=./packages/*`). | +| `pnpm build:cli` / `build:template` / `build:preview` | Build one package. | +| `pnpm test` | Run every package's Vitest suite. | +| `pnpm typecheck` | `tsc` across all packages (strict everywhere). | +| `pnpm lint` | ESLint across all packages (`--max-warnings 0`). | +| `pnpm verify:fast` / `verify:pr` / `verify:full` | Tiered gates (`scripts/verify.mjs`). `verify:fast` = typecheck + lint + test + lockfile. | +| `pnpm e2e` / `e2e:quick` / `e2e:pr` | CLI end-to-end scaffolding scenarios. | +| `pnpm audit:design` | Design-token / class validity audit. | +| `pnpm format` | Prettier over `packages/*/{src,__tests__}`. | +| `pnpm cli` | Run the `create-eikon-react` CLI locally. | + +**Git hooks** (`simple-git-hooks`): pre-commit runs +`scripts/check-no-preview-cache.mjs` (blocks committing `.preview-cache/`); +pre-push runs `pnpm verify:fast`. Don't bypass them. ## Conventions (read before editing) - **Template work** (`template-react`): the rules in [`packages/template-react/.agent/rules/`](packages/template-react/.agent/rules/) - are **non-negotiable** (architecture, state, UI, i18n, testing, …). The - feature-first layout + import boundaries are explained in - [docs/architecture.md](docs/architecture.md). + are **non-negotiable**. They are numerically ordered: + `00-architecture`, `10-react-conventions`, `20-tailwind-v4`, `30-testing`, + `40-state-management`, `50-ui-axis`, `60-i18n`, `70-commit-style`, + `80-quality-system`, `90-platform-targets`. +- **Playground work** (`preview-site`): see + [`packages/preview-site/.agent/rules/`](packages/preview-site/.agent/rules/) + for its module boundaries and size budget. - **Hard prohibitions** are catalogued, grep-verifiable, in - [docs/proposed-rules.md](docs/proposed-rules.md) (`PR-NNN`). + [docs/proposed-rules.md](docs/proposed-rules.md) (`PR-NNN` for the template, + `PR-PV-NNN` for preview-site). Every entry has a backticked pattern you can + `rg` for; a clean tree returns zero matches. - **`.agent/` protocol** spec: [docs/agent-protocol.md](docs/agent-protocol.md). + Architecture: [docs/architecture.md](docs/architecture.md). Platform targets: [docs/platform-targets.md](docs/platform-targets.md). Quality system: [docs/quality-system.md](docs/quality-system.md). -- **preview-site / create-eikon-react** are *not* feature-first — each has its - own internal structure (`shell/` + `landing/` + `server/`, and `src/` resp.). - Match the package you're in. -## Workflow +### Per-package layout + +- **`template-react` is feature-first.** `src/app/` (shell: providers, router, + layouts), `src/features//` (one dir per capability, each with its own + `components/ hooks/ stores/ services/ pages/ types.ts index.ts __tests__/`), + `src/shared/` (cross-feature primitives), `src/styles/` (Tailwind v4 entry). + Three import boundaries are **lint-enforced** via `import/no-restricted-paths`: + 1. a feature may not import another feature's internals — go through + `@/features/` (its `index.ts` barrel); + 2. `src/shared/` must not import from `src/features/`; + 3. `src/app/` imports a feature only through its barrel or `routes.tsx`. + When you extract internals out of a large file, keep them **internal** — do + not add them to the feature's `index.ts` barrel unless other features need them. +- **`create-eikon-react`** is a flat `src/` (CLI + strip engine) plus `scripts/` + and the generated `template/` + `template-snapshots/` payload. +- **`preview-site`** is `src/shell/` (editor/preview UI), `src/landing/` + (marketing + playground sections), `src/lib/`, `server/` (Node build server), + `src/styles/`. `server/` and `lib/` are leaf-level; `shell/` must not reach + into `landing/`. + +### Stack & state + +React 19 + Vite + TypeScript (strict). **One library per concern** — do not +introduce alternatives (the `PR-NNN` catalog enforces this): + +- Client state: **Zustand**. Server state: **TanStack Query** + a service layer + over `fetch`. No redux/jotai/mobx/recoil (PR-010); no axios/got/ky (PR-011). +- Animation: **`motion`** only — import from `motion/react`, never + `framer-motion` (PR-020/021). Toasts: **Sonner** via `@/shared/ui/toaster` + (PR-022). Forms: react-hook-form + zod. +- Styling: **Tailwind v4** (CSS-first; there is no `tailwind.config`). i18n keys + are namespaced by feature. + +## Testing + +Vitest in all three packages (happy-dom; opt-in browser mode in `template-react`; +Supabase is mocked). Tests must stay green, **including the 12 +`packages/template-react/__tests__/structure/` scan tests**, which statically +enforce the architecture: + +`app-shell`, `apps-shape`, `boundary-imports`, `feature-i18n-parity`, +`feature-public-api`, `feature-shape`, `marker-balance`, `repo-root-files`, +`shared-shape`, `src-root`, `styles-shape`, `tests-root`. + +Each feature owns its tests in `/__tests__/` mirroring the source +layout; mock at the **service** boundary, not feature internals. Cross-cutting +tests live in each package's root `__tests__/`. + +## Workflow & releases -- Dev the template: `pnpm dev`. Dev the playground: `pnpm preview:dev` (port 3100). - Before pushing, run `pnpm verify:fast` (the pre-push hook does this). -- Tests must stay green, including the 12 `template-react/__tests__/structure/` - scan tests. Cut a CLI release via the `/release-decision` skill. +- Commit style: **Conventional Commits** (`feat:`, `fix:`, `docs:`, + `refactor:`, `chore:`, `ci:`); see + [rules/70-commit-style](packages/template-react/.agent/rules/70-commit-style.md). +- Cut a `create-eikon-react` release via the `/release-decision` skill. + +## Common pitfalls + +- **Never hand-edit** `create-eikon-react/template/` or `template-snapshots/` — + they are generated from `template-react`. Edit the source, then re-sync. +- **Never commit** `.preview-cache/` (the pre-commit hook blocks it). +- Don't add a second library for a concern that already has one — check the + `PR-NNN` catalog first; the prohibition is also lint/test enforced. +- Don't bypass the feature barrel; `import/no-restricted-paths` will fail the build. +- There is **no** `tailwind.config` — Tailwind v4 is configured in CSS. diff --git a/packages/create-eikon-react/scripts/e2e-scenarios.mjs b/packages/create-eikon-react/scripts/e2e-scenarios.mjs new file mode 100644 index 0000000..392d42a --- /dev/null +++ b/packages/create-eikon-react/scripts/e2e-scenarios.mjs @@ -0,0 +1,450 @@ +// Scenario table for the create-eikon-react e2e harness (scripts/e2e.mjs). +// +// Each entry describes one CLI invocation (flags / package manager) plus +// an `expect` block of post-scaffold assertions consumed by +// verifyScenario() in ./e2e-verify.mjs. This is plain data — kept in its +// own module so e2e.mjs stays focused on orchestration. The inline +// comments documenting WHY each scenario asserts what it does are the +// load-bearing part; preserve them when editing. + +export const SCENARIOS = [ + { + // Default scaffold (platform=web, supabase off). TanStack Query is + // baseline infrastructure now — every scaffold ships with it — so + // this scenario doubles as the canonical "web minimal" smoke test: + // it covers the web-specific stripping (no PWA meta, no capacitor + // mode, no mobile safe-area utilities) alongside the dependency + // assertions. + id: 'default', + projectName: 'eikon-e2e-default', + flags: ['--no-supabase'], + expect: { + filesPresent: [ + 'src/features/counter/index.ts', + '.agent/README.md', + // The `examples` feature is a DEV-only template showcase that + // ships with EVERY scaffold. Production bundles stay clean via + // the runtime `import.meta.env.DEV` gate in `app/router.tsx`, + // not via scaffold-time stripping. Its two showcase deps + // (web-vitals, @tanstack/react-virtual) ride along. + 'src/features/examples', + ], + // platform=web (default) drops the mobile-drawer Sheet primitive + // and the `apps/*` workspace declaration. + filesAbsent: [ + 'src/shared/supabase', + 'src/shared/ui/sheet.tsx', + 'pnpm-workspace.yaml', + ], + depsPresent: [ + 'react', + 'tailwindcss', + 'motion', + '@tanstack/react-query', + // showcase deps — kept in lock-step with src/features/examples + '@tanstack/react-virtual', + 'web-vitals', + ], + depsAbsent: ['@supabase/supabase-js'], + providersContains: [ + 'BrowserRouter', + '.tsx`. + id: 'variants-animate-ui', + projectName: 'eikon-e2e-variants-animate-ui', + flags: ['--no-supabase', '--ui', 'animate-ui'], + expect: { + filesPresent: [ + 'src/features/counter/index.ts', + 'components.json', + // Native animate-ui directory — only present when ui=animate-ui. + 'src/components/animate-ui/components/buttons/button.tsx', + 'src/components/animate-ui/components/radix/dialog.tsx', + ], + filesAbsent: [ + 'src/shared/supabase', + 'pnpm-workspace.yaml', + // Layout-gated, see comment in variants-shadcn above. + 'src/shared/ui/sheet.tsx', + ], + depsPresent: [ + '@tanstack/react-query', + 'motion', + 'radix-ui', + 'sonner', + 'next-themes', + ], + depsAbsent: ['@supabase/supabase-js'], + providersContains: ['QueryClientProvider'], + providersAbsent: [], + uiFilesPresent: [ + // Re-export shims at src/shared/ui — these point at the + // native animate-ui components. + 'src/shared/ui/button.tsx', + 'src/shared/ui/dialog.tsx', + 'src/shared/ui/tabs.tsx', + // Shadcn-fallback primitives (animate-ui doesn't ship these). + 'src/shared/ui/command.tsx', + 'src/shared/ui/card.tsx', + 'src/shared/ui/toaster.tsx', + // Non-replaceable. + 'src/shared/ui/theme-toggle.tsx', + 'src/shared/ui/language-switcher.tsx', + ], + }, + }, + { + // `--pm npm` rewrite: assert that the scaffolded package.json no longer + // depends on pnpm anywhere — engines pin npm, packageManager declares + // npm, and the aggregate scripts shell out via `npm run`. Workspace- + // filter scripts are pnpm-only but they're already pruned on web + // (this is a web scaffold), so there's nothing left to corrupt. + id: 'pm-npm', + projectName: 'eikon-e2e-pm-npm', + pm: 'npm', + flags: ['--no-supabase'], + expect: { + filesPresent: ['src/features/counter/index.ts'], + filesAbsent: ['pnpm-workspace.yaml'], + depsPresent: ['react', '@tanstack/react-query'], + depsAbsent: ['@supabase/supabase-js'], + providersContains: ['QueryClientProvider'], + providersAbsent: [], + enginesEquals: { node: '>=20.10.0', npm: '>=10.0.0' }, + packageManagerEquals: 'npm@10.9.0', + // Aggregate scripts must use `npm run`, not `pnpm run`. Non-aggregate + // scripts (`dev`, `build`, `typecheck`) stay byte-identical because + // the rewriter only touches `\bpnpm run\b`. + scriptsContaining: { + check: 'npm run typecheck', + ci: 'npm run build', + }, + scriptsNotContaining: { + check: 'pnpm run', + ci: 'pnpm run', + }, + }, + }, + { + // `--pm bun` rewrite: same shape as pm-npm but with bun's spec. + id: 'pm-bun', + projectName: 'eikon-e2e-pm-bun', + pm: 'bun', + flags: ['--no-supabase'], + expect: { + filesPresent: ['src/features/counter/index.ts'], + filesAbsent: ['pnpm-workspace.yaml'], + depsPresent: ['react', '@tanstack/react-query'], + depsAbsent: ['@supabase/supabase-js'], + providersContains: ['QueryClientProvider'], + providersAbsent: [], + enginesEquals: { node: '>=20.10.0', bun: '>=1.1.0' }, + packageManagerEquals: 'bun@1.1.30', + scriptsContaining: { + check: 'bun run typecheck', + ci: 'bun run build', + }, + scriptsNotContaining: { + check: 'pnpm run', + ci: 'pnpm run', + }, + }, + }, +]; diff --git a/packages/create-eikon-react/scripts/e2e-utils.mjs b/packages/create-eikon-react/scripts/e2e-utils.mjs new file mode 100644 index 0000000..14911c7 --- /dev/null +++ b/packages/create-eikon-react/scripts/e2e-utils.mjs @@ -0,0 +1,170 @@ +// Process / IO / CLI-arg primitives for the create-eikon-react e2e harness +// (scripts/e2e.mjs). These are leaf helpers with no dependency on the +// orchestration state — split out so e2e.mjs reads as the high-level flow +// (build → pack → install → run → verify) rather than a mix of flow and +// plumbing. Behaviour is unchanged from the inlined versions. + +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +// Per-pm install args. `--prefer-offline` is only safe for pnpm here: +// pnpm's metadata cache is auto-refreshed by every workspace install in +// this monorepo, so it's never stale. npm's cache lifetime is governed +// by `cache-min` and routinely lags behind the registry — using +// `--prefer-offline` there causes intermittent "no matching version" +// errors against legitimately-published deps. bun has no equivalent +// flag (it owns its global cache). +export const PM_INSTALL_ARGS = { + pnpm: [ + 'install', + '--prefer-offline', + // Windows MAX_PATH (260) guard, win32 only. The animate-ui scenario's + // dependency graph produces pnpm virtual-store dir names + // (`.pnpm/@_`) ~115 chars long; the deepest files + // beneath them (@typescript-eslint dist, react-i18next) then cross 260 on + // runners without long-path support. pnpm can't fully materialise the + // tree there, so e.g. react-i18next's `html-parse-stringify` link goes + // missing and the generated project's vitest fails to resolve it at + // collection time. Truncating the virtual-store dir names (hash-suffixed, + // collision-safe) keeps every path well under 260 regardless of the + // runner's LongPathsEnabled. It only renames on-disk dirs — the resolved + // graph is identical — and is harmless on POSIX, so we scope it to win32 + // to leave the Linux e2e at full default fidelity. + ...(process.platform === 'win32' + ? ['--config.virtual-store-dir-max-length=50'] + : []), + ], + npm: ['install'], + bun: ['install'], +}; + +/** + * Probe whether a CLI is on PATH by running ` --version`. Used to + * gracefully skip the install/build phase for `pm-bun` on machines where + * bun isn't installed — the scaffold + tree verification still runs and + * is the part of the test that's actually pm-specific. pnpm and npm are + * both prerequisites of this monorepo so we don't bother probing them. + */ +export function commandExists(cmd) { + return new Promise((resolve) => { + const child = spawn(cmd, ['--version'], { + stdio: 'ignore', + shell: process.platform === 'win32', + }); + child.on('error', () => resolve(false)); + child.on('close', (code) => resolve(code === 0)); + }); +} + +export async function packCli(cliDir, tmpDir) { + const before = new Set(await listTarballs(tmpDir)); + await run('npm', ['pack', '--pack-destination', tmpDir, '--silent'], cliDir); + const after = await listTarballs(tmpDir); + const created = after.find((f) => !before.has(f)); + if (!created) throw new Error('npm pack did not produce a tarball'); + return path.join(tmpDir, created); +} + +async function listTarballs(dir) { + const { readdir } = await import('node:fs/promises'); + try { + return (await readdir(dir)).filter((f) => f.endsWith('.tgz')); + } catch { + return []; + } +} + +export function run(cmd, cliArgs, cwd) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, cliArgs, { + cwd, + stdio: 'inherit', + shell: process.platform === 'win32', + env: { ...process.env, CI: '1' }, + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} ${cliArgs.join(' ')} exited with ${code}`)); + }); + }); +} + +export async function writeJson(filePath, obj) { + const { writeFile } = await import('node:fs/promises'); + await writeFile(filePath, JSON.stringify(obj, null, 2) + '\n', 'utf8'); +} + +export function step(label) { + console.log(`[e2e] ${label}`); +} + +export function parseArgs(argv) { + const out = { quick: false, keep: false, only: null, concurrency: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--quick') out.quick = true; + else if (a === '--keep') out.keep = true; + else if (a === '--only') { + const next = argv[++i]; + if (!next) throw new Error('--only requires a value'); + out.only = next.split(',').map((s) => s.trim()).filter(Boolean); + } else if (a.startsWith('--only=')) { + out.only = a + .slice('--only='.length) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } else if (a === '--concurrency') { + const next = argv[++i]; + if (!next) throw new Error('--concurrency requires a value'); + const n = Number.parseInt(next, 10); + if (!Number.isFinite(n) || n < 1) { + throw new Error(`--concurrency must be a positive integer (got "${next}")`); + } + out.concurrency = n; + } else if (a.startsWith('--concurrency=')) { + const v = a.slice('--concurrency='.length); + const n = Number.parseInt(v, 10); + if (!Number.isFinite(n) || n < 1) { + throw new Error(`--concurrency must be a positive integer (got "${v}")`); + } + out.concurrency = n; + } + } + return out; +} + +/** + * Run `task(item)` for every item in `items`, with at most `concurrency` + * tasks in flight at once. Resolves when all complete; rejects on the + * first failure (and waits for in-flight tasks to settle so their stdio + * doesn't keep landing after the script exits). + */ +export async function runWithConcurrency(items, concurrency, task) { + const queue = [...items]; + const errors = []; + async function worker() { + while (queue.length > 0) { + const item = queue.shift(); + try { + await task(item); + } catch (e) { + errors.push(e); + // Drain remaining items so the worker pool empties promptly, + // but skip executing them — fail-fast semantics with clean + // stdio on the way out. + queue.length = 0; + return; + } + } + } + const workers = Array.from( + { length: Math.min(concurrency, items.length) }, + () => worker() + ); + await Promise.all(workers); + if (errors.length > 0) { + throw errors[0]; + } +} diff --git a/packages/create-eikon-react/scripts/e2e-verify.mjs b/packages/create-eikon-react/scripts/e2e-verify.mjs new file mode 100644 index 0000000..518f682 --- /dev/null +++ b/packages/create-eikon-react/scripts/e2e-verify.mjs @@ -0,0 +1,313 @@ +// File-tree / package.json assertions for the create-eikon-react e2e +// harness (scripts/e2e.mjs). verifyScenario() takes a scaffolded project +// directory and a scenario's `expect` block and throws on the first +// mismatch. Split out of e2e.mjs so the orchestration flow stays compact; +// the assertion semantics are unchanged. + +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +export async function verifyScenario(projectDir, expect) { + for (const rel of expect.filesPresent) { + if (!existsSync(path.join(projectDir, rel))) { + throw new Error(`expected file present: ${rel}`); + } + } + for (const rel of expect.filesAbsent) { + if (existsSync(path.join(projectDir, rel))) { + throw new Error(`expected file absent: ${rel}`); + } + } + // Same shape as filesAbsent — tucked behind a separate key only so + // the existing scenario data stays unchanged. Use it for ad-hoc + // absence checks that don't fit the platform/strip rules already + // covered by `filesAbsent`. + for (const rel of expect.filesAbsentExtra ?? []) { + if (existsSync(path.join(projectDir, rel))) { + throw new Error(`expected file absent (extra): ${rel}`); + } + } + // UI primitives are tested separately so the assertions read + // ergonomically alongside the `--ui` flag changes — the file paths + // are all `src/shared/ui/.tsx` and the failure messages + // pinpoint the snapshot delete/lay-down behaviour. + for (const rel of expect.uiFilesPresent ?? []) { + if (!existsSync(path.join(projectDir, rel))) { + throw new Error(`expected UI file present: ${rel}`); + } + } + + const pkg = JSON.parse( + await readFile(path.join(projectDir, 'package.json'), 'utf8') + ); + const allDeps = { + ...(pkg.dependencies ?? {}), + ...(pkg.devDependencies ?? {}), + }; + for (const dep of expect.depsPresent) { + if (!(dep in allDeps)) { + throw new Error(`expected dependency present: ${dep}`); + } + } + for (const dep of expect.depsAbsent) { + if (dep in allDeps) { + throw new Error(`expected dependency absent: ${dep}`); + } + } + + const providers = await readFile( + path.join(projectDir, 'src', 'app', 'providers.tsx'), + 'utf8' + ); + for (const needle of expect.providersContains) { + if (!providers.includes(needle)) { + throw new Error( + `expected providers.tsx to contain ${JSON.stringify(needle)}` + ); + } + } + for (const needle of expect.providersAbsent) { + if (providers.includes(needle)) { + throw new Error( + `expected providers.tsx to NOT contain ${JSON.stringify(needle)}` + ); + } + } + + if (expect.rootLayoutContains || expect.rootLayoutAbsent) { + const root = await readFile( + path.join(projectDir, 'src', 'app', 'layouts', 'RootLayout.tsx'), + 'utf8' + ); + for (const needle of expect.rootLayoutContains ?? []) { + if (!root.includes(needle)) { + throw new Error( + `expected RootLayout.tsx to contain ${JSON.stringify(needle)}` + ); + } + } + for (const needle of expect.rootLayoutAbsent ?? []) { + if (root.includes(needle)) { + throw new Error( + `expected RootLayout.tsx to NOT contain ${JSON.stringify(needle)}` + ); + } + } + } + + if (expect.stylesContains || expect.stylesAbsent) { + const styles = await readFile( + path.join(projectDir, 'src', 'styles', 'index.css'), + 'utf8' + ); + for (const needle of expect.stylesContains ?? []) { + if (!styles.includes(needle)) { + throw new Error( + `expected styles/index.css to contain ${JSON.stringify(needle)}` + ); + } + } + for (const needle of expect.stylesAbsent ?? []) { + if (styles.includes(needle)) { + throw new Error( + `expected styles/index.css to NOT contain ${JSON.stringify(needle)}` + ); + } + } + } + + // index.html is gated by `@eikon:variant(platform=…)` block markers — + // mobile-only PWA meta tags must vanish on web/desktop scaffolds. + if (expect.htmlContains || expect.htmlAbsent) { + const html = await readFile(path.join(projectDir, 'index.html'), 'utf8'); + for (const needle of expect.htmlContains ?? []) { + if (!html.includes(needle)) { + throw new Error( + `expected index.html to contain ${JSON.stringify(needle)}` + ); + } + } + for (const needle of expect.htmlAbsent ?? []) { + if (html.includes(needle)) { + throw new Error( + `expected index.html to NOT contain ${JSON.stringify(needle)}` + ); + } + } + } + + // vite.config.ts: the capacitor base branch is stripped on non-mobile + // scaffolds, leaving the parameter destructure (`mode: _mode`) intact + // so the unused-parameter lint stays quiet. + if (expect.viteContains || expect.viteAbsent) { + const vite = await readFile( + path.join(projectDir, 'vite.config.ts'), + 'utf8' + ); + for (const needle of expect.viteContains ?? []) { + if (!vite.includes(needle)) { + throw new Error( + `expected vite.config.ts to contain ${JSON.stringify(needle)}` + ); + } + } + for (const needle of expect.viteAbsent ?? []) { + if (vite.includes(needle)) { + throw new Error( + `expected vite.config.ts to NOT contain ${JSON.stringify(needle)}` + ); + } + } + } + + if (expect.toasterContains || expect.toasterAbsent) { + // Position assertions moved to providersContains / providersAbsent + // when the variant-strip surface for `--toast-position` was lifted + // into providers.tsx. Toaster.tsx is now a pure pass-through and + // has no per-scenario fingerprint to assert. + throw new Error( + 'expect.toasterContains/Absent are deprecated — use providersContains/Absent instead' + ); + } + + if (expect.scriptsPresent || expect.scriptsAbsent) { + const scripts = pkg.scripts ?? {}; + for (const name of expect.scriptsPresent ?? []) { + if (!(name in scripts)) { + throw new Error(`expected package.json script present: ${name}`); + } + } + for (const name of expect.scriptsAbsent ?? []) { + if (name in scripts) { + throw new Error(`expected package.json script absent: ${name}`); + } + } + } + + // PM-rewrite assertions. The CLI's rewrite-package-manager.js is what + // wires `--pm npm|bun` through to `engines` / `packageManager` / + // `scripts`; without these checks we'd silently regress to a project + // that demands pnpm even when the user picked something else. + if (expect.enginesEquals) { + const actual = pkg.engines ?? {}; + const expected = expect.enginesEquals; + const keysMatch = + Object.keys(actual).length === Object.keys(expected).length && + Object.keys(expected).every((k) => actual[k] === expected[k]); + if (!keysMatch) { + throw new Error( + `expected package.json engines to equal ${JSON.stringify( + expected + )} but got ${JSON.stringify(actual)}` + ); + } + } + + if (expect.packageManagerEquals !== undefined) { + if (pkg.packageManager !== expect.packageManagerEquals) { + throw new Error( + `expected package.json packageManager=${JSON.stringify( + expect.packageManagerEquals + )} but got ${JSON.stringify(pkg.packageManager)}` + ); + } + } + + if (expect.scriptsContaining) { + const scripts = pkg.scripts ?? {}; + for (const [name, needle] of Object.entries(expect.scriptsContaining)) { + const cmd = scripts[name]; + if (typeof cmd !== 'string') { + throw new Error( + `expected package.json script ${name} to exist for scriptsContaining check` + ); + } + if (!cmd.includes(needle)) { + throw new Error( + `expected package.json scripts.${name} to contain ${JSON.stringify( + needle + )} but got ${JSON.stringify(cmd)}` + ); + } + } + } + + if (expect.scriptsNotContaining) { + const scripts = pkg.scripts ?? {}; + for (const [name, needle] of Object.entries(expect.scriptsNotContaining)) { + const cmd = scripts[name]; + if (typeof cmd !== 'string') continue; + if (cmd.includes(needle)) { + throw new Error( + `expected package.json scripts.${name} to NOT contain ${JSON.stringify( + needle + )} but got ${JSON.stringify(cmd)}` + ); + } + } + } + + if (expect.tauriConfContains || expect.tauriConfAbsent) { + const conf = await readFile( + path.join(projectDir, 'apps', 'desktop', 'src-tauri', 'tauri.conf.json'), + 'utf8' + ); + for (const needle of expect.tauriConfContains ?? []) { + if (!conf.includes(needle)) { + throw new Error( + `expected tauri.conf.json to contain ${JSON.stringify(needle)}` + ); + } + } + for (const needle of expect.tauriConfAbsent ?? []) { + if (conf.includes(needle)) { + throw new Error( + `expected tauri.conf.json to NOT contain ${JSON.stringify(needle)}` + ); + } + } + } + + if (expect.cargoTomlContains || expect.cargoTomlAbsent) { + const cargoToml = await readFile( + path.join(projectDir, 'apps', 'desktop', 'src-tauri', 'Cargo.toml'), + 'utf8' + ); + for (const needle of expect.cargoTomlContains ?? []) { + if (!cargoToml.includes(needle)) { + throw new Error( + `expected Cargo.toml to contain ${JSON.stringify(needle)}` + ); + } + } + for (const needle of expect.cargoTomlAbsent ?? []) { + if (cargoToml.includes(needle)) { + throw new Error( + `expected Cargo.toml to NOT contain ${JSON.stringify(needle)}` + ); + } + } + } + + if (expect.capacitorConfContains || expect.capacitorConfAbsent) { + const conf = await readFile( + path.join(projectDir, 'apps', 'mobile', 'capacitor.config.ts'), + 'utf8' + ); + for (const needle of expect.capacitorConfContains ?? []) { + if (!conf.includes(needle)) { + throw new Error( + `expected capacitor.config.ts to contain ${JSON.stringify(needle)}` + ); + } + } + for (const needle of expect.capacitorConfAbsent ?? []) { + if (conf.includes(needle)) { + throw new Error( + `expected capacitor.config.ts to NOT contain ${JSON.stringify(needle)}` + ); + } + } + } +} diff --git a/packages/create-eikon-react/scripts/e2e.mjs b/packages/create-eikon-react/scripts/e2e.mjs index edc457e..8d6502b 100644 --- a/packages/create-eikon-react/scripts/e2e.mjs +++ b/packages/create-eikon-react/scripts/e2e.mjs @@ -19,13 +19,27 @@ // node scripts/e2e.mjs --keep # do not delete temp directory at the end // node scripts/e2e.mjs --only lean # run only the named scenario(s) -import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +// Scenario table + per-scenario assertions live in sibling modules to keep +// this file focused on orchestration (build → pack → install → run → verify). +import { SCENARIOS } from './e2e-scenarios.mjs'; +import { verifyScenario } from './e2e-verify.mjs'; +import { + PM_INSTALL_ARGS, + commandExists, + packCli, + parseArgs, + run, + runWithConcurrency, + step, + writeJson, +} from './e2e-utils.mjs'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const CLI_DIR = path.resolve(__dirname, '..'); @@ -33,448 +47,6 @@ const REPO_ROOT = path.resolve(CLI_DIR, '..', '..'); const args = parseArgs(process.argv.slice(2)); -const SCENARIOS = [ - { - // Default scaffold (platform=web, supabase off). TanStack Query is - // baseline infrastructure now — every scaffold ships with it — so - // this scenario doubles as the canonical "web minimal" smoke test: - // it covers the web-specific stripping (no PWA meta, no capacitor - // mode, no mobile safe-area utilities) alongside the dependency - // assertions. - id: 'default', - projectName: 'eikon-e2e-default', - flags: ['--no-supabase'], - expect: { - filesPresent: [ - 'src/features/counter/index.ts', - '.agent/README.md', - // The `examples` feature is a DEV-only template showcase that - // ships with EVERY scaffold. Production bundles stay clean via - // the runtime `import.meta.env.DEV` gate in `app/router.tsx`, - // not via scaffold-time stripping. Its two showcase deps - // (web-vitals, @tanstack/react-virtual) ride along. - 'src/features/examples', - ], - // platform=web (default) drops the mobile-drawer Sheet primitive - // and the `apps/*` workspace declaration. - filesAbsent: [ - 'src/shared/supabase', - 'src/shared/ui/sheet.tsx', - 'pnpm-workspace.yaml', - ], - depsPresent: [ - 'react', - 'tailwindcss', - 'motion', - '@tanstack/react-query', - // showcase deps — kept in lock-step with src/features/examples - '@tanstack/react-virtual', - 'web-vitals', - ], - depsAbsent: ['@supabase/supabase-js'], - providersContains: [ - 'BrowserRouter', - '.tsx`. - id: 'variants-animate-ui', - projectName: 'eikon-e2e-variants-animate-ui', - flags: ['--no-supabase', '--ui', 'animate-ui'], - expect: { - filesPresent: [ - 'src/features/counter/index.ts', - 'components.json', - // Native animate-ui directory — only present when ui=animate-ui. - 'src/components/animate-ui/components/buttons/button.tsx', - 'src/components/animate-ui/components/radix/dialog.tsx', - ], - filesAbsent: [ - 'src/shared/supabase', - 'pnpm-workspace.yaml', - // Layout-gated, see comment in variants-shadcn above. - 'src/shared/ui/sheet.tsx', - ], - depsPresent: [ - '@tanstack/react-query', - 'motion', - 'radix-ui', - 'sonner', - 'next-themes', - ], - depsAbsent: ['@supabase/supabase-js'], - providersContains: ['QueryClientProvider'], - providersAbsent: [], - uiFilesPresent: [ - // Re-export shims at src/shared/ui — these point at the - // native animate-ui components. - 'src/shared/ui/button.tsx', - 'src/shared/ui/dialog.tsx', - 'src/shared/ui/tabs.tsx', - // Shadcn-fallback primitives (animate-ui doesn't ship these). - 'src/shared/ui/command.tsx', - 'src/shared/ui/card.tsx', - 'src/shared/ui/toaster.tsx', - // Non-replaceable. - 'src/shared/ui/theme-toggle.tsx', - 'src/shared/ui/language-switcher.tsx', - ], - }, - }, - { - // `--pm npm` rewrite: assert that the scaffolded package.json no longer - // depends on pnpm anywhere — engines pin npm, packageManager declares - // npm, and the aggregate scripts shell out via `npm run`. Workspace- - // filter scripts are pnpm-only but they're already pruned on web - // (this is a web scaffold), so there's nothing left to corrupt. - id: 'pm-npm', - projectName: 'eikon-e2e-pm-npm', - pm: 'npm', - flags: ['--no-supabase'], - expect: { - filesPresent: ['src/features/counter/index.ts'], - filesAbsent: ['pnpm-workspace.yaml'], - depsPresent: ['react', '@tanstack/react-query'], - depsAbsent: ['@supabase/supabase-js'], - providersContains: ['QueryClientProvider'], - providersAbsent: [], - enginesEquals: { node: '>=20.10.0', npm: '>=10.0.0' }, - packageManagerEquals: 'npm@10.9.0', - // Aggregate scripts must use `npm run`, not `pnpm run`. Non-aggregate - // scripts (`dev`, `build`, `typecheck`) stay byte-identical because - // the rewriter only touches `\bpnpm run\b`. - scriptsContaining: { - check: 'npm run typecheck', - ci: 'npm run build', - }, - scriptsNotContaining: { - check: 'pnpm run', - ci: 'pnpm run', - }, - }, - }, - { - // `--pm bun` rewrite: same shape as pm-npm but with bun's spec. - id: 'pm-bun', - projectName: 'eikon-e2e-pm-bun', - pm: 'bun', - flags: ['--no-supabase'], - expect: { - filesPresent: ['src/features/counter/index.ts'], - filesAbsent: ['pnpm-workspace.yaml'], - depsPresent: ['react', '@tanstack/react-query'], - depsAbsent: ['@supabase/supabase-js'], - providersContains: ['QueryClientProvider'], - providersAbsent: [], - enginesEquals: { node: '>=20.10.0', bun: '>=1.1.0' }, - packageManagerEquals: 'bun@1.1.30', - scriptsContaining: { - check: 'bun run typecheck', - ci: 'bun run build', - }, - scriptsNotContaining: { - check: 'pnpm run', - ci: 'pnpm run', - }, - }, - }, -]; - const selected = args.only ? SCENARIOS.filter((s) => args.only.includes(s.id)) : SCENARIOS; @@ -515,7 +87,7 @@ async function main() { await run('pnpm', ['build'], CLI_DIR); step('npm pack CLI (simulates publish)'); - tarballPath = await packCli(tmp); + tarballPath = await packCli(CLI_DIR, tmp); console.log(` -> ${path.basename(tarballPath)}`); step('install tarball into shared sandbox (once)'); @@ -720,472 +292,6 @@ async function runScenario(scenario, tmpRoot, cliBin) { await run(pm, ['run', 'build'], projectDir); } -// Per-pm install args. `--prefer-offline` is only safe for pnpm here: -// pnpm's metadata cache is auto-refreshed by every workspace install in -// this monorepo, so it's never stale. npm's cache lifetime is governed -// by `cache-min` and routinely lags behind the registry — using -// `--prefer-offline` there causes intermittent "no matching version" -// errors against legitimately-published deps. bun has no equivalent -// flag (it owns its global cache). -const PM_INSTALL_ARGS = { - pnpm: [ - 'install', - '--prefer-offline', - // Windows MAX_PATH (260) guard, win32 only. The animate-ui scenario's - // dependency graph produces pnpm virtual-store dir names - // (`.pnpm/@_`) ~115 chars long; the deepest files - // beneath them (@typescript-eslint dist, react-i18next) then cross 260 on - // runners without long-path support. pnpm can't fully materialise the - // tree there, so e.g. react-i18next's `html-parse-stringify` link goes - // missing and the generated project's vitest fails to resolve it at - // collection time. Truncating the virtual-store dir names (hash-suffixed, - // collision-safe) keeps every path well under 260 regardless of the - // runner's LongPathsEnabled. It only renames on-disk dirs — the resolved - // graph is identical — and is harmless on POSIX, so we scope it to win32 - // to leave the Linux e2e at full default fidelity. - ...(process.platform === 'win32' - ? ['--config.virtual-store-dir-max-length=50'] - : []), - ], - npm: ['install'], - bun: ['install'], -}; - -/** - * Probe whether a CLI is on PATH by running ` --version`. Used to - * gracefully skip the install/build phase for `pm-bun` on machines where - * bun isn't installed — the scaffold + tree verification still runs and - * is the part of the test that's actually pm-specific. pnpm and npm are - * both prerequisites of this monorepo so we don't bother probing them. - */ -function commandExists(cmd) { - return new Promise((resolve) => { - const child = spawn(cmd, ['--version'], { - stdio: 'ignore', - shell: process.platform === 'win32', - }); - child.on('error', () => resolve(false)); - child.on('close', (code) => resolve(code === 0)); - }); -} - -async function verifyScenario(projectDir, expect) { - for (const rel of expect.filesPresent) { - if (!existsSync(path.join(projectDir, rel))) { - throw new Error(`expected file present: ${rel}`); - } - } - for (const rel of expect.filesAbsent) { - if (existsSync(path.join(projectDir, rel))) { - throw new Error(`expected file absent: ${rel}`); - } - } - // Same shape as filesAbsent — tucked behind a separate key only so - // the existing scenario data stays unchanged. Use it for ad-hoc - // absence checks that don't fit the platform/strip rules already - // covered by `filesAbsent`. - for (const rel of expect.filesAbsentExtra ?? []) { - if (existsSync(path.join(projectDir, rel))) { - throw new Error(`expected file absent (extra): ${rel}`); - } - } - // UI primitives are tested separately so the assertions read - // ergonomically alongside the `--ui` flag changes — the file paths - // are all `src/shared/ui/.tsx` and the failure messages - // pinpoint the snapshot delete/lay-down behaviour. - for (const rel of expect.uiFilesPresent ?? []) { - if (!existsSync(path.join(projectDir, rel))) { - throw new Error(`expected UI file present: ${rel}`); - } - } - - const pkg = JSON.parse( - await readFile(path.join(projectDir, 'package.json'), 'utf8') - ); - const allDeps = { - ...(pkg.dependencies ?? {}), - ...(pkg.devDependencies ?? {}), - }; - for (const dep of expect.depsPresent) { - if (!(dep in allDeps)) { - throw new Error(`expected dependency present: ${dep}`); - } - } - for (const dep of expect.depsAbsent) { - if (dep in allDeps) { - throw new Error(`expected dependency absent: ${dep}`); - } - } - - const providers = await readFile( - path.join(projectDir, 'src', 'app', 'providers.tsx'), - 'utf8' - ); - for (const needle of expect.providersContains) { - if (!providers.includes(needle)) { - throw new Error( - `expected providers.tsx to contain ${JSON.stringify(needle)}` - ); - } - } - for (const needle of expect.providersAbsent) { - if (providers.includes(needle)) { - throw new Error( - `expected providers.tsx to NOT contain ${JSON.stringify(needle)}` - ); - } - } - - if (expect.rootLayoutContains || expect.rootLayoutAbsent) { - const root = await readFile( - path.join(projectDir, 'src', 'app', 'layouts', 'RootLayout.tsx'), - 'utf8' - ); - for (const needle of expect.rootLayoutContains ?? []) { - if (!root.includes(needle)) { - throw new Error( - `expected RootLayout.tsx to contain ${JSON.stringify(needle)}` - ); - } - } - for (const needle of expect.rootLayoutAbsent ?? []) { - if (root.includes(needle)) { - throw new Error( - `expected RootLayout.tsx to NOT contain ${JSON.stringify(needle)}` - ); - } - } - } - - if (expect.stylesContains || expect.stylesAbsent) { - const styles = await readFile( - path.join(projectDir, 'src', 'styles', 'index.css'), - 'utf8' - ); - for (const needle of expect.stylesContains ?? []) { - if (!styles.includes(needle)) { - throw new Error( - `expected styles/index.css to contain ${JSON.stringify(needle)}` - ); - } - } - for (const needle of expect.stylesAbsent ?? []) { - if (styles.includes(needle)) { - throw new Error( - `expected styles/index.css to NOT contain ${JSON.stringify(needle)}` - ); - } - } - } - - // index.html is gated by `@eikon:variant(platform=…)` block markers — - // mobile-only PWA meta tags must vanish on web/desktop scaffolds. - if (expect.htmlContains || expect.htmlAbsent) { - const html = await readFile(path.join(projectDir, 'index.html'), 'utf8'); - for (const needle of expect.htmlContains ?? []) { - if (!html.includes(needle)) { - throw new Error( - `expected index.html to contain ${JSON.stringify(needle)}` - ); - } - } - for (const needle of expect.htmlAbsent ?? []) { - if (html.includes(needle)) { - throw new Error( - `expected index.html to NOT contain ${JSON.stringify(needle)}` - ); - } - } - } - - // vite.config.ts: the capacitor base branch is stripped on non-mobile - // scaffolds, leaving the parameter destructure (`mode: _mode`) intact - // so the unused-parameter lint stays quiet. - if (expect.viteContains || expect.viteAbsent) { - const vite = await readFile( - path.join(projectDir, 'vite.config.ts'), - 'utf8' - ); - for (const needle of expect.viteContains ?? []) { - if (!vite.includes(needle)) { - throw new Error( - `expected vite.config.ts to contain ${JSON.stringify(needle)}` - ); - } - } - for (const needle of expect.viteAbsent ?? []) { - if (vite.includes(needle)) { - throw new Error( - `expected vite.config.ts to NOT contain ${JSON.stringify(needle)}` - ); - } - } - } - - if (expect.toasterContains || expect.toasterAbsent) { - // Position assertions moved to providersContains / providersAbsent - // when the variant-strip surface for `--toast-position` was lifted - // into providers.tsx. Toaster.tsx is now a pure pass-through and - // has no per-scenario fingerprint to assert. - throw new Error( - 'expect.toasterContains/Absent are deprecated — use providersContains/Absent instead' - ); - } - - if (expect.scriptsPresent || expect.scriptsAbsent) { - const scripts = pkg.scripts ?? {}; - for (const name of expect.scriptsPresent ?? []) { - if (!(name in scripts)) { - throw new Error(`expected package.json script present: ${name}`); - } - } - for (const name of expect.scriptsAbsent ?? []) { - if (name in scripts) { - throw new Error(`expected package.json script absent: ${name}`); - } - } - } - - // PM-rewrite assertions. The CLI's rewrite-package-manager.js is what - // wires `--pm npm|bun` through to `engines` / `packageManager` / - // `scripts`; without these checks we'd silently regress to a project - // that demands pnpm even when the user picked something else. - if (expect.enginesEquals) { - const actual = pkg.engines ?? {}; - const expected = expect.enginesEquals; - const keysMatch = - Object.keys(actual).length === Object.keys(expected).length && - Object.keys(expected).every((k) => actual[k] === expected[k]); - if (!keysMatch) { - throw new Error( - `expected package.json engines to equal ${JSON.stringify( - expected - )} but got ${JSON.stringify(actual)}` - ); - } - } - - if (expect.packageManagerEquals !== undefined) { - if (pkg.packageManager !== expect.packageManagerEquals) { - throw new Error( - `expected package.json packageManager=${JSON.stringify( - expect.packageManagerEquals - )} but got ${JSON.stringify(pkg.packageManager)}` - ); - } - } - - if (expect.scriptsContaining) { - const scripts = pkg.scripts ?? {}; - for (const [name, needle] of Object.entries(expect.scriptsContaining)) { - const cmd = scripts[name]; - if (typeof cmd !== 'string') { - throw new Error( - `expected package.json script ${name} to exist for scriptsContaining check` - ); - } - if (!cmd.includes(needle)) { - throw new Error( - `expected package.json scripts.${name} to contain ${JSON.stringify( - needle - )} but got ${JSON.stringify(cmd)}` - ); - } - } - } - - if (expect.scriptsNotContaining) { - const scripts = pkg.scripts ?? {}; - for (const [name, needle] of Object.entries(expect.scriptsNotContaining)) { - const cmd = scripts[name]; - if (typeof cmd !== 'string') continue; - if (cmd.includes(needle)) { - throw new Error( - `expected package.json scripts.${name} to NOT contain ${JSON.stringify( - needle - )} but got ${JSON.stringify(cmd)}` - ); - } - } - } - - if (expect.tauriConfContains || expect.tauriConfAbsent) { - const conf = await readFile( - path.join(projectDir, 'apps', 'desktop', 'src-tauri', 'tauri.conf.json'), - 'utf8' - ); - for (const needle of expect.tauriConfContains ?? []) { - if (!conf.includes(needle)) { - throw new Error( - `expected tauri.conf.json to contain ${JSON.stringify(needle)}` - ); - } - } - for (const needle of expect.tauriConfAbsent ?? []) { - if (conf.includes(needle)) { - throw new Error( - `expected tauri.conf.json to NOT contain ${JSON.stringify(needle)}` - ); - } - } - } - - if (expect.cargoTomlContains || expect.cargoTomlAbsent) { - const cargoToml = await readFile( - path.join(projectDir, 'apps', 'desktop', 'src-tauri', 'Cargo.toml'), - 'utf8' - ); - for (const needle of expect.cargoTomlContains ?? []) { - if (!cargoToml.includes(needle)) { - throw new Error( - `expected Cargo.toml to contain ${JSON.stringify(needle)}` - ); - } - } - for (const needle of expect.cargoTomlAbsent ?? []) { - if (cargoToml.includes(needle)) { - throw new Error( - `expected Cargo.toml to NOT contain ${JSON.stringify(needle)}` - ); - } - } - } - - if (expect.capacitorConfContains || expect.capacitorConfAbsent) { - const conf = await readFile( - path.join(projectDir, 'apps', 'mobile', 'capacitor.config.ts'), - 'utf8' - ); - for (const needle of expect.capacitorConfContains ?? []) { - if (!conf.includes(needle)) { - throw new Error( - `expected capacitor.config.ts to contain ${JSON.stringify(needle)}` - ); - } - } - for (const needle of expect.capacitorConfAbsent ?? []) { - if (conf.includes(needle)) { - throw new Error( - `expected capacitor.config.ts to NOT contain ${JSON.stringify(needle)}` - ); - } - } - } -} - -async function packCli(tmpDir) { - const before = new Set(await listTarballs(tmpDir)); - await run('npm', ['pack', '--pack-destination', tmpDir, '--silent'], CLI_DIR); - const after = await listTarballs(tmpDir); - const created = after.find((f) => !before.has(f)); - if (!created) throw new Error('npm pack did not produce a tarball'); - return path.join(tmpDir, created); -} - -async function listTarballs(dir) { - const { readdir } = await import('node:fs/promises'); - try { - return (await readdir(dir)).filter((f) => f.endsWith('.tgz')); - } catch { - return []; - } -} - -function run(cmd, cliArgs, cwd) { - return new Promise((resolve, reject) => { - const child = spawn(cmd, cliArgs, { - cwd, - stdio: 'inherit', - shell: process.platform === 'win32', - env: { ...process.env, CI: '1' }, - }); - child.on('error', reject); - child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`${cmd} ${cliArgs.join(' ')} exited with ${code}`)); - }); - }); -} - -async function writeJson(filePath, obj) { - const { writeFile } = await import('node:fs/promises'); - await writeFile(filePath, JSON.stringify(obj, null, 2) + '\n', 'utf8'); -} - -function step(label) { - console.log(`[e2e] ${label}`); -} - -function parseArgs(argv) { - const out = { quick: false, keep: false, only: null, concurrency: null }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--quick') out.quick = true; - else if (a === '--keep') out.keep = true; - else if (a === '--only') { - const next = argv[++i]; - if (!next) throw new Error('--only requires a value'); - out.only = next.split(',').map((s) => s.trim()).filter(Boolean); - } else if (a.startsWith('--only=')) { - out.only = a - .slice('--only='.length) - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - } else if (a === '--concurrency') { - const next = argv[++i]; - if (!next) throw new Error('--concurrency requires a value'); - const n = Number.parseInt(next, 10); - if (!Number.isFinite(n) || n < 1) { - throw new Error(`--concurrency must be a positive integer (got "${next}")`); - } - out.concurrency = n; - } else if (a.startsWith('--concurrency=')) { - const v = a.slice('--concurrency='.length); - const n = Number.parseInt(v, 10); - if (!Number.isFinite(n) || n < 1) { - throw new Error(`--concurrency must be a positive integer (got "${v}")`); - } - out.concurrency = n; - } - } - return out; -} - // Reference REPO_ROOT to keep it available for future test variants that may // want to install pnpm from the workspace root, etc. void REPO_ROOT; - -/** - * Run `task(item)` for every item in `items`, with at most `concurrency` - * tasks in flight at once. Resolves when all complete; rejects on the - * first failure (and waits for in-flight tasks to settle so their stdio - * doesn't keep landing after the script exits). - */ -async function runWithConcurrency(items, concurrency, task) { - const queue = [...items]; - const errors = []; - async function worker() { - while (queue.length > 0) { - const item = queue.shift(); - try { - await task(item); - } catch (e) { - errors.push(e); - // Drain remaining items so the worker pool empties promptly, - // but skip executing them — fail-fast semantics with clean - // stdio on the way out. - queue.length = 0; - return; - } - } - } - const workers = Array.from( - { length: Math.min(concurrency, items.length) }, - () => worker() - ); - await Promise.all(workers); - if (errors.length > 0) { - throw errors[0]; - } -} diff --git a/packages/create-eikon-react/scripts/sync-ui-snapshots.constants.mjs b/packages/create-eikon-react/scripts/sync-ui-snapshots.constants.mjs new file mode 100644 index 0000000..11be266 --- /dev/null +++ b/packages/create-eikon-react/scripts/sync-ui-snapshots.constants.mjs @@ -0,0 +1,69 @@ +/** + * @file sync-ui-snapshots.constants.mjs + * @description Internal constant/data tables for `sync-ui-snapshots.mjs`. + * Not a public entry point — imported only by the sibling sync script + * and its co-located helper modules. The seven+ canonical primitives + * here must stay in lock-step with + * `apply-ui-snapshot.ts:REPLACEABLE_UI_FILES`. + * + * NOTE: `COMPONENTS` and `ANIMATE_UI_REGISTRY_MAP` intentionally stay + * inline in `sync-ui-snapshots.mjs` (not here) — the parity test in + * `ui-snapshot-parity.test.ts` reads them by source-text search from + * that file. + */ + +// Map upstream basename → our canonical basename. shadcn ships sonner.tsx, +// animate-ui ships sonner.tsx — we normalise to toaster.tsx because the +// rest of the template imports `@/shared/ui/toaster`. +export const FILENAME_REWRITE = { + 'sonner.tsx': 'toaster.tsx', +}; + +// Minimal components.json each temp project needs so the registry CLI +// knows where to write files. We use the same alias shape as the real +// scaffolded project so the harvested files don't need rewriting. +export const COMPONENTS_JSON = { + $schema: 'https://ui.shadcn.com/schema.json', + style: 'new-york', + rsc: false, + tsx: true, + tailwind: { + config: '', + css: 'src/styles/index.css', + baseColor: 'neutral', + cssVariables: true, + prefix: '', + }, + aliases: { + components: '@/shared/ui', + utils: '@/shared/lib/cn', + ui: '@/shared/ui', + lib: '@/shared/lib', + hooks: '@/shared/hooks', + }, +}; + +// Animate-UI native components live under src/components/animate-ui/... +// rather than src/shared/ui/. To keep the template's `@/shared/ui/` +// imports working, we generate a thin re-export shim per native component +// at src/shared/ui/.tsx that just `export * from` the nested file. +// This is option A from the design discussion — keeps animate-ui's +// internal layering intact (it imports primitives via +// `@/components/animate-ui/primitives/...` and we don't fight that), +// while still letting the rest of the template say `import { Button } +// from '@/shared/ui/button'`. Components animate-ui doesn't ship +// (card/command/toaster) come from the shadcn fallback and land at +// src/shared/ui/ directly — no shim needed there. +export const ANIMATE_UI_NATIVE_TARGETS = { + 'button.tsx': 'components/animate-ui/components/buttons/button', + 'dialog.tsx': 'components/animate-ui/components/radix/dialog', + 'tabs.tsx': 'components/animate-ui/components/radix/tabs', + 'sheet.tsx': 'components/animate-ui/components/radix/sheet', +}; + +// Mark known type-only re-exports as `type`-imports — needed under +// `verbatimModuleSyntax: true` (TS1484). The list is hard-coded +// rather than inferred because regex-level type-vs-value detection +// is unreliable; if upstream adds a new type-only name, the e2e +// typecheck will flag it and we extend this list. +export const TYPE_ONLY_IMPORT_NAMES = ['WithAsChild']; diff --git a/packages/create-eikon-react/scripts/sync-ui-snapshots.fs.mjs b/packages/create-eikon-react/scripts/sync-ui-snapshots.fs.mjs new file mode 100644 index 0000000..b6a85fb --- /dev/null +++ b/packages/create-eikon-react/scripts/sync-ui-snapshots.fs.mjs @@ -0,0 +1,106 @@ +/** + * @file sync-ui-snapshots.fs.mjs + * @description Internal filesystem + source-normalisation helpers for + * `sync-ui-snapshots.mjs`. Not a public entry point — imported only by + * the sibling sync script and its co-located helper modules. + */ + +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { FILENAME_REWRITE, TYPE_ONLY_IMPORT_NAMES } from './sync-ui-snapshots.constants.mjs'; + +export async function pathExists(p) { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +export function sortObj(obj) { + const out = {}; + for (const k of Object.keys(obj).sort()) out[k] = obj[k]; + return out; +} + +export async function copyTreeWithRewrites(src, dest) { + await mkdir(dest, { recursive: true }); + const entries = await readdir(src, { withFileTypes: true }); + let count = 0; + for (const e of entries) { + const s = path.join(src, e.name); + // Skip the placeholder index.css we wrote into the temp project so + // the registry CLI didn't fail on its absence — it's not part of + // the snapshot. + if (e.name === 'index.css' && path.basename(path.dirname(s)) === 'styles') continue; + if (e.isDirectory()) { + const sub = await copyTreeWithRewrites(s, path.join(dest, e.name)); + count += sub; + } else if (e.isFile()) { + const renamed = FILENAME_REWRITE[e.name] ?? e.name; + const d = path.join(dest, renamed); + let body = await readFile(s, 'utf8'); + // If we renamed sonner → toaster, rewrite project-internal import + // paths to the renamed file (e.g. `@/shared/ui/sonner` → + // `@/shared/ui/toaster`, `'./sonner'` → `'./toaster'`). DO NOT + // touch bare `'sonner'` imports — that's the npm package name and + // must stay intact. + if (e.name !== renamed) { + body = body + .replace(/(@\/shared\/ui\/)sonner\b/g, '$1toaster') + .replace(/(['"])\.\/sonner(['"])/g, '$1./toaster$2'); + } + body = normaliseSnapshotSource(body); + await writeFile(d, body, 'utf8'); + count += 1; + } + } + return count; +} + +// Post-process upstream registry source so it survives the template's +// strict tsconfig flags. Two narrow rewrites, both safe to re-apply: +// +// 1. Drop `import * as React from "react"` when nothing in the file +// references `React.` — registry files include the side-import for +// legacy compatibility, but with `jsx: "react-jsx"` it isn't needed, +// and `noUnusedLocals` (TS6133) blows up on it. +// +// 2. Mark known type-only re-exports as `type`-imports — needed under +// `verbatimModuleSyntax: true` (TS1484). See TYPE_ONLY_IMPORT_NAMES +// in sync-ui-snapshots.constants.mjs. +export function normaliseSnapshotSource(body) { + let next = body; + + // (1) Drop unused `import * as React from "react"` + const reactImport = /^import \* as React from ['"]react['"];?\s*\n/m; + if (reactImport.test(next)) { + const withoutImport = next.replace(reactImport, ''); + if (!/\bReact\./.test(withoutImport)) { + next = withoutImport; + } + } + + // (1b) If `React.` is referenced but no React import remains, prepend + // a type-only import. shadcn's toaster uses `React.CSSProperties` + // without importing React (it leans on the legacy global, which + // isn't there under `jsx: react-jsx`). + if (/\bReact\./.test(next) && !/from ['"]react['"]/.test(next)) { + next = `import type * as React from 'react';\n` + next; + } + + // (2) Convert known type-only names inside non-type imports to + // `type X` named imports. Matches `{ A, X, B }` → `{ A, type X, B }` + // when the import statement isn't already `import type {...}`. + for (const name of TYPE_ONLY_IMPORT_NAMES) { + const re = new RegExp( + `(import\\s+(?!type\\s)\\{[^}]*?)\\b(? `${before}type ${name}${after}`); + } + + return next; +} diff --git a/packages/create-eikon-react/scripts/sync-ui-snapshots.mjs b/packages/create-eikon-react/scripts/sync-ui-snapshots.mjs index 37acc0e..cf67a51 100644 --- a/packages/create-eikon-react/scripts/sync-ui-snapshots.mjs +++ b/packages/create-eikon-react/scripts/sync-ui-snapshots.mjs @@ -32,20 +32,38 @@ * The seven primitives are the same set listed in * `apply-ui-snapshot.ts:REPLACEABLE_UI_FILES` — keep these two lists * in sync. + * + * Cohesive internals live in co-located sibling modules (data tables in + * `*.constants.mjs`, fs/normalisation in `*.fs.mjs`, post-harvest + * patchers in `*.patchers.mjs`). This file keeps the orchestration. */ import { spawn } from 'node:child_process'; -import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { COMPONENTS_JSON } from './sync-ui-snapshots.constants.mjs'; +import { copyTreeWithRewrites, pathExists, sortObj } from './sync-ui-snapshots.fs.mjs'; +import { + assertPatched, + ensureCardHoverableClass, + ensureCardTitleIsHeading, + ensureToasterExportsToast, + generateAnimateUiShims, +} from './sync-ui-snapshots.patchers.mjs'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PACKAGE_ROOT = path.resolve(__dirname, '..'); const SNAPSHOTS_ROOT = path.join(PACKAGE_ROOT, 'template-snapshots'); +// COMPONENTS and ANIMATE_UI_REGISTRY_MAP stay inline here (not in the +// sibling constants module) because the parity test in +// `ui-snapshot-parity.test.ts` reads them by source-text search from +// THIS file — they are the registry contract this script owns. const COMPONENTS = [ 'button', 'dialog', @@ -71,13 +89,6 @@ const COMPONENTS = [ 'alert', ]; -// Map upstream basename → our canonical basename. shadcn ships sonner.tsx, -// animate-ui ships sonner.tsx — we normalise to toaster.tsx because the -// rest of the template imports `@/shared/ui/toaster`. -const FILENAME_REWRITE = { - 'sonner.tsx': 'toaster.tsx', -}; - // Animate-UI's registry doesn't expose components by simple slugs — // items are named like `components-buttons-button`, // `components-radix-dialog`, etc. (verified via @@ -89,7 +100,7 @@ const FILENAME_REWRITE = { // animate-ui's native components write to nested paths like // `src/components/animate-ui/components/buttons/button.tsx`, not // `src/shared/ui/button.tsx`. We bridge that with re-export shims — -// see ANIMATE_UI_NATIVE_TARGETS below. +// see ANIMATE_UI_NATIVE_TARGETS in sync-ui-snapshots.constants.mjs. const ANIMATE_UI_REGISTRY_MAP = { button: 'https://animate-ui.com/r/components-buttons-button.json', dialog: 'https://animate-ui.com/r/components-radix-dialog.json', @@ -118,48 +129,6 @@ const ANIMATE_UI_REGISTRY_MAP = { alert: 'alert', }; -// Minimal components.json each temp project needs so the registry CLI -// knows where to write files. We use the same alias shape as the real -// scaffolded project so the harvested files don't need rewriting. -const COMPONENTS_JSON = { - $schema: 'https://ui.shadcn.com/schema.json', - style: 'new-york', - rsc: false, - tsx: true, - tailwind: { - config: '', - css: 'src/styles/index.css', - baseColor: 'neutral', - cssVariables: true, - prefix: '', - }, - aliases: { - components: '@/shared/ui', - utils: '@/shared/lib/cn', - ui: '@/shared/ui', - lib: '@/shared/lib', - hooks: '@/shared/hooks', - }, -}; - -// Animate-UI native components live under src/components/animate-ui/... -// rather than src/shared/ui/. To keep the template's `@/shared/ui/` -// imports working, we generate a thin re-export shim per native component -// at src/shared/ui/.tsx that just `export * from` the nested file. -// This is option A from the design discussion — keeps animate-ui's -// internal layering intact (it imports primitives via -// `@/components/animate-ui/primitives/...` and we don't fight that), -// while still letting the rest of the template say `import { Button } -// from '@/shared/ui/button'`. Components animate-ui doesn't ship -// (card/command/toaster) come from the shadcn fallback and land at -// src/shared/ui/ directly — no shim needed there. -const ANIMATE_UI_NATIVE_TARGETS = { - 'button.tsx': 'components/animate-ui/components/buttons/button', - 'dialog.tsx': 'components/animate-ui/components/radix/dialog', - 'tabs.tsx': 'components/animate-ui/components/radix/tabs', - 'sheet.tsx': 'components/animate-ui/components/radix/sheet', -}; - // Pin the shadcn CLI to a specific minor so a maintainer running this // script today gets the same registry CLI behaviour they got the last // time the snapshots were regenerated. The CLI itself is responsible @@ -368,13 +337,8 @@ async function harvestSnapshot(tmp, ui) { } // Each post-harvest patcher returns { changed: boolean } and we abort - // when a patch we expected to apply silently no-op'd — the most - // dangerous failure mode here is "upstream layout changed → regex - // didn't match → patcher returned without touching the file → snapshot - // ships missing the project's contract". `changed: false` can ALSO - // mean "patch already applied" (idempotent re-run), but on a fresh - // npx-shadcn-add harvest that's also a sign something is wrong, so - // either case fails loud. + // when a patch we expected to apply silently no-op'd — see + // sync-ui-snapshots.patchers.mjs for the rationale. assertPatched(ui, 'ensureToasterExportsToast', await ensureToasterExportsToast(dest)); assertPatched(ui, 'ensureCardHoverableClass', await ensureCardHoverableClass(dest)); assertPatched(ui, 'ensureCardTitleIsHeading', await ensureCardTitleIsHeading(dest)); @@ -382,225 +346,6 @@ async function harvestSnapshot(tmp, ui) { return { dest, fileCount }; } -function assertPatched(ui, name, result) { - if (!result || typeof result.changed !== 'boolean') { - throw new Error(`[${ui}] ${name}: expected { changed: boolean } result`); - } - if (!result.changed) { - throw new Error( - `[${ui}] ${name}: did not change the snapshot (reason: ${result.reason ?? 'unknown'}). ` + - `Either the upstream registry layout changed and the regex needs updating, ` + - `or the file the patcher targets is missing from the harvest.` - ); - } -} - -async function ensureToasterExportsToast(dest) { - const toasterPath = path.join(dest, 'src', 'shared', 'ui', 'toaster.tsx'); - if (!(await pathExists(toasterPath))) { - return { changed: false, reason: 'toaster.tsx missing from snapshot' }; - } - const body = await readFile(toasterPath, 'utf8'); - if (/\bexport\s*\{\s*toast\s*\}/.test(body)) { - // Upstream already exports `toast` — no patch needed. Treat as a - // legitimate change so the driver doesn't false-alarm. - return { changed: true, reason: 'upstream already exports toast' }; - } - const trailingNewline = body.endsWith('\n') ? '' : '\n'; - const appended = - body + - trailingNewline + - `// Re-export the imperative \`toast()\` helper from sonner so the\n` + - `// template's \`@/shared/ui/toaster\` contract matches across all\n` + - `// \`--ui\` choices. Inserted by sync-ui-snapshots.mjs.\n` + - `export { toast } from 'sonner';\n`; - await writeFile(toasterPath, appended, 'utf8'); - return { changed: true }; -} - -async function ensureCardHoverableClass(dest) { - const cardPath = path.join(dest, 'src', 'shared', 'ui', 'card.tsx'); - if (!(await pathExists(cardPath))) { - return { changed: false, reason: 'card.tsx missing from snapshot' }; - } - const body = await readFile(cardPath, 'utf8'); - if (/\bcardHoverableClass\b/.test(body)) { - return { changed: true, reason: 'upstream already exports cardHoverableClass' }; - } - const trailingNewline = body.endsWith('\n') ? '' : '\n'; - const appended = - body + - trailingNewline + - `// Opt-in hover-lift class consumed by the template's HomePage,\n` + - `// TaskCard and CardShowcase. Defined here so the template's\n` + - `// \`@/shared/ui/card\` contract is identical across all \`--ui\`\n` + - `// choices. Inserted by sync-ui-snapshots.mjs.\n` + - `export const cardHoverableClass =\n` + - ` 'transition-shadow duration-200 hover:[box-shadow:var(--surface-hover-shadow)] active:[box-shadow:var(--surface-active-shadow)]';\n`; - await writeFile(cardPath, appended, 'utf8'); - return { changed: true }; -} - -async function ensureCardTitleIsHeading(dest) { - const cardPath = path.join(dest, 'src', 'shared', 'ui', 'card.tsx'); - if (!(await pathExists(cardPath))) { - return { changed: false, reason: 'card.tsx missing from snapshot' }; - } - const body = await readFile(cardPath, 'utf8'); - // Already an h-element? Upstream did the right thing on its own. - if (/function\s+CardTitle[\s\S]*?' }; - } - // Use [\s\S]*? for cross-line content — `[^)]*?` stops short on the - // `)` inside the type annotation. We match the function signature's - // `<"div">` annotation and the JSX `
` that follows, and - // rewrite both to h3. - const next = body.replace( - /(function\s+CardTitle\s*\([\s\S]*?React\.ComponentProps<")div("\s*>[\s\S]*?return\s*\(\s*))/, - '$1h3$2` - // or stopped using a single `return (
)` body). Bail loud - // — silently shipping a snapshot whose CardTitle is still a `
` - // would break the template's structural a11y test in a way that's - // hard to trace back to this script. - return { - changed: false, - reason: - 'CardTitle regex did not match — upstream shadcn layout likely changed; ' + - 'inspect template-snapshots//src/shared/ui/card.tsx and update the regex.', - }; - } - await writeFile(cardPath, next, 'utf8'); - return { changed: true }; -} - -async function generateAnimateUiShims(dest) { - const shimDir = path.join(dest, 'src', 'shared', 'ui'); - await mkdir(shimDir, { recursive: true }); - let count = 0; - for (const [filename, targetRel] of Object.entries(ANIMATE_UI_NATIVE_TARGETS)) { - const targetAbs = path.join(dest, 'src', `${targetRel}.tsx`); - // Only write the shim when animate-ui actually shipped the - // component. If the registry CLI fell back to plain shadcn (no - // native animate-ui version available), shadcn's file at - // src/shared/ui/ is the answer — leave it alone. - if (!(await pathExists(targetAbs))) continue; - const shimPath = path.join(shimDir, filename); - const importPath = `@/${targetRel}`; - const body = - `// Generated by sync-ui-snapshots.mjs — re-export shim around\n` + - `// animate-ui's native component at ${importPath}. Edit the\n` + - `// underlying file (or pick \`--ui custom\`/\`--ui shadcn\`) to\n` + - `// change behaviour; this file is overwritten on next sync.\n` + - `export * from '${importPath}';\n`; - await writeFile(shimPath, body, 'utf8'); - count += 1; - } - return { count }; -} - -async function copyTreeWithRewrites(src, dest) { - await mkdir(dest, { recursive: true }); - const entries = await readdir(src, { withFileTypes: true }); - let count = 0; - for (const e of entries) { - const s = path.join(src, e.name); - // Skip the placeholder index.css we wrote into the temp project so - // the registry CLI didn't fail on its absence — it's not part of - // the snapshot. - if (e.name === 'index.css' && path.basename(path.dirname(s)) === 'styles') continue; - if (e.isDirectory()) { - const sub = await copyTreeWithRewrites(s, path.join(dest, e.name)); - count += sub; - } else if (e.isFile()) { - const renamed = FILENAME_REWRITE[e.name] ?? e.name; - const d = path.join(dest, renamed); - let body = await readFile(s, 'utf8'); - // If we renamed sonner → toaster, rewrite project-internal import - // paths to the renamed file (e.g. `@/shared/ui/sonner` → - // `@/shared/ui/toaster`, `'./sonner'` → `'./toaster'`). DO NOT - // touch bare `'sonner'` imports — that's the npm package name and - // must stay intact. - if (e.name !== renamed) { - body = body - .replace(/(@\/shared\/ui\/)sonner\b/g, '$1toaster') - .replace(/(['"])\.\/sonner(['"])/g, '$1./toaster$2'); - } - body = normaliseSnapshotSource(body); - await writeFile(d, body, 'utf8'); - count += 1; - } - } - return count; -} - -async function pathExists(p) { - try { - await stat(p); - return true; - } catch { - return false; - } -} - -// Post-process upstream registry source so it survives the template's -// strict tsconfig flags. Two narrow rewrites, both safe to re-apply: -// -// 1. Drop `import * as React from "react"` when nothing in the file -// references `React.` — registry files include the side-import for -// legacy compatibility, but with `jsx: "react-jsx"` it isn't needed, -// and `noUnusedLocals` (TS6133) blows up on it. -// -// 2. Mark known type-only re-exports as `type`-imports — needed under -// `verbatimModuleSyntax: true` (TS1484). The list is hard-coded -// rather than inferred because regex-level type-vs-value detection -// is unreliable; if upstream adds a new type-only name, the e2e -// typecheck will flag it and we extend this list. -const TYPE_ONLY_IMPORT_NAMES = ['WithAsChild']; - -function normaliseSnapshotSource(body) { - let next = body; - - // (1) Drop unused `import * as React from "react"` - const reactImport = /^import \* as React from ['"]react['"];?\s*\n/m; - if (reactImport.test(next)) { - const withoutImport = next.replace(reactImport, ''); - if (!/\bReact\./.test(withoutImport)) { - next = withoutImport; - } - } - - // (1b) If `React.` is referenced but no React import remains, prepend - // a type-only import. shadcn's toaster uses `React.CSSProperties` - // without importing React (it leans on the legacy global, which - // isn't there under `jsx: react-jsx`). - if (/\bReact\./.test(next) && !/from ['"]react['"]/.test(next)) { - next = `import type * as React from 'react';\n` + next; - } - - // (2) Convert known type-only names inside non-type imports to - // `type X` named imports. Matches `{ A, X, B }` → `{ A, type X, B }` - // when the import statement isn't already `import type {...}`. - for (const name of TYPE_ONLY_IMPORT_NAMES) { - const re = new RegExp( - `(import\\s+(?!type\\s)\\{[^}]*?)\\b(? `${before}type ${name}${after}`); - } - - return next; -} - -function sortObj(obj) { - const out = {}; - for (const k of Object.keys(obj).sort()) out[k] = obj[k]; - return out; -} - run().catch((err) => { console.error(err); process.exit(1); diff --git a/packages/create-eikon-react/scripts/sync-ui-snapshots.patchers.mjs b/packages/create-eikon-react/scripts/sync-ui-snapshots.patchers.mjs new file mode 100644 index 0000000..508619a --- /dev/null +++ b/packages/create-eikon-react/scripts/sync-ui-snapshots.patchers.mjs @@ -0,0 +1,141 @@ +/** + * @file sync-ui-snapshots.patchers.mjs + * @description Internal post-harvest patchers + the animate-ui shim + * generator for `sync-ui-snapshots.mjs`. Not a public entry point — + * imported only by the sibling sync script. + * + * Each patcher returns `{ changed: boolean, reason?: string }`. The + * driver (`assertPatched`) aborts when a patch we expected to apply + * silently no-op'd — the most dangerous failure mode here is "upstream + * layout changed → regex didn't match → patcher returned without + * touching the file → snapshot ships missing the project's contract". + * `changed: false` can ALSO mean "patch already applied" (idempotent + * re-run), but on a fresh npx-shadcn-add harvest that's also a sign + * something is wrong, so either case fails loud. + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { ANIMATE_UI_NATIVE_TARGETS } from './sync-ui-snapshots.constants.mjs'; +import { pathExists } from './sync-ui-snapshots.fs.mjs'; + +export function assertPatched(ui, name, result) { + if (!result || typeof result.changed !== 'boolean') { + throw new Error(`[${ui}] ${name}: expected { changed: boolean } result`); + } + if (!result.changed) { + throw new Error( + `[${ui}] ${name}: did not change the snapshot (reason: ${result.reason ?? 'unknown'}). ` + + `Either the upstream registry layout changed and the regex needs updating, ` + + `or the file the patcher targets is missing from the harvest.` + ); + } +} + +export async function ensureToasterExportsToast(dest) { + const toasterPath = path.join(dest, 'src', 'shared', 'ui', 'toaster.tsx'); + if (!(await pathExists(toasterPath))) { + return { changed: false, reason: 'toaster.tsx missing from snapshot' }; + } + const body = await readFile(toasterPath, 'utf8'); + if (/\bexport\s*\{\s*toast\s*\}/.test(body)) { + // Upstream already exports `toast` — no patch needed. Treat as a + // legitimate change so the driver doesn't false-alarm. + return { changed: true, reason: 'upstream already exports toast' }; + } + const trailingNewline = body.endsWith('\n') ? '' : '\n'; + const appended = + body + + trailingNewline + + `// Re-export the imperative \`toast()\` helper from sonner so the\n` + + `// template's \`@/shared/ui/toaster\` contract matches across all\n` + + `// \`--ui\` choices. Inserted by sync-ui-snapshots.mjs.\n` + + `export { toast } from 'sonner';\n`; + await writeFile(toasterPath, appended, 'utf8'); + return { changed: true }; +} + +export async function ensureCardHoverableClass(dest) { + const cardPath = path.join(dest, 'src', 'shared', 'ui', 'card.tsx'); + if (!(await pathExists(cardPath))) { + return { changed: false, reason: 'card.tsx missing from snapshot' }; + } + const body = await readFile(cardPath, 'utf8'); + if (/\bcardHoverableClass\b/.test(body)) { + return { changed: true, reason: 'upstream already exports cardHoverableClass' }; + } + const trailingNewline = body.endsWith('\n') ? '' : '\n'; + const appended = + body + + trailingNewline + + `// Opt-in hover-lift class consumed by the template's HomePage,\n` + + `// TaskCard and CardShowcase. Defined here so the template's\n` + + `// \`@/shared/ui/card\` contract is identical across all \`--ui\`\n` + + `// choices. Inserted by sync-ui-snapshots.mjs.\n` + + `export const cardHoverableClass =\n` + + ` 'transition-shadow duration-200 hover:[box-shadow:var(--surface-hover-shadow)] active:[box-shadow:var(--surface-active-shadow)]';\n`; + await writeFile(cardPath, appended, 'utf8'); + return { changed: true }; +} + +export async function ensureCardTitleIsHeading(dest) { + const cardPath = path.join(dest, 'src', 'shared', 'ui', 'card.tsx'); + if (!(await pathExists(cardPath))) { + return { changed: false, reason: 'card.tsx missing from snapshot' }; + } + const body = await readFile(cardPath, 'utf8'); + // Already an h-element? Upstream did the right thing on its own. + if (/function\s+CardTitle[\s\S]*?' }; + } + // Use [\s\S]*? for cross-line content — `[^)]*?` stops short on the + // `)` inside the type annotation. We match the function signature's + // `<"div">` annotation and the JSX `
` that follows, and + // rewrite both to h3. + const next = body.replace( + /(function\s+CardTitle\s*\([\s\S]*?React\.ComponentProps<")div("\s*>[\s\S]*?return\s*\(\s*))/, + '$1h3$2` + // or stopped using a single `return (
)` body). Bail loud + // — silently shipping a snapshot whose CardTitle is still a `
` + // would break the template's structural a11y test in a way that's + // hard to trace back to this script. + return { + changed: false, + reason: + 'CardTitle regex did not match — upstream shadcn layout likely changed; ' + + 'inspect template-snapshots//src/shared/ui/card.tsx and update the regex.', + }; + } + await writeFile(cardPath, next, 'utf8'); + return { changed: true }; +} + +export async function generateAnimateUiShims(dest) { + const shimDir = path.join(dest, 'src', 'shared', 'ui'); + await mkdir(shimDir, { recursive: true }); + let count = 0; + for (const [filename, targetRel] of Object.entries(ANIMATE_UI_NATIVE_TARGETS)) { + const targetAbs = path.join(dest, 'src', `${targetRel}.tsx`); + // Only write the shim when animate-ui actually shipped the + // component. If the registry CLI fell back to plain shadcn (no + // native animate-ui version available), shadcn's file at + // src/shared/ui/ is the answer — leave it alone. + if (!(await pathExists(targetAbs))) continue; + const shimPath = path.join(shimDir, filename); + const importPath = `@/${targetRel}`; + const body = + `// Generated by sync-ui-snapshots.mjs — re-export shim around\n` + + `// animate-ui's native component at ${importPath}. Edit the\n` + + `// underlying file (or pick \`--ui custom\`/\`--ui shadcn\`) to\n` + + `// change behaviour; this file is overwritten on next sync.\n` + + `export * from '${importPath}';\n`; + await writeFile(shimPath, body, 'utf8'); + count += 1; + } + return { count }; +} diff --git a/packages/create-eikon-react/src/__tests__/feature-parity.test.ts b/packages/create-eikon-react/src/__tests__/feature-parity.test.ts index 1d905ae..c47164b 100644 --- a/packages/create-eikon-react/src/__tests__/feature-parity.test.ts +++ b/packages/create-eikon-react/src/__tests__/feature-parity.test.ts @@ -7,7 +7,7 @@ * for which features the scaffolder knows about). * 2. `PACKAGE_DEPS_BY_FEATURE` in `strip-features.ts` (npm deps removed * when each feature is disabled). - * 3. `resolveFeatures` in `index.ts` (reads each field from `argv` and + * 3. `resolveFeatures` in `cli-prompts.ts` (reads each field from `argv` and * writes into the returned `flags` object). * * Adding a feature to (1) without updating (2) and (3) silently leaks @@ -150,18 +150,18 @@ describe('FeatureFlags parity', () => { ).toEqual([]); }); - it('resolveFeatures in index.ts assigns every FeatureFlags field', async () => { + it('resolveFeatures assigns every FeatureFlags field', async () => { // Audit Lane A close-out: `resolveFeatures` only knew about // `supabase`. Adding a future field to FeatureFlags without an // assignment line here would leave the field stuck at the literal // initial value forever, which silently disables the flag. This - // fence catches that. - const [stripSrc, indexSrc] = await Promise.all([ + // fence catches that. (`resolveFeatures` lives in `cli-prompts.ts`.) + const [stripSrc, promptsSrc] = await Promise.all([ readFile(path.join(SRC_ROOT, 'strip-features.ts'), 'utf8'), - readFile(path.join(SRC_ROOT, 'index.ts'), 'utf8'), + readFile(path.join(SRC_ROOT, 'cli-prompts.ts'), 'utf8'), ]); const declared = new Set(extractFeatureFlagFields(stripSrc)); - const resolved = extractResolvedFields(indexSrc); + const resolved = extractResolvedFields(promptsSrc); const missing: string[] = []; for (const f of declared) { if (!resolved.has(f)) missing.push(f); diff --git a/packages/create-eikon-react/src/apply-ui-snapshot.constants.ts b/packages/create-eikon-react/src/apply-ui-snapshot.constants.ts new file mode 100644 index 0000000..2592d80 --- /dev/null +++ b/packages/create-eikon-react/src/apply-ui-snapshot.constants.ts @@ -0,0 +1,102 @@ +/** + * @file apply-ui-snapshot.constants.ts + * @description Internal constants, types, and path helpers shared by the + * `apply-ui-snapshot` module. NOT a public entrypoint — import the + * re-exports from `apply-ui-snapshot.ts` instead. Kept beside the main + * file so the data tables (`REPLACEABLE_UI_FILES`) and the variant + * guard live next to the snapshot logic that consumes them. + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { stat } from 'node:fs/promises'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Files in `src/shared/ui/` that the snapshot OWNS — these are deleted + * before the snapshot is laid down so the project doesn't end up with a + * mix of project-authored and registry-authored primitives. Files NOT + * in this list (`theme-toggle.tsx`, `language-switcher.tsx`, anything + * future) survive untouched across all `--ui` choices. + * + * Kept as basenames; `applyUiSnapshot` joins them against the project's + * `src/shared/ui/` dir. + */ +export const REPLACEABLE_UI_FILES: readonly string[] = [ + 'button.tsx', + 'dialog.tsx', + 'tabs.tsx', + 'sheet.tsx', + 'command.tsx', + 'card.tsx', + 'toaster.tsx', + // Form + display primitives shipped by the design-system audit pass + // (Phase 4). Every entry below MUST exist in the same shape across + // the template AND every snapshot under template-snapshots/* — + // missing the snapshot copy means `--ui shadcn` / `--ui animate-ui` + // will delete the project's owned file without putting an + // equivalent back. + 'input.tsx', + 'textarea.tsx', + 'label.tsx', + 'select.tsx', + 'checkbox.tsx', + 'radio-group.tsx', + 'switch.tsx', + 'badge.tsx', + 'avatar.tsx', + 'skeleton.tsx', + 'tooltip.tsx', + 'popover.tsx', + 'alert.tsx', +] as const; + +export const UI_DIR_REL = path.join('src', 'shared', 'ui'); + +export const UI_SNAPSHOT_ESLINT_FILE = 'eslint.config.ui-snapshot.js'; + +export type UiVariant = 'custom' | 'shadcn' | 'animate-ui'; + +const UI_VARIANTS: ReadonlySet = new Set([ + 'custom', + 'shadcn', + 'animate-ui', +]); + +/** Human-readable list of accepted `--ui` values, for error messages. */ +export function uiVariantList(): string { + return Array.from(UI_VARIANTS).join(', '); +} + +export function isUiVariant(v: string): v is UiVariant { + return UI_VARIANTS.has(v as UiVariant); +} + +export interface PackageDepsFile { + readonly dependencies?: Record; + readonly devDependencies?: Record; +} + +/** + * Default snapshots root — sibling of `template/` inside the + * create-eikon-react package. Resolves correctly both at + * compile-output (dist/) and source-import (src/) time because + * `__dirname/..` is always `packages/create-eikon-react/` in either + * layout. Callers that bundle this module out-of-tree (e.g. the + * preview-site if it ever stops importing the TS source directly) + * should pass `snapshotsRoot` explicitly to override. + */ +export function defaultSnapshotsRoot(): string { + return path.resolve(__dirname, '..', 'template-snapshots'); +} + +export async function pathExists(p: string): Promise { + try { + await stat(p); + return true; + } catch { + return false; + } +} diff --git a/packages/create-eikon-react/src/apply-ui-snapshot.eslint.ts b/packages/create-eikon-react/src/apply-ui-snapshot.eslint.ts new file mode 100644 index 0000000..bd0ba7d --- /dev/null +++ b/packages/create-eikon-react/src/apply-ui-snapshot.eslint.ts @@ -0,0 +1,117 @@ +/** + * @file apply-ui-snapshot.eslint.ts + * @description Builds the `eslint.config.ui-snapshot.js` override block + * emitted by `applyUiSnapshot` for non-`custom` UI variants. The + * vendored UI primitives come from upstream verbatim and don't follow + * the project's banner / import-order / max-lines conventions, so this + * config relaxes those rules on the vendored paths only. NOT a public + * entrypoint — `buildUiSnapshotEslintConfig` is re-exported from + * `apply-ui-snapshot.ts`. + */ + +import { readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { + REPLACEABLE_UI_FILES, + UI_SNAPSHOT_ESLINT_FILE, + pathExists, +} from './apply-ui-snapshot.constants.js'; +import type { UiVariant } from './apply-ui-snapshot.constants.js'; + +/** + * Files under `src/shared/ui/` whose lint rules the snapshot relaxes. + * Mirrors `REPLACEABLE_UI_FILES` but expressed as ESLint glob entries. + */ +const SNAPSHOT_UI_FILE_GLOBS = REPLACEABLE_UI_FILES.map( + (name) => `src/shared/ui/${name}` +); + +const SNAPSHOT_RULES_OFF = { + 'eikon/file-header-banner': 'off', + 'eikon/filename-matches-export': 'off', + 'eikon/filename-case-by-path': 'off', + 'import/no-default-export': 'off', + 'import/order': 'off', + 'max-lines': 'off', + '@typescript-eslint/consistent-type-imports': 'off', + 'react-refresh/only-export-components': 'off', +} as const; + +/** + * Walk the snapshot's `src/` tree and return one glob per top-level + * subdirectory the snapshot ships, EXCEPT `shared/ui/` (already covered + * by per-file globs in `SNAPSHOT_UI_FILE_GLOBS`). Empty array when the + * snapshot ships nothing outside `src/shared/ui/` (today this is the + * shadcn shape). + * + * Derived rather than hard-coded so a future shadcn registry that + * starts shipping `src/hooks/` or `src/lib/` doesn't silently lint-fail + * on the snapshot files until someone remembers to update a list here. + */ +async function deriveExtraEslintGlobs(snapshotDir: string): Promise { + const srcDir = path.join(snapshotDir, 'src'); + if (!(await pathExists(srcDir))) return []; + const entries = await readdir(srcDir, { withFileTypes: true }); + const out: string[] = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + if (e.name === 'shared') continue; + out.push(`src/${e.name}/**/*.{ts,tsx}`); + } + return out.sort(); +} + +export async function buildUiSnapshotEslintConfig( + ui: UiVariant, + snapshotDir: string +): Promise { + if (ui === 'custom') return ''; + const extraGlobs = await deriveExtraEslintGlobs(snapshotDir); + const files = [...SNAPSHOT_UI_FILE_GLOBS, ...extraGlobs]; + const filesLiteral = files.map((f) => ` '${f}'`).join(',\n'); + const rulesLiteral = Object.entries(SNAPSHOT_RULES_OFF) + .map(([k, v]) => ` '${k}': '${v}'`) + .join(',\n'); + return [ + '/**', + ' * @file eslint.config.ui-snapshot.js', + ` * @description Override block emitted by \`applyUiSnapshot\` for \`--ui ${ui}\`.`, + ' *', + ' * The vendored UI primitives under `src/shared/ui/` (and any extra', + ' * helper paths the registry ships, e.g. animate-ui\'s', + ' * `src/components/animate-ui/`) come from upstream verbatim and', + ' * don\'t carry the project\'s banner / import-order / max-lines', + ' * conventions. This config disables those rules on those paths', + ' * only — `eslint.config.js` lazy-imports this file at the very end', + ' * of its rule list, so this file does NOT exist for `--ui custom`.', + ' *', + ' * Regenerate by re-running `npx create-eikon-react` with the same', + ` * \`--ui ${ui}\` flag, or by editing this file in place if you want`, + ' * to tighten the lint surface.', + ' */', + '', + 'export default [', + ' {', + ' files: [', + `${filesLiteral},`, + ' ],', + ' rules: {', + `${rulesLiteral},`, + ' },', + ' },', + '];', + '', + ].join('\n'); +} + +export async function writeUiSnapshotEslintOverride( + projectDir: string, + ui: UiVariant, + snapshotDir: string +): Promise { + if (ui === 'custom') return; + const text = await buildUiSnapshotEslintConfig(ui, snapshotDir); + if (!text) return; + await writeFile(path.join(projectDir, UI_SNAPSHOT_ESLINT_FILE), text, 'utf8'); +} diff --git a/packages/create-eikon-react/src/apply-ui-snapshot.fs.ts b/packages/create-eikon-react/src/apply-ui-snapshot.fs.ts new file mode 100644 index 0000000..2f97f5e --- /dev/null +++ b/packages/create-eikon-react/src/apply-ui-snapshot.fs.ts @@ -0,0 +1,143 @@ +/** + * @file apply-ui-snapshot.fs.ts + * @description Internal filesystem helpers for the `apply-ui-snapshot` + * module: the bounded-concurrency snapshot-copy walk, the snapshot-dir + * file walk, the copy-filter decision, and the `package.json` deps + * merge. NOT a public entrypoint — consumed by `apply-ui-snapshot.ts`. + */ + +import { + copyFile, + mkdir, + readFile, + readdir, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; + +import { runWithConcurrency } from './strip-features.js'; +import { + REPLACEABLE_UI_FILES, + pathExists, +} from './apply-ui-snapshot.constants.js'; +import type { PackageDepsFile } from './apply-ui-snapshot.constants.js'; + +/** + * Concurrency cap for the snapshot-copy walk. Snapshots are small + * (≤ ~30 files for animate-ui's superset), and the cap is per-directory + * — child dirs get their own pass. 8 keeps in-flight fds modest while + * still saturating IO on cold disks. + */ +const SNAPSHOT_COPY_CONCURRENCY = 8; + +export async function copyDirRecursive( + src: string, + dest: string, + filter?: (relPosix: string) => boolean, + rel: string = '' +): Promise { + await mkdir(dest, { recursive: true }); + const entries = await readdir(src, { withFileTypes: true }); + // Recurse into subdirectories sequentially (bounded depth, predictable + // fd usage) but race the leaf-file copyFile calls — that's where the + // wall-clock wins are. Empirically the snapshot has 1–2 nested levels + // and 5–25 files per dir, so flattening to a single mass copy is + // unnecessary. + const fileTasks: Array<() => Promise> = []; + for (const e of entries) { + const s = path.join(src, e.name); + const d = path.join(dest, e.name); + const next = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) { + await copyDirRecursive(s, d, filter, next); + } else if (e.isFile()) { + if (filter && !filter(next)) continue; + fileTasks.push(() => copyFile(s, d)); + } + } + await runWithConcurrency(fileTasks, SNAPSHOT_COPY_CONCURRENCY); +} + +/** + * Decides whether a snapshot tree entry should be copied into the + * scaffolded project. Replaceable primitives under `src/shared/ui/` + * only copy when their counterpart survived `stripFeatures` (the + * `survivors` set). Everything else copies through. + */ +export function shouldCopySnapshotEntry( + relPosix: string, + survivors: ReadonlySet +): boolean { + const uiPrefix = 'shared/ui/'; + if (relPosix.startsWith(uiPrefix)) { + const rest = relPosix.slice(uiPrefix.length); + if (rest.includes('/')) return true; + if (REPLACEABLE_UI_FILES.includes(rest)) { + return survivors.has(rest); + } + return true; + } + return true; +} + +export async function walkSnapshotDir( + base: string, + rel: string, + acc: string[] +): Promise { + const entries = await readdir(path.join(base, rel), { withFileTypes: true }); + for (const e of entries) { + if (e.name === 'package-deps.json' && rel === '') continue; + if (e.name === '.gitkeep') continue; + const next = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) { + await walkSnapshotDir(base, next, acc); + } else if (e.isFile()) { + acc.push(next); + } + } +} + +export async function mergePackageDeps( + projectDir: string, + snapshotDir: string +): Promise { + const depsFile = path.join(snapshotDir, 'package-deps.json'); + if (!(await pathExists(depsFile))) return; + + const raw = await readFile(depsFile, 'utf8'); + const parsed = JSON.parse(raw) as PackageDepsFile; + const addDeps = parsed.dependencies ?? {}; + const addDevDeps = parsed.devDependencies ?? {}; + if ( + Object.keys(addDeps).length === 0 && + Object.keys(addDevDeps).length === 0 + ) { + return; + } + + const pkgPath = path.join(projectDir, 'package.json'); + const pkgRaw = await readFile(pkgPath, 'utf8'); + const pkg = JSON.parse(pkgRaw) as { + dependencies?: Record; + devDependencies?: Record; + }; + + pkg.dependencies = sortDeps({ ...(pkg.dependencies ?? {}), ...addDeps }); + if (Object.keys(addDevDeps).length > 0) { + pkg.devDependencies = sortDeps({ + ...(pkg.devDependencies ?? {}), + ...addDevDeps, + }); + } + + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); +} + +function sortDeps(deps: Record): Record { + const sorted: Record = {}; + for (const k of Object.keys(deps).sort()) { + sorted[k] = deps[k]!; + } + return sorted; +} diff --git a/packages/create-eikon-react/src/apply-ui-snapshot.read.ts b/packages/create-eikon-react/src/apply-ui-snapshot.read.ts new file mode 100644 index 0000000..38f8428 --- /dev/null +++ b/packages/create-eikon-react/src/apply-ui-snapshot.read.ts @@ -0,0 +1,117 @@ +/** + * @file apply-ui-snapshot.read.ts + * @description Read-only snapshot query API: resolve a snapshot dir, + * list its files, read a single file, read its dependency pins, and the + * `isReplaceableUiFile` predicate. NOT a public entrypoint — these are + * re-exported verbatim from `apply-ui-snapshot.ts` so importers keep the + * same path. Split out so the main file holds only the `applyUiSnapshot` + * orchestration plus its design rationale. + */ + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { + REPLACEABLE_UI_FILES, + UI_DIR_REL, + defaultSnapshotsRoot, + isUiVariant, + pathExists, +} from './apply-ui-snapshot.constants.js'; +import type { PackageDepsFile } from './apply-ui-snapshot.constants.js'; +import { walkSnapshotDir } from './apply-ui-snapshot.fs.js'; + +/** + * True when `relPath` (POSIX-separated) names one of the seven UI + * primitives the snapshot OWNS. The simulator uses this to decide + * whether to read from `src/shared/ui/...` in the template-react tree + * or from the snapshot dir for the chosen `ui`. + */ +export function isReplaceableUiFile(relPath: string): boolean { + const expected = REPLACEABLE_UI_FILES.map( + (name) => `${UI_DIR_REL.replace(/\\/g, '/')}/${name}` + ); + return expected.includes(relPath); +} + +/** + * Returns the on-disk root for the chosen `ui`'s snapshot, or `null` + * for `custom` (no snapshot — the template's own files are used) or + * unknown values. Callers verify the directory exists separately. + */ +export function getSnapshotDir( + ui: string, + snapshotsRoot: string = defaultSnapshotsRoot() +): string | null { + if (!isUiVariant(ui) || ui === 'custom') return null; + return path.join(snapshotsRoot, ui); +} + +/** + * Walk a snapshot dir and return its files as POSIX-relative paths + * (relative to the snapshot dir itself). Skips `package-deps.json` + * because that's metadata for `applyUiSnapshot`, not a project file. + * + * Returns an empty array when the snapshot dir doesn't exist yet + * (e.g. before the maintainer has run `pnpm sync-ui-snapshots`). + */ +export async function listSnapshotFiles( + ui: string, + snapshotsRoot: string = defaultSnapshotsRoot() +): Promise { + const dir = getSnapshotDir(ui, snapshotsRoot); + if (!dir || !(await pathExists(dir))) return []; + const out: string[] = []; + await walkSnapshotDir(dir, '', out); + return out.sort(); +} + +/** + * Read a single snapshot file's contents. `relPath` is relative to the + * snapshot dir (not the project), POSIX-separated. Returns `null` when + * the file or snapshot dir doesn't exist. + */ +export async function readSnapshotFile( + ui: string, + relPath: string, + snapshotsRoot: string = defaultSnapshotsRoot() +): Promise { + const dir = getSnapshotDir(ui, snapshotsRoot); + if (!dir) return null; + const abs = path.join(dir, relPath); + if (!(await pathExists(abs))) return null; + try { + return await readFile(abs, 'utf8'); + } catch { + return null; + } +} + +/** + * Read the snapshot's `package-deps.json` if present. Returns `{}` when + * absent — callers can spread this into `package.json`'s `dependencies` + * unconditionally. + */ +export async function readSnapshotDeps( + ui: string, + snapshotsRoot: string = defaultSnapshotsRoot() +): Promise<{ + dependencies: Record; + devDependencies: Record; +}> { + const dir = getSnapshotDir(ui, snapshotsRoot); + if (!dir) return { dependencies: {}, devDependencies: {} }; + const file = path.join(dir, 'package-deps.json'); + if (!(await pathExists(file))) { + return { dependencies: {}, devDependencies: {} }; + } + try { + const parsed = JSON.parse(await readFile(file, 'utf8')) as PackageDepsFile; + return { + dependencies: parsed.dependencies ?? {}, + devDependencies: parsed.devDependencies ?? {}, + }; + } catch { + return { dependencies: {}, devDependencies: {} }; + } +} diff --git a/packages/create-eikon-react/src/apply-ui-snapshot.ts b/packages/create-eikon-react/src/apply-ui-snapshot.ts index 4b9999d..f34c348 100644 --- a/packages/create-eikon-react/src/apply-ui-snapshot.ts +++ b/packages/create-eikon-react/src/apply-ui-snapshot.ts @@ -34,296 +34,61 @@ * src/shared/ui/.tsx (replaces project counterpart) * components.json (placed at project root) * package-deps.json (merged into project package.json) + * + * Internal helpers live in sibling files (`*.constants.ts`, `*.fs.ts`, + * `*.eslint.ts`); this file owns the public API and orchestration. The + * public exports and import path are unchanged. */ // ================================================================================================= // Imports // ================================================================================================= -import { - copyFile, - mkdir, - readFile, - readdir, - rm, - stat, - writeFile, -} from 'node:fs/promises'; +import { copyFile, mkdir, rm } from 'node:fs/promises'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { runWithConcurrency } from './strip-features.js'; - -// ================================================================================================= -// Constants -// ================================================================================================= - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -/** - * Files in `src/shared/ui/` that the snapshot OWNS — these are deleted - * before the snapshot is laid down so the project doesn't end up with a - * mix of project-authored and registry-authored primitives. Files NOT - * in this list (`theme-toggle.tsx`, `language-switcher.tsx`, anything - * future) survive untouched across all `--ui` choices. - * - * Kept as basenames; `applyUiSnapshot` joins them against the project's - * `src/shared/ui/` dir. - */ -const REPLACEABLE_UI_FILES: readonly string[] = [ - 'button.tsx', - 'dialog.tsx', - 'tabs.tsx', - 'sheet.tsx', - 'command.tsx', - 'card.tsx', - 'toaster.tsx', - // Form + display primitives shipped by the design-system audit pass - // (Phase 4). Every entry below MUST exist in the same shape across - // the template AND every snapshot under template-snapshots/* — - // missing the snapshot copy means `--ui shadcn` / `--ui animate-ui` - // will delete the project's owned file without putting an - // equivalent back. - 'input.tsx', - 'textarea.tsx', - 'label.tsx', - 'select.tsx', - 'checkbox.tsx', - 'radio-group.tsx', - 'switch.tsx', - 'badge.tsx', - 'avatar.tsx', - 'skeleton.tsx', - 'tooltip.tsx', - 'popover.tsx', - 'alert.tsx', -] as const; - -const UI_DIR_REL = path.join('src', 'shared', 'ui'); - -// ================================================================================================= -// Types -// ================================================================================================= - -export type UiVariant = 'custom' | 'shadcn' | 'animate-ui'; - -const UI_VARIANTS: ReadonlySet = new Set([ - 'custom', - 'shadcn', - 'animate-ui', -]); - -function isUiVariant(v: string): v is UiVariant { - return UI_VARIANTS.has(v as UiVariant); -} - -interface PackageDepsFile { - readonly dependencies?: Record; - readonly devDependencies?: Record; -} - -// ================================================================================================= -// Helpers -// ================================================================================================= - -/** - * Default snapshots root — sibling of `template/` inside the - * create-eikon-react package. Resolves correctly both at - * compile-output (dist/) and source-import (src/) time because - * `__dirname/..` is always `packages/create-eikon-react/` in either - * layout. Callers that bundle this module out-of-tree (e.g. the - * preview-site if it ever stops importing the TS source directly) - * should pass `snapshotsRoot` explicitly to override. - */ -function defaultSnapshotsRoot(): string { - return path.resolve(__dirname, '..', 'template-snapshots'); -} - -async function pathExists(p: string): Promise { - try { - await stat(p); - return true; - } catch { - return false; - } -} - -/** - * Concurrency cap for the snapshot-copy walk. Snapshots are small - * (≤ ~30 files for animate-ui's superset), and the cap is per-directory - * — child dirs get their own pass. 8 keeps in-flight fds modest while - * still saturating IO on cold disks. - */ -const SNAPSHOT_COPY_CONCURRENCY = 8; -async function copyDirRecursive( - src: string, - dest: string, - filter?: (relPosix: string) => boolean, - rel: string = '' -): Promise { - await mkdir(dest, { recursive: true }); - const entries = await readdir(src, { withFileTypes: true }); - // Recurse into subdirectories sequentially (bounded depth, predictable - // fd usage) but race the leaf-file copyFile calls — that's where the - // wall-clock wins are. Empirically the snapshot has 1–2 nested levels - // and 5–25 files per dir, so flattening to a single mass copy is - // unnecessary. - const fileTasks: Array<() => Promise> = []; - for (const e of entries) { - const s = path.join(src, e.name); - const d = path.join(dest, e.name); - const next = rel ? `${rel}/${e.name}` : e.name; - if (e.isDirectory()) { - await copyDirRecursive(s, d, filter, next); - } else if (e.isFile()) { - if (filter && !filter(next)) continue; - fileTasks.push(() => copyFile(s, d)); - } - } - await runWithConcurrency(fileTasks, SNAPSHOT_COPY_CONCURRENCY); -} - -/** - * Decides whether a snapshot tree entry should be copied into the - * scaffolded project. Replaceable primitives under `src/shared/ui/` - * only copy when their counterpart survived `stripFeatures` (the - * `survivors` set). Everything else copies through. - */ -function shouldCopySnapshotEntry( - relPosix: string, - survivors: ReadonlySet -): boolean { - const uiPrefix = 'shared/ui/'; - if (relPosix.startsWith(uiPrefix)) { - const rest = relPosix.slice(uiPrefix.length); - if (rest.includes('/')) return true; - if (REPLACEABLE_UI_FILES.includes(rest)) { - return survivors.has(rest); - } - return true; - } - return true; -} +import { + REPLACEABLE_UI_FILES, + UI_DIR_REL, + UI_SNAPSHOT_ESLINT_FILE, + defaultSnapshotsRoot, + isUiVariant, + pathExists, + uiVariantList, +} from './apply-ui-snapshot.constants.js'; +import type { UiVariant } from './apply-ui-snapshot.constants.js'; +import { + copyDirRecursive, + mergePackageDeps, + shouldCopySnapshotEntry, +} from './apply-ui-snapshot.fs.js'; +import { + buildUiSnapshotEslintConfig, + writeUiSnapshotEslintOverride, +} from './apply-ui-snapshot.eslint.js'; +import { + getSnapshotDir, + isReplaceableUiFile, + listSnapshotFiles, + readSnapshotDeps, + readSnapshotFile, +} from './apply-ui-snapshot.read.js'; + +// The read-only query API lives in `./apply-ui-snapshot.read.js`; +// re-exported here verbatim so the public import path +// (`apply-ui-snapshot.ts`) keeps the same surface for every importer. +export { + getSnapshotDir, + isReplaceableUiFile, + listSnapshotFiles, + readSnapshotDeps, + readSnapshotFile, +}; // ================================================================================================= // Public API // ================================================================================================= -/** - * True when `relPath` (POSIX-separated) names one of the seven UI - * primitives the snapshot OWNS. The simulator uses this to decide - * whether to read from `src/shared/ui/...` in the template-react tree - * or from the snapshot dir for the chosen `ui`. - */ -export function isReplaceableUiFile(relPath: string): boolean { - const expected = REPLACEABLE_UI_FILES.map((name) => - `${UI_DIR_REL.replace(/\\/g, '/')}/${name}` - ); - return expected.includes(relPath); -} - -/** - * Returns the on-disk root for the chosen `ui`'s snapshot, or `null` - * for `custom` (no snapshot — the template's own files are used) or - * unknown values. Callers verify the directory exists separately. - */ -export function getSnapshotDir( - ui: string, - snapshotsRoot: string = defaultSnapshotsRoot() -): string | null { - if (!isUiVariant(ui) || ui === 'custom') return null; - return path.join(snapshotsRoot, ui); -} - -/** - * Walk a snapshot dir and return its files as POSIX-relative paths - * (relative to the snapshot dir itself). Skips `package-deps.json` - * because that's metadata for `applyUiSnapshot`, not a project file. - * - * Returns an empty array when the snapshot dir doesn't exist yet - * (e.g. before the maintainer has run `pnpm sync-ui-snapshots`). - */ -export async function listSnapshotFiles( - ui: string, - snapshotsRoot: string = defaultSnapshotsRoot() -): Promise { - const dir = getSnapshotDir(ui, snapshotsRoot); - if (!dir || !(await pathExists(dir))) return []; - const out: string[] = []; - await walkSnapshotDir(dir, '', out); - return out.sort(); -} - -async function walkSnapshotDir( - base: string, - rel: string, - acc: string[] -): Promise { - const entries = await readdir(path.join(base, rel), { withFileTypes: true }); - for (const e of entries) { - if (e.name === 'package-deps.json' && rel === '') continue; - if (e.name === '.gitkeep') continue; - const next = rel ? `${rel}/${e.name}` : e.name; - if (e.isDirectory()) { - await walkSnapshotDir(base, next, acc); - } else if (e.isFile()) { - acc.push(next); - } - } -} - -/** - * Read a single snapshot file's contents. `relPath` is relative to the - * snapshot dir (not the project), POSIX-separated. Returns `null` when - * the file or snapshot dir doesn't exist. - */ -export async function readSnapshotFile( - ui: string, - relPath: string, - snapshotsRoot: string = defaultSnapshotsRoot() -): Promise { - const dir = getSnapshotDir(ui, snapshotsRoot); - if (!dir) return null; - const abs = path.join(dir, relPath); - if (!(await pathExists(abs))) return null; - try { - return await readFile(abs, 'utf8'); - } catch { - return null; - } -} - -/** - * Read the snapshot's `package-deps.json` if present. Returns `{}` when - * absent — callers can spread this into `package.json`'s `dependencies` - * unconditionally. - */ -export async function readSnapshotDeps( - ui: string, - snapshotsRoot: string = defaultSnapshotsRoot() -): Promise<{ - dependencies: Record; - devDependencies: Record; -}> { - const dir = getSnapshotDir(ui, snapshotsRoot); - if (!dir) return { dependencies: {}, devDependencies: {} }; - const file = path.join(dir, 'package-deps.json'); - if (!(await pathExists(file))) { - return { dependencies: {}, devDependencies: {} }; - } - try { - const parsed = JSON.parse(await readFile(file, 'utf8')) as PackageDepsFile; - return { - dependencies: parsed.dependencies ?? {}, - devDependencies: parsed.devDependencies ?? {}, - }; - } catch { - return { dependencies: {}, devDependencies: {} }; - } -} - /** * Replace the project's `src/shared/ui/*` library files with the chosen * snapshot, drop in `components.json`, and merge `package-deps.json` @@ -345,7 +110,7 @@ export async function applyUiSnapshot( if (!isUiVariant(ui)) { throw new Error( `[applyUiSnapshot] unknown ui variant: ${ui} ` + - `(expected one of: ${Array.from(UI_VARIANTS).join(', ')})` + `(expected one of: ${uiVariantList()})` ); } // Scrub any leftover snapshot artefacts before bailing on `custom` — @@ -477,154 +242,13 @@ async function scrubSnapshotArtefacts(projectDir: string): Promise { ]); } -async function mergePackageDeps( - projectDir: string, - snapshotDir: string -): Promise { - const depsFile = path.join(snapshotDir, 'package-deps.json'); - if (!(await pathExists(depsFile))) return; - - const raw = await readFile(depsFile, 'utf8'); - const parsed = JSON.parse(raw) as PackageDepsFile; - const addDeps = parsed.dependencies ?? {}; - const addDevDeps = parsed.devDependencies ?? {}; - if ( - Object.keys(addDeps).length === 0 && - Object.keys(addDevDeps).length === 0 - ) { - return; - } - - const pkgPath = path.join(projectDir, 'package.json'); - const pkgRaw = await readFile(pkgPath, 'utf8'); - const pkg = JSON.parse(pkgRaw) as { - dependencies?: Record; - devDependencies?: Record; - }; - - pkg.dependencies = sortDeps({ ...(pkg.dependencies ?? {}), ...addDeps }); - if (Object.keys(addDevDeps).length > 0) { - pkg.devDependencies = sortDeps({ - ...(pkg.devDependencies ?? {}), - ...addDevDeps, - }); - } - - await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); -} - -function sortDeps(deps: Record): Record { - const sorted: Record = {}; - for (const k of Object.keys(deps).sort()) { - sorted[k] = deps[k]!; - } - return sorted; -} - -/** - * Files under `src/shared/ui/` whose lint rules the snapshot relaxes. - * Mirrors `REPLACEABLE_UI_FILES` but expressed as ESLint glob entries. - */ -const SNAPSHOT_UI_FILE_GLOBS = REPLACEABLE_UI_FILES.map( - (name) => `src/shared/ui/${name}` -); - -const SNAPSHOT_RULES_OFF = { - 'eikon/file-header-banner': 'off', - 'eikon/filename-matches-export': 'off', - 'eikon/filename-case-by-path': 'off', - 'import/no-default-export': 'off', - 'import/order': 'off', - 'max-lines': 'off', - '@typescript-eslint/consistent-type-imports': 'off', - 'react-refresh/only-export-components': 'off', -} as const; - -/** - * Walk the snapshot's `src/` tree and return one glob per top-level - * subdirectory the snapshot ships, EXCEPT `shared/ui/` (already covered - * by per-file globs in `SNAPSHOT_UI_FILE_GLOBS`). Empty array when the - * snapshot ships nothing outside `src/shared/ui/` (today this is the - * shadcn shape). - * - * Derived rather than hard-coded so a future shadcn registry that - * starts shipping `src/hooks/` or `src/lib/` doesn't silently lint-fail - * on the snapshot files until someone remembers to update a list here. - */ -async function deriveExtraEslintGlobs(snapshotDir: string): Promise { - const srcDir = path.join(snapshotDir, 'src'); - if (!(await pathExists(srcDir))) return []; - const entries = await readdir(srcDir, { withFileTypes: true }); - const out: string[] = []; - for (const e of entries) { - if (!e.isDirectory()) continue; - if (e.name === 'shared') continue; - out.push(`src/${e.name}/**/*.{ts,tsx}`); - } - return out.sort(); -} - -async function buildUiSnapshotEslintConfig( - ui: UiVariant, - snapshotDir: string -): Promise { - if (ui === 'custom') return ''; - const extraGlobs = await deriveExtraEslintGlobs(snapshotDir); - const files = [...SNAPSHOT_UI_FILE_GLOBS, ...extraGlobs]; - const filesLiteral = files.map((f) => ` '${f}'`).join(',\n'); - const rulesLiteral = Object.entries(SNAPSHOT_RULES_OFF) - .map(([k, v]) => ` '${k}': '${v}'`) - .join(',\n'); - return [ - '/**', - ' * @file eslint.config.ui-snapshot.js', - ` * @description Override block emitted by \`applyUiSnapshot\` for \`--ui ${ui}\`.`, - ' *', - ' * The vendored UI primitives under `src/shared/ui/` (and any extra', - ' * helper paths the registry ships, e.g. animate-ui\'s', - ' * `src/components/animate-ui/`) come from upstream verbatim and', - ' * don\'t carry the project\'s banner / import-order / max-lines', - ' * conventions. This config disables those rules on those paths', - ' * only — `eslint.config.js` lazy-imports this file at the very end', - ' * of its rule list, so this file does NOT exist for `--ui custom`.', - ' *', - ' * Regenerate by re-running `npx create-eikon-react` with the same', - ` * \`--ui ${ui}\` flag, or by editing this file in place if you want`, - ' * to tighten the lint surface.', - ' */', - '', - 'export default [', - ' {', - ' files: [', - `${filesLiteral},`, - ' ],', - ' rules: {', - `${rulesLiteral},`, - ' },', - ' },', - '];', - '', - ].join('\n'); -} - -async function writeUiSnapshotEslintOverride( - projectDir: string, - ui: UiVariant, - snapshotDir: string -): Promise { - if (ui === 'custom') return; - const text = await buildUiSnapshotEslintConfig(ui, snapshotDir); - if (!text) return; - await writeFile( - path.join(projectDir, UI_SNAPSHOT_ESLINT_FILE), - text, - 'utf8' - ); -} - // ================================================================================================= // Exports // ================================================================================================= -export { REPLACEABLE_UI_FILES, buildUiSnapshotEslintConfig }; -export const UI_SNAPSHOT_ESLINT_FILE = 'eslint.config.ui-snapshot.js'; +export type { UiVariant }; +export { + REPLACEABLE_UI_FILES, + UI_SNAPSHOT_ESLINT_FILE, + buildUiSnapshotEslintConfig, +}; diff --git a/packages/create-eikon-react/src/cli-args.ts b/packages/create-eikon-react/src/cli-args.ts new file mode 100644 index 0000000..a434990 --- /dev/null +++ b/packages/create-eikon-react/src/cli-args.ts @@ -0,0 +1,174 @@ +/** + * @file cli-args.ts + * @description argv parsing for the `create-eikon-react` CLI. Owns the + * `ParsedArgs` shape, the flag grammar (boolean flags + `--pm` + + * `--` variant flags), and the "unknown flag → did you mean" + * suggestion path. Kept as an internal sibling of `index.ts`; the entry + * file imports `parseArgs` + `ParsedArgs` and is otherwise unchanged. + * + * The variant axes are mirrored here as a string-literal union; the + * canonical value tables (`VARIANT_CHOICES` / `PLATFORM_OVERRIDES`) stay + * inline in `index.ts` because the CLI ↔ schema parity test parses them + * textually from that file. + */ + +import kleur from 'kleur'; + +export type VariantAxis = 'platform' | 'design' | 'layout' | 'ui' | 'toastPosition'; +export type PlatformValue = 'web' | 'desktop' | 'mobile'; + +export const VARIANT_FLAG_ALIASES: Record = { + platform: ['platform'], + design: ['design'], + layout: ['layout'], + ui: ['ui'], + toastPosition: ['toastPosition', 'toast-position'], +}; + +const VARIANT_AXES = Object.keys(VARIANT_FLAG_ALIASES) as VariantAxis[]; + +export interface ParsedArgs { + name?: string; + yes: boolean; + supabase?: boolean; + install?: boolean; + git?: boolean; + pm?: 'pnpm' | 'npm' | 'bun'; + variants?: Partial>; +} + +export function parseArgs(argv: string[]): ParsedArgs { + const out: ParsedArgs = { yes: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + if (a === '-y' || a === '--yes') out.yes = true; + else if (a === '--supabase') out.supabase = true; + else if (a === '--no-supabase') out.supabase = false; + else if (a === '--install') out.install = true; + else if (a === '--no-install') out.install = false; + else if (a === '--git') out.git = true; + else if (a === '--no-git') out.git = false; + else if (a === '--pm') { + const next = argv[++i]; + if (next === 'pnpm' || next === 'npm' || next === 'bun') out.pm = next; + } else if (a.startsWith('--pm=')) { + const v = a.slice('--pm='.length); + if (v === 'pnpm' || v === 'npm' || v === 'bun') out.pm = v; + } else if (tryParseVariant(a, argv, i, out)) { + // `tryParseVariant` consumes one or two argv positions and updates `out`. + if (a.indexOf('=') === -1) i += 1; + } else if (!a.startsWith('-') && out.name === undefined) { + out.name = a; + } else if (a.startsWith('-')) { + reportUnknownFlag(a); + } + } + return out; +} + +/** + * Every long-form flag the CLI recognises. Used to suggest a "did you + * mean" candidate when the user typo's a flag name. Variant flags come + * straight from `VARIANT_FLAG_ALIASES` so a future axis automatically + * lands in the suggestion set without a second touch-point here. + */ +function knownFlags(): readonly string[] { + const variantFlags: string[] = []; + for (const axis of VARIANT_AXES) { + for (const alias of VARIANT_FLAG_ALIASES[axis]) { + variantFlags.push(`--${alias}`); + } + } + return [ + '--yes', + '-y', + '--supabase', + '--no-supabase', + '--install', + '--no-install', + '--git', + '--no-git', + '--pm', + ...variantFlags, + ]; +} + +/** + * Print a clear "Unknown flag: --x. Did you mean --y?" and exit non-zero. + * The previous behaviour silently ignored typos, which meant + * `create-eikon-react my-app --platfrom=mobile` ran as `web` with no + * indication anything had gone wrong — the user thought they'd asked + * for mobile and didn't notice until much later. + */ +function reportUnknownFlag(typed: string): never { + const flagOnly = typed.split('=')[0]!; + const candidates = knownFlags(); + let best: { flag: string; dist: number } | null = null; + for (const cand of candidates) { + const d = levenshtein(flagOnly, cand); + if (best === null || d < best.dist) best = { flag: cand, dist: d }; + } + console.error(kleur.red(`Unknown flag: ${flagOnly}`)); + if (best && best.dist <= Math.max(2, Math.floor(flagOnly.length / 3))) { + console.error(`Did you mean ${kleur.cyan(best.flag)}?`); + } + console.error(`Run ${kleur.cyan('create-eikon-react --help')} to see all flags.`); + process.exit(1); +} + +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + const prev = new Array(b.length + 1); + const curr = new Array(b.length + 1); + for (let j = 0; j <= b.length; j++) prev[j] = j; + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(curr[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost); + } + for (let j = 0; j <= b.length; j++) prev[j] = curr[j]!; + } + return prev[b.length]!; +} + +/** + * Parse one of the variant flags (`--design / --layout / --ui`) in either + * `--flag value` or `--flag=value` form. Returns true if the token belonged + * to a known variant axis (and was therefore consumed) regardless of + * whether the value was valid. + * + * Always records the typed value when one was supplied (skipping only + * tokens shaped like another flag, e.g. `--ui --design`), so + * `resolveVariants` can produce a single, descriptive warning naming the + * value, the axis, and the chosen default — visible in `--yes` mode + * where there's no interactive re-prompt to soften the failure. + */ +function tryParseVariant( + token: string, + argv: string[], + i: number, + out: ParsedArgs +): boolean { + for (const axis of VARIANT_AXES) { + for (const alias of VARIANT_FLAG_ALIASES[axis]) { + const longFlag = `--${alias}`; + let value: string | undefined; + if (token === longFlag) { + value = argv[i + 1]; + } else if (token.startsWith(`${longFlag}=`)) { + value = token.slice(longFlag.length + 1); + } else { + continue; + } + if (value !== undefined && !value.startsWith('-')) { + out.variants ??= {}; + out.variants[axis] = value; + } + return true; + } + } + return false; +} diff --git a/packages/create-eikon-react/src/cli-prompts.ts b/packages/create-eikon-react/src/cli-prompts.ts new file mode 100644 index 0000000..d04b41b --- /dev/null +++ b/packages/create-eikon-react/src/cli-prompts.ts @@ -0,0 +1,232 @@ +/** + * @file cli-prompts.ts + * @description Interactive (and `--yes`/argv-driven) resolution of every + * CLI answer: project name, feature flags, variant selections, package + * manager, and the install/git booleans. Each resolver short-circuits on + * an explicit argv value, then on `--yes`, and otherwise falls back to a + * `@clack/prompts` question. + * + * Internal sibling of `index.ts`. The canonical value tables + * (`VARIANT_CHOICES` / `PLATFORM_OVERRIDES`) live inline in `index.ts` + * (the parity test parses them textually) and are threaded in here as + * arguments rather than imported, keeping this module free of an import + * cycle with the entry file. + */ + +import { cancel, confirm, isCancel, log, select, text } from '@clack/prompts'; +import kleur from 'kleur'; + +import type { ParsedArgs, PlatformValue, VariantAxis } from './cli-args.js'; +import { DEFAULT_VARIANTS, type FeatureFlags, type VariantSelections } from './strip-features.js'; +import { isValidPackageName, toValidPackageName } from './validate.js'; + +type PlatformOverride = { + readonly values?: readonly string[]; + readonly default?: string; +}; + +export type VariantChoices = Record; +export type PlatformOverrides = Record< + Exclude, + Partial> +>; + +function getEffectiveValues( + axis: VariantAxis, + platform: PlatformValue, + choices: VariantChoices, + overrides: PlatformOverrides +): readonly string[] { + if (axis === 'platform') return choices.platform; + const override = overrides[axis][platform]; + return override?.values ?? choices[axis]; +} + +function getEffectiveDefault( + axis: VariantAxis, + platform: PlatformValue, + overrides: PlatformOverrides +): string { + if (axis === 'platform') return DEFAULT_VARIANTS.platform!; + const override = overrides[axis][platform]; + return override?.default ?? DEFAULT_VARIANTS[axis]!; +} + +export async function promptProjectName( + fromArgv: string | undefined, + yes: boolean +): Promise { + if (fromArgv) { + // `.` / `./` are passed through unchanged; resolveProjectTarget() turns + // them into "scaffold into cwd" with a derived package name. + if (isCwdShortcut(fromArgv)) return fromArgv; + if (isValidPackageName(fromArgv)) return fromArgv; + return toValidPackageName(fromArgv); + } + if (yes) return 'my-eikon-app'; + const name = await text({ + message: 'Project name (or "." to use the current directory):', + placeholder: 'my-eikon-app', + initialValue: 'my-eikon-app', + validate(value) { + if (!value) return 'Required.'; + if (isCwdShortcut(value)) return undefined; + if (!isValidPackageName(value)) + return 'Must be a valid npm package name (lowercase, dashes, no spaces).'; + return undefined; + }, + }); + if (isCancel(name)) { + cancel('Aborted.'); + process.exit(1); + } + return name; +} + +function isCwdShortcut(value: string): boolean { + return value === '.' || value === './'; +} + +export async function resolveFeatures(argv: ParsedArgs): Promise { + const flags: FeatureFlags = { supabase: false }; + + if (argv.supabase !== undefined) flags.supabase = argv.supabase; + + if (!argv.yes && argv.supabase === undefined) { + const supabase = await select({ + message: 'Include Supabase (auth + db + storage) scaffolding?', + initialValue: false as boolean, + options: [ + { value: false, label: 'No — plain frontend only (recommended for new starters)' }, + { value: true, label: 'Yes — include @supabase/supabase-js + client scaffold' }, + ], + }); + if (isCancel(supabase)) { + cancel('Aborted.'); + process.exit(1); + } + flags.supabase = Boolean(supabase); + } + + return flags; +} + +export async function resolveVariants( + argv: ParsedArgs, + choices: VariantChoices, + overrides: PlatformOverrides +): Promise { + const out: VariantSelections = { ...DEFAULT_VARIANTS }; + // Resolve `platform` FIRST so subsequent axes can default / restrict + // their accepted values per the chosen target. Order is enforced by + // walking `Object.keys(choices)` which lists `platform` first. + const axes = Object.keys(choices) as VariantAxis[]; + for (const axis of axes) { + const platform = (out.platform ?? DEFAULT_VARIANTS.platform!) as PlatformValue; + const allowed = getEffectiveValues(axis, platform, choices, overrides); + const effDefault = getEffectiveDefault(axis, platform, overrides); + + // Snap any pre-set value (from --yes default-walk OR previous axis + // resolution) into the per-platform allowed set. E.g. when the user + // passes `--yes --platform mobile`, layout's seeded `'stacked'` would + // be invalid for mobile and gets snapped to `'mobile-drawer'`. + if (!allowed.includes(out[axis] ?? '')) { + out[axis] = effDefault; + } + + const fromArgv = argv.variants?.[axis]; + if (fromArgv !== undefined) { + // Argv values bypass the prompt but still respect the per-platform + // narrowing — pass through only if the user-supplied value is valid + // for the chosen platform; otherwise keep the snapped default and + // surface a one-line warning. Silent ignore would hide bugs. + if (allowed.includes(fromArgv)) { + out[axis] = fromArgv; + } else { + log.warn( + `--${axis}=${kleur.yellow(fromArgv)} is not valid for platform=${platform}.` + + ` Using ${kleur.cyan(effDefault)} instead.` + ); + } + continue; + } + if (argv.yes) continue; + const choice = await select({ + message: `Choose a ${axis} variant:`, + initialValue: out[axis] as string, + options: allowed.map((value) => ({ + value, + label: value === effDefault ? `${value} (default)` : value, + })), + }); + if (isCancel(choice)) { + cancel('Aborted.'); + process.exit(1); + } + out[axis] = String(choice); + } + return out; +} + +export async function resolvePackageManager( + argv: ParsedArgs, + variants: VariantSelections +): Promise<'pnpm' | 'npm' | 'bun'> { + // Desktop / mobile shells live in `apps/*` and are wired up via + // `pnpm-workspace.yaml` + `pnpm --filter` scripts in the root + // package.json. Neither npm nor bun has a drop-in equivalent for + // those filter calls, and rewriting the workspace topology to npm + // workspaces / bun workspaces is out of scope. Snap to pnpm with a + // visible warning so the user knows their `--pm` choice was overridden + // rather than silently ignored. + const platform = variants.platform; + const requiresPnpm = platform === 'desktop' || platform === 'mobile'; + + const snapIfNeeded = (chosen: 'pnpm' | 'npm' | 'bun'): 'pnpm' | 'npm' | 'bun' => { + if (requiresPnpm && chosen !== 'pnpm') { + log.warn( + `--pm=${kleur.yellow(chosen)} is not supported for platform=${kleur.yellow( + platform! + )} — apps/${platform}/ uses pnpm workspaces. Falling back to ${kleur.cyan( + 'pnpm' + )}.` + ); + return 'pnpm'; + } + return chosen; + }; + + if (argv.pm) return snapIfNeeded(argv.pm); + if (argv.yes) return 'pnpm'; + const pm = await select({ + message: 'Package manager:', + initialValue: 'pnpm' as 'pnpm' | 'npm' | 'bun', + options: [ + { value: 'pnpm', label: 'pnpm (recommended)' }, + { value: 'npm', label: 'npm' }, + { value: 'bun', label: 'bun' }, + ], + }); + if (isCancel(pm)) { + cancel('Aborted.'); + process.exit(1); + } + return snapIfNeeded(pm); +} + +export async function resolveBoolean( + argv: ParsedArgs, + key: 'install' | 'git', + defaultValue: boolean, + message: string +): Promise { + const fromArgv = argv[key]; + if (fromArgv !== undefined) return fromArgv; + if (argv.yes) return defaultValue; + const answer = await confirm({ message, initialValue: defaultValue }); + if (isCancel(answer)) { + cancel('Aborted.'); + process.exit(1); + } + return Boolean(answer); +} diff --git a/packages/create-eikon-react/src/index.ts b/packages/create-eikon-react/src/index.ts index 28436fb..419b035 100644 --- a/packages/create-eikon-react/src/index.ts +++ b/packages/create-eikon-react/src/index.ts @@ -1,36 +1,22 @@ -import { mkdir, readdir } from 'node:fs/promises'; +import { readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - cancel, - confirm, - intro, - isCancel, - log, - note, - outro, - select, - spinner, - text, -} from '@clack/prompts'; +import { cancel, confirm, intro, isCancel, note, outro } from '@clack/prompts'; import kleur from 'kleur'; -import { copyTemplate } from './copy-template.js'; -import { applyUiSnapshot } from './apply-ui-snapshot.js'; -import { initGit } from './init-git.js'; -import { injectHtmlVariants } from './inject-html-variants.js'; -import { installDeps } from './install-deps.js'; -import { resolveProjectTarget } from './resolve-target.js'; -import { rewritePackageManagerFields } from './rewrite-package-manager.js'; +import { parseArgs } from './cli-args.js'; +import type { PlatformOverrides, VariantChoices } from './cli-prompts.js'; import { - DEFAULT_VARIANTS, - stripFeatures, - type FeatureFlags, - type VariantSelections, -} from './strip-features.js'; -import { isValidPackageName, toValidPackageName } from './validate.js'; + promptProjectName, + resolveBoolean, + resolveFeatures, + resolvePackageManager, + resolveVariants, +} from './cli-prompts.js'; +import { resolveProjectTarget } from './resolve-target.js'; +import { scaffold, type CliOptions } from './scaffold.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -44,6 +30,10 @@ const TEMPLATE_DIR = path.resolve(__dirname, '..', 'template'); * Order matters for the interactive prompt: `platform` is asked FIRST so * that subsequent prompts can apply per-platform default/value overrides * declared in `PLATFORM_OVERRIDES` below. + * + * NOTE: `VARIANT_CHOICES` and `PLATFORM_OVERRIDES` are parsed *textually* + * from this file by `__tests__/cli-schema-parity.test.ts` — they must stay + * inline here as object literals (do not extract to a sibling module). */ const VARIANT_CHOICES = { platform: ['web', 'desktop', 'mobile'] as const, @@ -80,18 +70,7 @@ const VARIANT_CHOICES = { ] as const, ui: ['custom', 'shadcn', 'animate-ui'] as const, toastPosition: ['top-right', 'top-center', 'bottom-center', 'bottom-right'] as const, -} satisfies Record; - -type VariantAxis = keyof typeof VARIANT_CHOICES; -type PlatformValue = (typeof VARIANT_CHOICES)['platform'][number]; - -const VARIANT_FLAG_ALIASES: Record = { - platform: ['platform'], - design: ['design'], - layout: ['layout'], - ui: ['ui'], - toastPosition: ['toastPosition', 'toast-position'], -}; +} satisfies VariantChoices; /** * Per-platform narrowing of accepted values + default overrides. The @@ -104,15 +83,7 @@ const VARIANT_FLAG_ALIASES: Record = { * assert that synchronisation programmatically; for now the contract is * documented here and verified at e2e. */ -type PlatformOverride = { - readonly values?: readonly string[]; - readonly default?: string; -}; - -const PLATFORM_OVERRIDES: Record< - Exclude, - Partial> -> = { +const PLATFORM_OVERRIDES: PlatformOverrides = { layout: { // Web and desktop are restricted to the four desktop-shaped layouts — // the three mobile variants (mobile-drawer / bottom-tabs(-fab)) are @@ -137,34 +108,6 @@ const PLATFORM_OVERRIDES: Record< toastPosition: {}, }; -function getEffectiveValues( - axis: VariantAxis, - platform: PlatformValue -): readonly string[] { - if (axis === 'platform') return VARIANT_CHOICES.platform; - const override = PLATFORM_OVERRIDES[axis][platform]; - return override?.values ?? VARIANT_CHOICES[axis]; -} - -function getEffectiveDefault( - axis: VariantAxis, - platform: PlatformValue -): string { - if (axis === 'platform') return DEFAULT_VARIANTS.platform!; - const override = PLATFORM_OVERRIDES[axis][platform]; - return override?.default ?? DEFAULT_VARIANTS[axis]!; -} - -interface CliOptions { - targetDir: string; - projectName: string; - packageManager: 'pnpm' | 'npm' | 'bun'; - features: FeatureFlags; - variants: VariantSelections; - installDeps: boolean; - initGit: boolean; -} - async function run(rawArgv: string[]): Promise { const argv = parseArgs(rawArgv); @@ -210,7 +153,7 @@ async function run(rawArgv: string[]): Promise { } const features = await resolveFeatures(argv); - const variants = await resolveVariants(argv); + const variants = await resolveVariants(argv, VARIANT_CHOICES, PLATFORM_OVERRIDES); const packageManager = await resolvePackageManager(argv, variants); const wantInstall = await resolveBoolean( argv, @@ -235,7 +178,7 @@ async function run(rawArgv: string[]): Promise { initGit: wantGit, }; - await scaffold(opts); + await scaffold(opts, TEMPLATE_DIR); const cwdRel = path.relative(process.cwd(), opts.targetDir); const steps: string[] = []; @@ -256,422 +199,6 @@ async function run(rawArgv: string[]): Promise { outro(kleur.green('Done.') + ' Happy hacking!'); } -async function promptProjectName( - fromArgv: string | undefined, - yes: boolean -): Promise { - if (fromArgv) { - // `.` / `./` are passed through unchanged; resolveProjectTarget() turns - // them into "scaffold into cwd" with a derived package name. - if (isCwdShortcut(fromArgv)) return fromArgv; - if (isValidPackageName(fromArgv)) return fromArgv; - return toValidPackageName(fromArgv); - } - if (yes) return 'my-eikon-app'; - const name = await text({ - message: 'Project name (or "." to use the current directory):', - placeholder: 'my-eikon-app', - initialValue: 'my-eikon-app', - validate(value) { - if (!value) return 'Required.'; - if (isCwdShortcut(value)) return undefined; - if (!isValidPackageName(value)) - return 'Must be a valid npm package name (lowercase, dashes, no spaces).'; - return undefined; - }, - }); - if (isCancel(name)) { - cancel('Aborted.'); - process.exit(1); - } - return name; -} - -function isCwdShortcut(value: string): boolean { - return value === '.' || value === './'; -} - -async function resolveFeatures(argv: ParsedArgs): Promise { - const flags: FeatureFlags = { supabase: false }; - - if (argv.supabase !== undefined) flags.supabase = argv.supabase; - - if (!argv.yes && argv.supabase === undefined) { - const supabase = await select({ - message: 'Include Supabase (auth + db + storage) scaffolding?', - initialValue: false as boolean, - options: [ - { value: false, label: 'No — plain frontend only (recommended for new starters)' }, - { value: true, label: 'Yes — include @supabase/supabase-js + client scaffold' }, - ], - }); - if (isCancel(supabase)) { - cancel('Aborted.'); - process.exit(1); - } - flags.supabase = Boolean(supabase); - } - - return flags; -} - -async function resolveVariants(argv: ParsedArgs): Promise { - const out: VariantSelections = { ...DEFAULT_VARIANTS }; - // Resolve `platform` FIRST so subsequent axes can default / restrict - // their accepted values per the chosen target. Order is enforced by - // walking `Object.keys(VARIANT_CHOICES)` which lists `platform` first. - const axes = Object.keys(VARIANT_CHOICES) as VariantAxis[]; - for (const axis of axes) { - const platform = (out.platform ?? DEFAULT_VARIANTS.platform!) as PlatformValue; - const allowed = getEffectiveValues(axis, platform); - const effDefault = getEffectiveDefault(axis, platform); - - // Snap any pre-set value (from --yes default-walk OR previous axis - // resolution) into the per-platform allowed set. E.g. when the user - // passes `--yes --platform mobile`, layout's seeded `'stacked'` would - // be invalid for mobile and gets snapped to `'mobile-drawer'`. - if (!allowed.includes(out[axis] ?? '')) { - out[axis] = effDefault; - } - - const fromArgv = argv.variants?.[axis]; - if (fromArgv !== undefined) { - // Argv values bypass the prompt but still respect the per-platform - // narrowing — pass through only if the user-supplied value is valid - // for the chosen platform; otherwise keep the snapped default and - // surface a one-line warning. Silent ignore would hide bugs. - if (allowed.includes(fromArgv)) { - out[axis] = fromArgv; - } else { - log.warn( - `--${axis}=${kleur.yellow(fromArgv)} is not valid for platform=${platform}.` + - ` Using ${kleur.cyan(effDefault)} instead.` - ); - } - continue; - } - if (argv.yes) continue; - const choice = await select({ - message: `Choose a ${axis} variant:`, - initialValue: out[axis] as string, - options: allowed.map((value) => ({ - value, - label: value === effDefault ? `${value} (default)` : value, - })), - }); - if (isCancel(choice)) { - cancel('Aborted.'); - process.exit(1); - } - out[axis] = String(choice); - } - return out; -} - -async function resolvePackageManager( - argv: ParsedArgs, - variants: VariantSelections -): Promise<'pnpm' | 'npm' | 'bun'> { - // Desktop / mobile shells live in `apps/*` and are wired up via - // `pnpm-workspace.yaml` + `pnpm --filter` scripts in the root - // package.json. Neither npm nor bun has a drop-in equivalent for - // those filter calls, and rewriting the workspace topology to npm - // workspaces / bun workspaces is out of scope. Snap to pnpm with a - // visible warning so the user knows their `--pm` choice was overridden - // rather than silently ignored. - const platform = variants.platform; - const requiresPnpm = platform === 'desktop' || platform === 'mobile'; - - const snapIfNeeded = (chosen: 'pnpm' | 'npm' | 'bun'): 'pnpm' | 'npm' | 'bun' => { - if (requiresPnpm && chosen !== 'pnpm') { - log.warn( - `--pm=${kleur.yellow(chosen)} is not supported for platform=${kleur.yellow( - platform! - )} — apps/${platform}/ uses pnpm workspaces. Falling back to ${kleur.cyan( - 'pnpm' - )}.` - ); - return 'pnpm'; - } - return chosen; - }; - - if (argv.pm) return snapIfNeeded(argv.pm); - if (argv.yes) return 'pnpm'; - const pm = await select({ - message: 'Package manager:', - initialValue: 'pnpm' as 'pnpm' | 'npm' | 'bun', - options: [ - { value: 'pnpm', label: 'pnpm (recommended)' }, - { value: 'npm', label: 'npm' }, - { value: 'bun', label: 'bun' }, - ], - }); - if (isCancel(pm)) { - cancel('Aborted.'); - process.exit(1); - } - return snapIfNeeded(pm); -} - -async function resolveBoolean( - argv: ParsedArgs, - key: 'install' | 'git', - defaultValue: boolean, - message: string -): Promise { - const fromArgv = argv[key]; - if (fromArgv !== undefined) return fromArgv; - if (argv.yes) return defaultValue; - const answer = await confirm({ message, initialValue: defaultValue }); - if (isCancel(answer)) { - cancel('Aborted.'); - process.exit(1); - } - return Boolean(answer); -} - -async function scaffold(opts: CliOptions): Promise { - const s = spinner(); - - s.start('Copying template files'); - await mkdir(opts.targetDir, { recursive: true }); - await copyTemplate({ - src: TEMPLATE_DIR, - dest: opts.targetDir, - projectName: opts.projectName, - }); - s.stop('Template copied'); - - s.start('Applying feature selection'); - await stripFeatures(opts.targetDir, opts.features, opts.variants); - s.stop('Feature selection applied'); - - // Phase J: bake the chosen `--ui` library's components into the - // project. For `--ui custom` this is a no-op (the project keeps the - // self-authored Radix wrappers already in `src/shared/ui/`); for - // `--ui shadcn` / `--ui animate-ui` it swaps the seven replaceable - // primitives for the upstream-registry copies pre-baked under - // `template-snapshots//`, drops `components.json` at the project - // root, and merges the snapshot's `package-deps.json` into the - // project's `package.json`. - // - // Scaffold step order: - // stripFeatures → applyUiSnapshot → injectHtmlVariants → rewritePackageManagerFields - // - // - applyUiSnapshot runs AFTER stripFeatures so feature-strip doesn't - // fight the snapshot files we're about to drop in. - // - applyUiSnapshot runs BEFORE rewritePackageManagerFields so the - // deps merge happens on the same package.json the rewriter mutates. - // - The order between applyUiSnapshot and injectHtmlVariants is - // convention rather than necessity — applyUiSnapshot only touches - // `src/shared/ui/*.tsx`, `components.json`, and `package.json`, - // while injectHtmlVariants only edits `index.html`. The two are - // disjoint; if the snapshot ever starts rewriting `index.html` this - // ordering MUST be preserved (snapshot first). - await applyUiSnapshot(opts.targetDir, opts.variants.ui ?? DEFAULT_VARIANTS.ui!); - - // Phase I: stamp the picked design / layout onto `` so the - // first paint renders without a flash and the runtime initialisers in - // `src/main.tsx` + `src/app/LayoutVariantContext.tsx` resolve to the - // same value. `default` design collapses to no class/data attrs, - // while `data-layout` is always stamped as the layout Context's - // initial value. The `ui` axis is NOT mirrored here — it's a - // scaffold-time file swap (Phase J), not a runtime style. - await injectHtmlVariants(opts.targetDir, opts.variants); - - // Re-flavour `package.json` for the chosen package manager. No-op on - // pnpm (the template is already pnpm-flavoured); for npm/bun this - // rewrites `engines`, `packageManager`, and any `pnpm run` callsites - // in aggregate scripts (`check`, `ci`). Workspace-scoped scripts - // (`tauri:*`, `cap:*`) are pnpm-only — `resolvePackageManager` snaps - // `--pm` to pnpm on desktop/mobile so we never reach this with a - // non-pnpm PM on those platforms. - await rewritePackageManagerFields(opts.targetDir, opts.packageManager); - - if (opts.initGit) { - s.start('Initializing git repository'); - try { - const result = await initGit(opts.targetDir); - if (result.commitWarning) { - s.stop('Git repository initialized (no initial commit)'); - log.warn( - `git commit skipped: ${result.commitWarning}. ` + - `Set user.email / user.name and run \`git commit\` manually.` - ); - } else { - s.stop('Git repository initialized'); - } - } catch (err) { - s.stop('Skipping git init (git not available or already initialized)'); - log.warn(String(err)); - } - } - - if (opts.installDeps) { - s.start(`Installing dependencies with ${opts.packageManager}`); - try { - await installDeps(opts.targetDir, opts.packageManager); - s.stop('Dependencies installed'); - } catch (err) { - s.stop(kleur.red('Dependency install failed')); - log.error(String(err)); - log.info( - `You can retry manually: ${kleur.cyan( - `cd ${path.relative(process.cwd(), opts.targetDir)} && ${opts.packageManager} install` - )}` - ); - } - } -} - -interface ParsedArgs { - name?: string; - yes: boolean; - supabase?: boolean; - install?: boolean; - git?: boolean; - pm?: 'pnpm' | 'npm' | 'bun'; - variants?: Partial>; -} - -function parseArgs(argv: string[]): ParsedArgs { - const out: ParsedArgs = { yes: false }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]!; - if (a === '-y' || a === '--yes') out.yes = true; - else if (a === '--supabase') out.supabase = true; - else if (a === '--no-supabase') out.supabase = false; - else if (a === '--install') out.install = true; - else if (a === '--no-install') out.install = false; - else if (a === '--git') out.git = true; - else if (a === '--no-git') out.git = false; - else if (a === '--pm') { - const next = argv[++i]; - if (next === 'pnpm' || next === 'npm' || next === 'bun') out.pm = next; - } else if (a.startsWith('--pm=')) { - const v = a.slice('--pm='.length); - if (v === 'pnpm' || v === 'npm' || v === 'bun') out.pm = v; - } else if (tryParseVariant(a, argv, i, out)) { - // `tryParseVariant` consumes one or two argv positions and updates `out`. - if (a.indexOf('=') === -1) i += 1; - } else if (!a.startsWith('-') && out.name === undefined) { - out.name = a; - } else if (a.startsWith('-')) { - reportUnknownFlag(a); - } - } - return out; -} - -/** - * Every long-form flag the CLI recognises. Used to suggest a "did you - * mean" candidate when the user typo's a flag name. Variant flags come - * straight from `VARIANT_FLAG_ALIASES` so a future axis automatically - * lands in the suggestion set without a second touch-point here. - */ -function knownFlags(): readonly string[] { - const variantFlags: string[] = []; - for (const axis of Object.keys(VARIANT_FLAG_ALIASES) as VariantAxis[]) { - for (const alias of VARIANT_FLAG_ALIASES[axis]) { - variantFlags.push(`--${alias}`); - } - } - return [ - '--yes', - '-y', - '--supabase', - '--no-supabase', - '--install', - '--no-install', - '--git', - '--no-git', - '--pm', - ...variantFlags, - ]; -} - -/** - * Print a clear "Unknown flag: --x. Did you mean --y?" and exit non-zero. - * The previous behaviour silently ignored typos, which meant - * `create-eikon-react my-app --platfrom=mobile` ran as `web` with no - * indication anything had gone wrong — the user thought they'd asked - * for mobile and didn't notice until much later. - */ -function reportUnknownFlag(typed: string): never { - const flagOnly = typed.split('=')[0]!; - const candidates = knownFlags(); - let best: { flag: string; dist: number } | null = null; - for (const cand of candidates) { - const d = levenshtein(flagOnly, cand); - if (best === null || d < best.dist) best = { flag: cand, dist: d }; - } - console.error(kleur.red(`Unknown flag: ${flagOnly}`)); - if (best && best.dist <= Math.max(2, Math.floor(flagOnly.length / 3))) { - console.error(`Did you mean ${kleur.cyan(best.flag)}?`); - } - console.error(`Run ${kleur.cyan('create-eikon-react --help')} to see all flags.`); - process.exit(1); -} - -function levenshtein(a: string, b: string): number { - if (a === b) return 0; - if (a.length === 0) return b.length; - if (b.length === 0) return a.length; - const prev = new Array(b.length + 1); - const curr = new Array(b.length + 1); - for (let j = 0; j <= b.length; j++) prev[j] = j; - for (let i = 1; i <= a.length; i++) { - curr[0] = i; - for (let j = 1; j <= b.length; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - curr[j] = Math.min(curr[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost); - } - for (let j = 0; j <= b.length; j++) prev[j] = curr[j]!; - } - return prev[b.length]!; -} - -/** - * Parse one of the variant flags (`--design / --layout / --ui`) in either - * `--flag value` or `--flag=value` form. Returns true if the token belonged - * to a known variant axis (and was therefore consumed) regardless of - * whether the value was valid. - * - * Always records the typed value when one was supplied (skipping only - * tokens shaped like another flag, e.g. `--ui --design`), so - * `resolveVariants` can produce a single, descriptive warning naming the - * value, the axis, and the chosen default — visible in `--yes` mode - * where there's no interactive re-prompt to soften the failure. - */ -function tryParseVariant( - token: string, - argv: string[], - i: number, - out: ParsedArgs -): boolean { - const axes = Object.keys(VARIANT_CHOICES) as VariantAxis[]; - for (const axis of axes) { - for (const alias of VARIANT_FLAG_ALIASES[axis]) { - const longFlag = `--${alias}`; - let value: string | undefined; - if (token === longFlag) { - value = argv[i + 1]; - } else if (token.startsWith(`${longFlag}=`)) { - value = token.slice(longFlag.length + 1); - } else { - continue; - } - if (value !== undefined && !value.startsWith('-')) { - out.variants ??= {}; - out.variants[axis] = value; - } - return true; - } - } - return false; -} - run(process.argv.slice(2)).catch((err) => { console.error(kleur.red('\nUnexpected error:'), err); process.exit(1); diff --git a/packages/create-eikon-react/src/scaffold.ts b/packages/create-eikon-react/src/scaffold.ts new file mode 100644 index 0000000..fcd7134 --- /dev/null +++ b/packages/create-eikon-react/src/scaffold.ts @@ -0,0 +1,134 @@ +/** + * @file scaffold.ts + * @description The non-interactive build pipeline that turns a resolved + * set of `CliOptions` into a project on disk: copy template → strip + * features → bake the UI snapshot → stamp HTML variants → re-flavour + * package.json for the chosen PM → (optionally) git init + install deps. + * + * Internal sibling of `index.ts`. The entry file owns prompting and + * argv; this module owns side effects. `templateDir` is threaded in so + * this module stays independent of the entry file's path constants. + */ + +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; + +import { log, spinner } from '@clack/prompts'; +import kleur from 'kleur'; + +import { applyUiSnapshot } from './apply-ui-snapshot.js'; +import { copyTemplate } from './copy-template.js'; +import { initGit } from './init-git.js'; +import { injectHtmlVariants } from './inject-html-variants.js'; +import { installDeps } from './install-deps.js'; +import { rewritePackageManagerFields } from './rewrite-package-manager.js'; +import { + DEFAULT_VARIANTS, + stripFeatures, + type FeatureFlags, + type VariantSelections, +} from './strip-features.js'; + +export interface CliOptions { + targetDir: string; + projectName: string; + packageManager: 'pnpm' | 'npm' | 'bun'; + features: FeatureFlags; + variants: VariantSelections; + installDeps: boolean; + initGit: boolean; +} + +export async function scaffold(opts: CliOptions, templateDir: string): Promise { + const s = spinner(); + + s.start('Copying template files'); + await mkdir(opts.targetDir, { recursive: true }); + await copyTemplate({ + src: templateDir, + dest: opts.targetDir, + projectName: opts.projectName, + }); + s.stop('Template copied'); + + s.start('Applying feature selection'); + await stripFeatures(opts.targetDir, opts.features, opts.variants); + s.stop('Feature selection applied'); + + // Phase J: bake the chosen `--ui` library's components into the + // project. For `--ui custom` this is a no-op (the project keeps the + // self-authored Radix wrappers already in `src/shared/ui/`); for + // `--ui shadcn` / `--ui animate-ui` it swaps the seven replaceable + // primitives for the upstream-registry copies pre-baked under + // `template-snapshots//`, drops `components.json` at the project + // root, and merges the snapshot's `package-deps.json` into the + // project's `package.json`. + // + // Scaffold step order: + // stripFeatures → applyUiSnapshot → injectHtmlVariants → rewritePackageManagerFields + // + // - applyUiSnapshot runs AFTER stripFeatures so feature-strip doesn't + // fight the snapshot files we're about to drop in. + // - applyUiSnapshot runs BEFORE rewritePackageManagerFields so the + // deps merge happens on the same package.json the rewriter mutates. + // - The order between applyUiSnapshot and injectHtmlVariants is + // convention rather than necessity — applyUiSnapshot only touches + // `src/shared/ui/*.tsx`, `components.json`, and `package.json`, + // while injectHtmlVariants only edits `index.html`. The two are + // disjoint; if the snapshot ever starts rewriting `index.html` this + // ordering MUST be preserved (snapshot first). + await applyUiSnapshot(opts.targetDir, opts.variants.ui ?? DEFAULT_VARIANTS.ui!); + + // Phase I: stamp the picked design / layout onto `` so the + // first paint renders without a flash and the runtime initialisers in + // `src/main.tsx` + `src/app/LayoutVariantContext.tsx` resolve to the + // same value. `default` design collapses to no class/data attrs, + // while `data-layout` is always stamped as the layout Context's + // initial value. The `ui` axis is NOT mirrored here — it's a + // scaffold-time file swap (Phase J), not a runtime style. + await injectHtmlVariants(opts.targetDir, opts.variants); + + // Re-flavour `package.json` for the chosen package manager. No-op on + // pnpm (the template is already pnpm-flavoured); for npm/bun this + // rewrites `engines`, `packageManager`, and any `pnpm run` callsites + // in aggregate scripts (`check`, `ci`). Workspace-scoped scripts + // (`tauri:*`, `cap:*`) are pnpm-only — `resolvePackageManager` snaps + // `--pm` to pnpm on desktop/mobile so we never reach this with a + // non-pnpm PM on those platforms. + await rewritePackageManagerFields(opts.targetDir, opts.packageManager); + + if (opts.initGit) { + s.start('Initializing git repository'); + try { + const result = await initGit(opts.targetDir); + if (result.commitWarning) { + s.stop('Git repository initialized (no initial commit)'); + log.warn( + `git commit skipped: ${result.commitWarning}. ` + + `Set user.email / user.name and run \`git commit\` manually.` + ); + } else { + s.stop('Git repository initialized'); + } + } catch (err) { + s.stop('Skipping git init (git not available or already initialized)'); + log.warn(String(err)); + } + } + + if (opts.installDeps) { + s.start(`Installing dependencies with ${opts.packageManager}`); + try { + await installDeps(opts.targetDir, opts.packageManager); + s.stop('Dependencies installed'); + } catch (err) { + s.stop(kleur.red('Dependency install failed')); + log.error(String(err)); + log.info( + `You can retry manually: ${kleur.cyan( + `cd ${path.relative(process.cwd(), opts.targetDir)} && ${opts.packageManager} install` + )}` + ); + } + } +} diff --git a/packages/create-eikon-react/template/.agent/rules/00-architecture.md b/packages/create-eikon-react/template/.agent/rules/00-architecture.md index 9c8dd97..d7e7a52 100644 --- a/packages/create-eikon-react/template/.agent/rules/00-architecture.md +++ b/packages/create-eikon-react/template/.agent/rules/00-architecture.md @@ -111,3 +111,16 @@ enforced as private by `eslint-plugin-import`. - The piece is glue between many features (auth context, analytics) → `src/shared/` (potentially a `shared//`). When in doubt, prefer creating a feature; "promote to shared" is a cheap later refactor, but "split a feature back apart" is expensive. + +## Prohibitions (grep-verifiable) + +Concrete `❌` rules, each with a backticked pattern you can `rg` over `src/` and +an index (`PR-NNN`) so a regressing PR can name the rule it broke. These restate +the import-boundary section above in enforceable form; the executable fence is +[`__tests__/structure/boundary-imports.test.ts`](../../__tests__/structure/boundary-imports.test.ts) +(ripgrep needs `-P` for the look-ahead patterns). + +- ❌ PR-001: `from '@/features//'` — cross-feature imports must target the barrel `@/features/` or `@/features//routes`, never a deep path. rg: `from ['"]@/features/[^'"]+/(?!index|routes)['"]` +- ❌ PR-002: `@/features/*` imported from `src/shared/**` — `shared/` is leaf-level and must not depend on features. rg (inside `src/shared/`): `from ['"]@/features/` +- ❌ PR-003: `@/features//components/...` from `src/app/**` — the app shell consumes a feature only via its barrel or `routes`. rg (inside `src/app/`): `from ['"]@/features/[^'"]+/(?!index|routes)` +- ❌ PR-004: `from '@/styles/...'` anywhere but `src/main.tsx` — styles are a side-effect import owned solely by the entrypoint. rg: `from ['"]@/styles/` diff --git a/packages/create-eikon-react/template/.agent/rules/40-state-management.md b/packages/create-eikon-react/template/.agent/rules/40-state-management.md index e73cb15..d0112cd 100644 --- a/packages/create-eikon-react/template/.agent/rules/40-state-management.md +++ b/packages/create-eikon-react/template/.agent/rules/40-state-management.md @@ -146,3 +146,13 @@ The store is exported only so tests and devtools can poke `getState()`. - Don't sync server data into a Zustand store **as a cache layer over TanStack Query**. If TanStack Query owns the fetching, let it own the caching too. The Swappable-backends pattern is a different shape: the store IS the cache because the service layer is direct (no Query in front), and the store and service are co-designed. - Don't store form state in Zustand. Use React Hook Form locally, or `useState` for trivial forms. - Don't introduce a fourth flavour of state. The three primitives (`useState`, Zustand store, TanStack Query) cover every case in this codebase. If you reach for something else (event emitter, observable, context-as-state, …), describe the problem in the PR and we'll fold the right primitive's escape hatch into this rule instead. + +## Prohibitions (grep-verifiable) + +Concrete `❌` rules with a backticked, `rg`-able pattern and a `PR-NNN` index so a +regressing PR can name the rule it broke. These restate the "Don't" section above +in enforceable form. + +- ❌ PR-010: `from 'redux' | '@reduxjs/toolkit' | 'mobx' | 'jotai' | 'recoil'` — the sanctioned primitives are `useState`/`useReducer`, Zustand, and TanStack Query; no fourth state library. rg: `from ['"](redux|@reduxjs/toolkit|mobx|mobx-react|jotai|recoil)['"]` +- ❌ PR-011: `from 'axios'` (or other HTTP clients) — server data flows through `fetch` wrapped in TanStack Query or the feature service layer. rg: `from ['"](axios|got|ky|superagent)['"]` +- ❌ PR-012: a feature **component/page** importing its own `store/` directly — components see only the `selectors/` barrel and the service facade; the store is exported for tests/devtools only. rg (inside `features/*/components` and `features/*/pages`): `from ['"][^'"]*/store/` diff --git a/packages/create-eikon-react/template/.agent/rules/50-ui-axis.md b/packages/create-eikon-react/template/.agent/rules/50-ui-axis.md index f7525f3..69a17e1 100644 --- a/packages/create-eikon-react/template/.agent/rules/50-ui-axis.md +++ b/packages/create-eikon-react/template/.agent/rules/50-ui-axis.md @@ -150,3 +150,13 @@ Use a plain Tailwind utility (`transition-colors`, `transition-transform`, `dura - Don't add a parallel "design system" folder. Everything ui lives under `src/shared/ui/`. - Don't render `motion.*` components inside loops without keys — `` requires stable keys. - Don't mix libraries — once `--ui` is chosen, keep all primitives under that library. Mixing shadcn + animate-ui in the same project means two motion philosophies fighting each other. + +## Prohibitions (grep-verifiable) + +Concrete `❌` rules with a backticked, `rg`-able pattern and a `PR-NNN` index so a +regressing PR can name the rule it broke. These restate the "Don't" section above +in enforceable form. + +- ❌ PR-020: `from 'framer-motion'` — use `motion` (the `motion/react` entrypoint) only. rg: `from ['"]framer-motion['"]` +- ❌ PR-021: `from 'react-spring' | '@react-spring/*' | 'gsap'` — a single animation library (`motion`). rg: `from ['"](react-spring|@react-spring/web|gsap)['"]` +- ❌ PR-022: `from 'react-hot-toast' | 'react-toastify' | 'notistack'` — toasts go through `@/shared/ui/toaster` (Sonner). rg: `from ['"](react-hot-toast|react-toastify|notistack)['"]` diff --git a/packages/preview-site/.agent/rules/00-structure.md b/packages/preview-site/.agent/rules/00-structure.md new file mode 100644 index 0000000..c7326ca --- /dev/null +++ b/packages/preview-site/.agent/rules/00-structure.md @@ -0,0 +1,93 @@ +--- +id: structure +title: preview-site structure & boundaries +description: Top-level module boundaries (shell / landing / server / lib), per-file size budget, and the dynamic-import rule that keeps route loaders off the static Nav edge. +applies_to: ["src/**", "server/**"] +severity: must +--- + +# Structure + +`@eikon/preview` (the playground) is **not** shipped to end users — it is a UX +layer over `template-react` and the strip engine. It splits into four +top-level modules, each with a distinct cost profile and lifecycle: + +``` +packages/preview-site/ +├── server/ # Node side: strip engine, Vite builder, watcher, metrics. +│ # Runs in Node only. NEVER imported by browser code. +├── src/ +│ ├── shell/ # The playground app itself: