From 47def70604191a4ee36ce789ecb0cb1a61d99f1c Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Mon, 27 Apr 2026 21:37:36 +0300 Subject: [PATCH 01/11] feat(common): add oz-ui create with recipes, matrix docs, and changeset Scaffold Vite + React + TypeScript apps with presets, feature flags, and normalized CreateAppSpec. Document CLI matrix, recipes, and link from READMEs. --- .changeset/bright-lions-create.md | 5 + README.md | 2 + packages/cli/README.md | 59 +- packages/cli/docs/CREATE_MATRIX.md | 85 ++ packages/cli/docs/CREATE_RECIPES.md | 84 ++ packages/cli/package.json | 2 +- packages/cli/src/commands/create.test.ts | 307 ++++++++ packages/cli/src/commands/create.ts | 141 ++++ packages/cli/src/create/interactive.ts | 89 +++ packages/cli/src/create/options.ts | 144 ++++ packages/cli/src/create/recipes.ts | 108 +++ packages/cli/src/create/scaffold.ts | 100 +++ packages/cli/src/create/templates.ts | 963 +++++++++++++++++++++++ packages/cli/src/create/types.ts | 99 +++ packages/cli/src/index.ts | 2 + 15 files changed, 2187 insertions(+), 3 deletions(-) create mode 100644 .changeset/bright-lions-create.md create mode 100644 packages/cli/docs/CREATE_MATRIX.md create mode 100644 packages/cli/docs/CREATE_RECIPES.md create mode 100644 packages/cli/src/commands/create.test.ts create mode 100644 packages/cli/src/commands/create.ts create mode 100644 packages/cli/src/create/interactive.ts create mode 100644 packages/cli/src/create/options.ts create mode 100644 packages/cli/src/create/recipes.ts create mode 100644 packages/cli/src/create/scaffold.ts create mode 100644 packages/cli/src/create/templates.ts create mode 100644 packages/cli/src/create/types.ts diff --git a/.changeset/bright-lions-create.md b/.changeset/bright-lions-create.md new file mode 100644 index 0000000..c0cbe8b --- /dev/null +++ b/.changeset/bright-lions-create.md @@ -0,0 +1,5 @@ +--- +"@openzeppelin/ui-cli": patch +--- + +Add `oz-ui create` for scaffolding Vite + React + TypeScript OpenZeppelin UI apps with preset layouts, EVM wallet wiring, and agent-friendly JSON output. diff --git a/README.md b/README.md index 12861e9..b04008f 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,8 @@ This project uses [GitHub Speckit](https://github.com/github/spec-kit) for spec- - [Migration Guide](./docs/MIGRATION.md) - Migrate from `@openzeppelin/ui-builder-`* packages - [LLM-led migration reference](./packages/cli/docs/LLM_MIGRATION_REFERENCE.md) - AI-assisted workflow for `@openzeppelin/ui-cli` (optional) +- [CLI create matrix](./packages/cli/docs/CREATE_MATRIX.md) - Presets, options, features, generated files, and supported combinations for `oz-ui create` +- [CLI create recipes](./packages/cli/docs/CREATE_RECIPES.md) - Architecture for `oz-ui create` presets, layouts, and feature combinations - [DevOps Setup Guide](./docs/DEVOPS_SETUP.md) - CI/CD secrets and GitHub App configuration - [Operations Runbook](./docs/RUNBOOK.md) - Release management and incident procedures diff --git a/packages/cli/README.md b/packages/cli/README.md index 8159afa..8c37134 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -26,21 +26,76 @@ The `oz-ui` binary is organized by **command group** (e.g. `oz-ui …`). | Group | Description | | --------- | ----------------------------------------------------------------------------------- | +| `create` | Scaffold a Vite + React + TypeScript app with selectable OpenZeppelin UI wiring. | | `migrate` | Move an existing React app onto the OpenZeppelin UI Kit (manifest-driven workflow). | +### `create` + +`oz-ui create` generates a Vite + React + TypeScript app with OpenZeppelin UI styles and optional runtime, wallet, routing, sidebar, and wizard wiring. Human mode uses a guided prompt flow; agents and CI should pass flags plus `--yes --json`. + +The create internals use normalized layout/content recipes so feature combinations such as `--preset wizard --with sidebar` keep the wizard as the primary content while changing only the surrounding shell. See the [create recipes architecture](./docs/CREATE_RECIPES.md). + + +| Preset | Description | +| ----------- | ----------------------------------------------------------------------------------------------------------------- | +| `minimal` | Vite + React + TypeScript, Tailwind v4, OZ styles/components, small landing page, no wallet/runtime/router. | +| `dapp` | Default. Adds `RuntimeProvider`, `WalletStateProvider`, EVM runtime, custom wallet UI, app config, and status UI. | +| `app-shell` | Adds React Router, sidebar/header/footer shell, and placeholder routes. Sidebar implies router. | +| `wizard` | Adds a generic multi-step wizard shell for guided workflows. | + + +Key options: + + +| Option | Description | +| ---------------------- | --------------------------------------------------------------------------------------------- | +| `--preset ` | `minimal`, `dapp`, `app-shell`, or `wizard`. Defaults to `dapp`. | +| `--wallet ` | `custom`, `rainbowkit`, or `none`. Defaults to `custom` except for `minimal`. | +| `--ecosystem ` | Target ecosystem. V1 supports `evm`. | +| `--routing ` | `none` or `react-router`. `app-shell` / `sidebar` scaffolds require `react-router`. | +| `--with ` | Comma-separated features to include, such as `router,sidebar,theme,toasts,status-panel`. | +| `--without ` | Comma-separated features to exclude, such as `wallet` or `toasts`. Contradictions are errors. | +| `--skip-install` | Generate files without running the package manager install command. | +| `--force` | Overwrite generated files in a non-empty target directory. | +| `--yes` | Use resolved defaults and skip interactive prompts. | +| `--json` | Emit a machine-readable payload with `action: "create"`. | + + +Examples: + +```bash +oz-ui create my-app +oz-ui create my-app --preset minimal +oz-ui create my-app --preset app-shell +oz-ui create my-app --wallet rainbowkit +oz-ui create my-app --preset dapp --ecosystem evm --wallet custom --json --yes --skip-install +``` + +Generated apps keep OpenZeppelin-specific integration code in `src/oz/` so it is easy to inspect and extend: + +- `src/oz/OzProviders.tsx` wires `RuntimeProvider` and `WalletStateProvider` +- `src/oz/runtime.ts` owns adapter/runtime and network resolution +- `src/oz/config.ts` initializes `appConfigService` +- `public/app.config.json` stores runtime service defaults + +Create documentation: + +- [Create generation matrix](./docs/CREATE_MATRIX.md) - User-facing matrix of presets, options, features, generated files, and combinations. +- [Create recipes architecture](./docs/CREATE_RECIPES.md) - How `oz-ui create` resolves presets, layouts, content, and feature combinations. + ### `migrate` > **Experimental** — Migration features are under active development. Do not expect perfect automation on every project. The workflow uses deterministic `oz-ui migrate` commands and has been exercised on small- to medium-sized apps; it should still beat a fully manual migration. For the full disclaimer and an **LLM-assisted** flow (prompts, manifest workflow, skills), see the [LLM-led migration reference](./docs/LLM_MIGRATION_REFERENCE.md) ([on GitHub](https://github.com/OpenZeppelin/openzeppelin-ui/blob/main/packages/cli/docs/LLM_MIGRATION_REFERENCE.md)). -All `oz-ui migrate `* subcommands support `--json` and return machine-readable payloads with an `action` field plus command-specific data. Invoke each row as `oz-ui migrate ` (or `npx` / `pnpm exec` as needed). +All `oz-ui migrate` * subcommands support `--json` and return machine-readable payloads with an `action` field plus command-specific data. Invoke each row as `oz-ui migrate ` (or `npx` / `pnpm exec` as needed). | Subcommand | Description | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `init` | Initialize migration scaffolding: OZ packages, provider templates, Tailwind, and assistant assets. Requires `--agent-profile` to choose where skills/agents are copied (for example `standard,claude` → `.agents/skills` + `.claude/skills`, with agents under `.cursor` and `.claude`). | | `analyze` | Scan the project for UI components from third-party libraries (shadcn/ui, Radix, MUI, Chakra, Ant Design) and raw HTML elements, then map them to OpenZeppelin UI Kit equivalents. | -| `plan` | Generate a step-by-step migration plan based on the analysis report. Uses the same assistant profiles as the initial `migrate init` (read from the manifest; no `--agent-profile` flag). | +| `plan` | Generate a step-by-step migration plan based on the analysis report. Uses the same assistant profiles as the initial `migrate init` (read from the manifest; no `--agent-profile` flag). | | `execute` | Execute the next dependency-safe migration task. Deterministic setup and component tasks are applied automatically; manual-review tasks return structured instructions instead of silently editing the manifest. | | `complete` | Mark a manually executed task as completed. By default this validates the task first with the same structural checks used by `migrate doctor`. | | `fail` | Mark a task as failed and record a blocker reason in the manifest so work can be resumed cleanly later. | diff --git a/packages/cli/docs/CREATE_MATRIX.md b/packages/cli/docs/CREATE_MATRIX.md new file mode 100644 index 0000000..4384d80 --- /dev/null +++ b/packages/cli/docs/CREATE_MATRIX.md @@ -0,0 +1,85 @@ +# `oz-ui create` Matrix + +This document describes what `oz-ui create` can generate today. For the implementation model behind these combinations, see [Create recipes architecture](./CREATE_RECIPES.md). + +## Help + +Use the command help to see the current flag surface: + +```bash +oz-ui create --help +``` + +## Presets + +| Preset | Default Wallet | Routing | Default Features | Generated Shape | +| --- | --- | --- | --- | --- | +| `minimal` | `none` | `none` | `theme`, `toasts`, `tooltips` | Plain centered starter page with OpenZeppelin UI styles and components. | +| `dapp` | `custom` | `none` | `wallet`, `theme`, `toasts`, `tooltips`, `status-panel` | Top-bar dApp starter with wallet runtime wiring and status panel. | +| `app-shell` | `custom` | `react-router` | `wallet`, `router`, `sidebar`, `theme`, `toasts`, `tooltips`, `status-panel` | Sidebar/header/footer app shell with placeholder routes. | +| `wizard` | `custom` | `none` | `wallet`, `theme`, `toasts`, `tooltips`, `wizard`, `status-panel` | Top-bar guided workflow starter using `WizardLayout`. | + +The default preset is `dapp`. + +## Options + +| Option | Values | Effect | +| --- | --- | --- | +| `--preset ` | `minimal`, `dapp`, `app-shell`, `wizard` | Selects the default feature set and primary generated content. | +| `--ecosystem ` | `evm` | Selects adapter ecosystem support. V1 supports EVM only. | +| `--wallet ` | `none`, `custom`, `rainbowkit` | Selects wallet UI/runtime wiring. Non-`none` values enable `wallet`. | +| `--routing ` | `none`, `react-router` | Selects route support. `sidebar` and `app-shell` require `react-router`. | +| `--with ` | comma-separated feature list | Adds features on top of the preset defaults. | +| `--without ` | comma-separated feature list | Removes features from preset defaults. Contradictions are rejected. | +| `--package-manager ` | `npm`, `pnpm`, `yarn` | Selects dependency install command and next-step output. | +| `--skip-install` | boolean flag | Writes files without installing dependencies. | +| `--force` | boolean flag | Allows generated files to be overwritten in a non-empty target directory. | +| `--yes` | boolean flag | Uses defaults and skips interactive prompts. | +| `--json` | boolean flag | Emits machine-readable command output. | + +## Features + +| Feature | Adds | +| --- | --- | +| `wallet` | `src/oz` runtime/provider files, `public/app.config.json`, wallet dependencies, wallet UI in generated app chrome. | +| `router` | `react-router-dom` dependency and route-aware app wiring where a router layout is selected. | +| `sidebar` | `SidebarLayout`, sidebar navigation, logo asset, and route-aware shell layout. Also implies `router`. | +| `theme` | `next-themes` dependency and `ThemeProvider` with light theme as the default. | +| `toasts` | `Toaster` from `@openzeppelin/ui-components`. | +| `tooltips` | `TooltipProvider` wrapping the generated app. | +| `wizard` | Wizard content using `WizardLayout`. The `wizard` preset always keeps this enabled. | +| `status-panel` | `src/components/RuntimeStatus.tsx` and runtime status placeholder UI. | + +## Generated Files + +| Condition | Additional Files | +| --- | --- | +| Always | `package.json`, `index.html`, `tsconfig.json`, `vite.config.ts`, `src/main.tsx`, `src/App.tsx`, `src/index.css`, `src/vite-env.d.ts` | +| `wallet` | `public/app.config.json`, `src/oz/config.ts`, `src/oz/runtime.ts`, `src/oz/OzProviders.tsx` | +| logo asset required | `public/OZ-Logo-BlackBG.svg` | +| `status-panel` | `src/components/RuntimeStatus.tsx` | +| `--wallet rainbowkit` | `src/oz/wallet/rainbowkit.config.ts` | + +The logo asset is generated for top-bar and sidebar layouts. The `minimal` preset does not include it by default. + +## Layout And Content Matrix + +| Command Shape | Layout | Content | Notes | +| --- | --- | --- | --- | +| `--preset minimal` | `plain` | `landing` | Small starter page without wallet/runtime files. | +| `--preset dapp` | `topbar` | `dapp-dashboard` | Default generated app. | +| `--preset app-shell` | `sidebar-shell` | `dapp-dashboard` | Full app shell with multiple placeholder routes. | +| `--preset wizard` | `topbar` | `wizard` | Wizard is the primary content. | +| `--preset dapp --with sidebar` | `sidebar-shell` | `dapp-dashboard` | Sidebar changes the shell and implies routing. | +| `--preset wizard --with sidebar` | `sidebar-shell` | `wizard` | Sidebar wraps the wizard; it does not replace wizard content with the shell dashboard. | +| `--preset minimal --with wallet` | `plain` | `landing` | Keeps the minimal page but adds wallet/runtime wiring. | +| `--preset minimal --with sidebar` | `sidebar-shell` | `landing` | Sidebar implies router; use this only when a routed frame is desired around minimal content. | + +## Validation Rules + +- A feature cannot appear in both `--with` and `--without`. +- `--without wallet` cannot be combined with `--wallet custom` or `--wallet rainbowkit`. +- `sidebar` implies `router`. +- `app-shell` implies `router`. +- `wizard` preset always includes `wizard`. +- `--routing none` is rejected if the resolved scaffold includes `router` or `sidebar`. diff --git a/packages/cli/docs/CREATE_RECIPES.md b/packages/cli/docs/CREATE_RECIPES.md new file mode 100644 index 0000000..ca7833a --- /dev/null +++ b/packages/cli/docs/CREATE_RECIPES.md @@ -0,0 +1,84 @@ +# `oz-ui create` Recipes + +The `create` command resolves user input in two stages: + +1. `resolveCreateOptions` validates CLI flags and expands feature implications. +2. `resolveCreateAppSpec` turns the resolved options into a normalized app recipe. + +This keeps preset behavior predictable while avoiding one-off branching for every preset and feature combination. + +## Core Model + +The generated app is described by `CreateAppSpec`: + +- `layout`: the outer application frame. Current values are `plain`, `topbar`, and `sidebar-shell`. +- `content`: the primary generated experience. Current values are `landing`, `dapp-dashboard`, and `wizard`. +- feature booleans: `hasWallet`, `hasRouter`, `hasTooltips`, `hasStatusPanel`, and related flags derived once from resolved features. +- assets and navigation: `requiresLogoAsset` and sidebar navigation metadata. + +Templates should render from this spec instead of re-checking raw CLI flags. For example, `--preset wizard --with sidebar` becomes: + +```ts +{ + layout: 'sidebar-shell', + content: 'wizard', + hasSidebar: true, + hasWizard: true, + hasRouter: true +} +``` + +That means the wizard remains the primary content, and the sidebar is only the navigation frame. + +## Preset Mapping + + +| Preset | Default Layout | Default Content | Notes | +| ----------- | --------------- | ---------------- | ------------------------------------------------- | +| `minimal` | `plain` | `landing` | No wallet wiring and no logo asset by default. | +| `dapp` | `topbar` | `dapp-dashboard` | Wallet-enabled starter with runtime status. | +| `app-shell` | `sidebar-shell` | `dapp-dashboard` | Route-aware shell with richer sidebar navigation. | +| `wizard` | `topbar` | `wizard` | Guided workflow starter. | + + +Feature flags can change the layout without changing the content. The important rule is that content wins over layout: a wizard with a sidebar is still a wizard app. + +## Adding A Layout + +Add a new layout when the outer frame changes substantially, such as split panes, command palette shell, or embedded mode. + +1. Extend `CreateLayout` in `src/create/types.ts`. +2. Update `resolveLayout` in `src/create/recipes.ts`. +3. Add or update the renderer selection in `src/create/templates.ts`. +4. Add tests that assert the normalized recipe and generated `App.tsx` shape. + +Keep layout renderers responsible for frame concerns: header, sidebar, footer, route containers, and scroll boundaries. + +## Adding Content + +Add a new content type when the primary app experience changes, such as a table workspace, contract explorer, or dashboard variant. + +1. Extend `CreateContent` in `src/create/types.ts`. +2. Update `resolveContent` in `src/create/recipes.ts`. +3. Add the content renderer or compose it into an existing layout renderer. +4. Add tests for at least one standalone layout and one combined layout if the content supports both. + +Keep content renderers responsible for product placeholders, cards, wizard steps, forms, and domain examples. + +## Template Rules + +- Prefer reading `CreateAppSpec` booleans over repeating `options.features.includes(...)`. +- Keep CLI validation and feature implication in `options.ts`. +- Keep app-shape decisions in `recipes.ts`. +- Keep generated file contents in `templates.ts`. +- Add direct dependencies whenever generated code or upstream packages require them under pnpm strict resolution. +- Preserve Tailwind `@source` coverage for OpenZeppelin packages when generated UI imports package-provided components or wallet UI. + +## Test Strategy + +For each new recipe combination, test both layers: + +- recipe tests: assert `layout`, `content`, and relevant feature booleans. +- scaffold tests: assert generated files and key code markers. + +This catches both decision bugs, such as choosing the wrong layout, and template bugs, such as omitting imports or assets. \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json index 64aaca5..91ebd38 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -5,7 +5,7 @@ "description": "Consumer CLI for scaffolding, migrating, and managing OpenZeppelin UI applications.", "type": "module", "bin": { - "oz-ui": "dist/index.mjs" + "oz-ui": "dist/index.cjs" }, "main": "dist/index.cjs", "module": "dist/index.mjs", diff --git a/packages/cli/src/commands/create.test.ts b/packages/cli/src/commands/create.test.ts new file mode 100644 index 0000000..44fedbb --- /dev/null +++ b/packages/cli/src/commands/create.test.ts @@ -0,0 +1,307 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveCreateOptions } from '../create/options'; +import { resolveCreateAppSpec } from '../create/recipes'; +import { scaffoldProject } from '../create/scaffold'; +import { registerCreateCommand } from './create'; + +const temporaryDirectories: string[] = []; + +function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oz-ui-create-')); + temporaryDirectories.push(dir); + return dir; +} + +function readFile(filePath: string): string { + return fs.readFileSync(filePath, 'utf8'); +} + +async function captureStdout(fn: () => Promise | void): Promise { + const chunks: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); + return true; + }) as typeof process.stdout.write; + + try { + await fn(); + } finally { + process.stdout.write = originalWrite; + } + + return chunks.join(''); +} + +async function runCreate(args: string[]): Promise { + const root = new Command(); + registerCreateCommand(root); + return captureStdout(async () => { + await root.parseAsync(['create', ...args], { from: 'user' }); + }); +} + +afterEach(() => { + process.exitCode = 0; + for (const dir of temporaryDirectories.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('create command', () => { + it('creates the default dapp scaffold with JSON output', async () => { + const dir = createTempDir(); + const stdout = await runCreate([ + 'demo-app', + '--directory', + dir, + '--skip-install', + '--yes', + '--json', + ]); + const payload = JSON.parse(stdout); + const projectRoot = path.join(dir, 'demo-app'); + + expect(payload.action).toBe('create'); + expect(payload.ok).toBe(true); + expect(payload.preset).toBe('dapp'); + expect(payload.wallet).toBe('custom'); + expect(payload.routing).toBe('none'); + expect(payload.features).toContain('wallet'); + expect(payload.filesWritten).toContain('src/oz/OzProviders.tsx'); + expect(fs.existsSync(path.join(projectRoot, 'src', 'oz', 'runtime.ts'))).toBe(true); + expect(readFile(path.join(projectRoot, 'package.json'))).toContain('@wagmi/core'); + expect(readFile(path.join(projectRoot, 'package.json'))).toContain('@openzeppelin/ui-cli'); + expect(readFile(path.join(projectRoot, 'package.json'))).toContain('"oz-ui": "oz-ui"'); + expect(readFile(path.join(projectRoot, 'src', 'index.css'))).toContain( + "@source '../node_modules/@openzeppelin';" + ); + expect(readFile(path.join(projectRoot, 'src', 'App.tsx'))).toContain('RuntimeStatus'); + }); + + it('creates each preset with the expected shape', async () => { + const dir = createTempDir(); + + await runCreate([ + 'minimal-app', + '--directory', + dir, + '--preset', + 'minimal', + '--skip-install', + '--yes', + '--json', + ]); + await runCreate([ + 'shell-app', + '--directory', + dir, + '--preset', + 'app-shell', + '--skip-install', + '--yes', + '--json', + ]); + await runCreate([ + 'wizard-app', + '--directory', + dir, + '--preset', + 'wizard', + '--skip-install', + '--yes', + '--json', + ]); + + expect(fs.existsSync(path.join(dir, 'minimal-app', 'src', 'oz'))).toBe(false); + expect(readFile(path.join(dir, 'shell-app', 'src', 'App.tsx'))).toContain('BrowserRouter'); + expect(readFile(path.join(dir, 'shell-app', 'src', 'App.tsx'))).toContain( + 'OZ-Logo-BlackBG.svg' + ); + expect(readFile(path.join(dir, 'shell-app', 'src', 'App.tsx'))).toContain( + '' + ); + expect(readFile(path.join(dir, 'shell-app', 'src', 'App.tsx'))).toContain( + '
' + ); + expect(fs.existsSync(path.join(dir, 'shell-app', 'public', 'OZ-Logo-BlackBG.svg'))).toBe(true); + expect(fs.existsSync(path.join(dir, 'minimal-app', 'public', 'OZ-Logo-BlackBG.svg'))).toBe( + false + ); + expect(readFile(path.join(dir, 'wizard-app', 'src', 'App.tsx'))).toContain('WizardLayout'); + expect(readFile(path.join(dir, 'wizard-app', 'src', 'App.tsx'))).toContain("title: 'Intro'"); + expect(readFile(path.join(dir, 'minimal-app', 'src', 'App.tsx'))).not.toContain('' + ); + expect(readFile(path.join(dir, 'wizard-app', 'src', 'App.tsx'))).toContain( + 'variant="vertical"' + ); + expect(readFile(path.join(dir, 'wizard-app', 'src', 'App.tsx'))).toContain( + 'className="flex h-16 w-full items-center gap-4 px-3 sm:px-4 md:px-5"' + ); + expect(fs.existsSync(path.join(dir, 'wizard-app', 'public', 'OZ-Logo-BlackBG.svg'))).toBe(true); + }); + + it('auto-adds router when sidebar is requested', async () => { + const dir = createTempDir(); + const stdout = await runCreate([ + 'sidebar-app', + '--directory', + dir, + '--preset', + 'dapp', + '--with', + 'sidebar', + '--skip-install', + '--yes', + '--json', + ]); + const payload = JSON.parse(stdout); + + expect(payload.routing).toBe('react-router'); + expect(payload.features).toContain('router'); + expect(payload.impliedFeatures.router).toContain('sidebar'); + }); + + it('normalizes presets and feature combinations into app recipes', () => { + const dapp = resolveCreateAppSpec( + resolveCreateOptions({ projectName: 'dapp-recipe', targetDirectory: createTempDir() }) + ); + const shell = resolveCreateAppSpec( + resolveCreateOptions({ + projectName: 'shell-recipe', + targetDirectory: createTempDir(), + preset: 'app-shell', + }) + ); + const wizardSidebar = resolveCreateAppSpec( + resolveCreateOptions({ + projectName: 'wizard-sidebar-recipe', + targetDirectory: createTempDir(), + preset: 'wizard', + withFeatures: ['sidebar'], + }) + ); + + expect(dapp.layout).toBe('topbar'); + expect(dapp.content).toBe('dapp-dashboard'); + expect(dapp.hasWallet).toBe(true); + expect(shell.layout).toBe('sidebar-shell'); + expect(shell.content).toBe('dapp-dashboard'); + expect(shell.hasRouter).toBe(true); + expect(wizardSidebar.layout).toBe('sidebar-shell'); + expect(wizardSidebar.content).toBe('wizard'); + expect(wizardSidebar.hasWizard).toBe(true); + expect(wizardSidebar.requiresLogoAsset).toBe(true); + }); + + it('keeps wizard content when sidebar is added to the wizard preset', async () => { + const dir = createTempDir(); + const stdout = await runCreate([ + 'wizard-sidebar-app', + '--directory', + dir, + '--preset', + 'wizard', + '--with', + 'sidebar', + '--skip-install', + '--yes', + '--json', + ]); + const payload = JSON.parse(stdout); + const app = readFile(path.join(dir, 'wizard-sidebar-app', 'src', 'App.tsx')); + + expect(payload.preset).toBe('wizard'); + expect(payload.features).toContain('wizard'); + expect(payload.features).toContain('sidebar'); + expect(payload.routing).toBe('react-router'); + expect(app).toContain('SidebarLayout'); + expect(app).toContain('WizardLayout'); + expect(app).toContain('function WizardContent()'); + expect(app).not.toContain('Your route-aware app shell is ready'); + }); + + it('generates different wallet files for custom and rainbowkit', async () => { + const dir = createTempDir(); + + await runCreate([ + 'custom-app', + '--directory', + dir, + '--wallet', + 'custom', + '--skip-install', + '--yes', + '--json', + ]); + await runCreate([ + 'rainbow-app', + '--directory', + dir, + '--wallet', + 'rainbowkit', + '--skip-install', + '--yes', + '--json', + ]); + + expect(fs.existsSync(path.join(dir, 'custom-app', 'src', 'oz', 'wallet'))).toBe(false); + expect( + fs.existsSync(path.join(dir, 'rainbow-app', 'src', 'oz', 'wallet', 'rainbowkit.config.ts')) + ).toBe(true); + expect(readFile(path.join(dir, 'rainbow-app', 'package.json'))).toContain( + '@rainbow-me/rainbowkit' + ); + }); + + it('rejects contradictory wallet options', async () => { + const dir = createTempDir(); + + expect(() => + resolveCreateOptions({ + projectName: 'bad-app', + targetDirectory: dir, + withoutFeatures: ['wallet'], + wallet: 'custom', + skipInstall: true, + }) + ).toThrow('Cannot use --without wallet'); + }); + + it('does not overwrite a non-empty directory without --force', async () => { + const dir = createTempDir(); + const projectRoot = path.join(dir, 'existing-app'); + fs.mkdirSync(projectRoot); + fs.writeFileSync(path.join(projectRoot, 'README.md'), 'keep me'); + + const options = resolveCreateOptions({ + projectName: 'existing-app', + targetDirectory: dir, + skipInstall: true, + }); + + expect(() => scaffoldProject(options)).toThrow('not empty'); + }); + + it('prints selected components and src/oz edit points in human output', async () => { + const dir = createTempDir(); + const stdout = await runCreate(['human-app', '--directory', dir, '--skip-install', '--yes']); + + expect(stdout).toContain('Components:'); + expect(stdout).toContain('src/oz/runtime.ts'); + expect(stdout).toContain('Next steps'); + }); +}); diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts new file mode 100644 index 0000000..55c7735 --- /dev/null +++ b/packages/cli/src/commands/create.ts @@ -0,0 +1,141 @@ +import path from 'node:path'; +import { Command } from 'commander'; +import pc from 'picocolors'; + +import type { JsonCommandResult } from './migrate/json-results'; + +import { promptForCreateOptions } from '../create/interactive'; +import { parseFeatureList, resolveCreateOptions } from '../create/options'; +import { scaffoldProject } from '../create/scaffold'; +import type { CreateEcosystem, CreatePreset, CreateRouting, CreateWallet } from '../create/types'; +import { printError, printJson } from '../utils/logger'; + +interface CreateCommandOptions { + directory?: string; + preset?: CreatePreset; + ecosystem?: CreateEcosystem; + wallet?: CreateWallet; + routing?: CreateRouting; + with?: string; + without?: string; + packageManager?: 'npm' | 'pnpm' | 'yarn'; + skipInstall?: boolean; + force?: boolean; + yes?: boolean; + json?: boolean; +} + +interface CreateResult extends JsonCommandResult<'create'> { + projectName: string; + projectRoot: string; + preset: CreatePreset; + ecosystem: CreateEcosystem; + wallet: CreateWallet; + routing: CreateRouting; + features: string[]; + impliedFeatures: Record; + filesWritten: string[]; + filesSkipped: string[]; + packageManager: string; + installCommand: string | null; + installRan: boolean; + nextSteps: string[]; +} + +function renderSuccess(result: CreateResult): void { + process.stdout.write(pc.green(`Created ${result.projectName}\n`)); + process.stdout.write(` Preset: ${pc.cyan(result.preset)}\n`); + process.stdout.write(` Ecosystem: ${result.ecosystem}\n`); + process.stdout.write(` Wallet: ${result.wallet}\n`); + process.stdout.write(` Routing: ${result.routing}\n`); + process.stdout.write(` Components: ${result.features.join(', ') || 'none'}\n`); + + const impliedEntries = Object.entries(result.impliedFeatures); + if (impliedEntries.length > 0) { + process.stdout.write(` Auto-added:\n`); + for (const [feature, reason] of impliedEntries) { + process.stdout.write(` ${feature}: ${pc.dim(reason)}\n`); + } + } + + process.stdout.write(` Files written: ${result.filesWritten.length}\n`); + if (result.filesSkipped.length > 0) { + process.stdout.write(` Files skipped: ${result.filesSkipped.length}\n`); + } + + if (result.installRan) { + process.stdout.write(` Installed dependencies with ${pc.cyan(result.installCommand ?? '')}\n`); + } else { + process.stdout.write(` Dependency install skipped\n`); + } + + if (result.features.includes('wallet')) { + process.stdout.write(`\n${pc.bold('OZ wiring')}\n`); + process.stdout.write(` Edit ${pc.cyan('src/oz/runtime.ts')} for adapter/runtime behavior\n`); + process.stdout.write(` Edit ${pc.cyan('src/oz/OzProviders.tsx')} for provider wiring\n`); + process.stdout.write(` Edit ${pc.cyan('public/app.config.json')} for runtime config\n`); + } + + process.stdout.write(`\n${pc.bold('Next steps')}\n`); + for (const step of result.nextSteps) { + process.stdout.write(` ${pc.cyan(step)}\n`); + } +} + +/** + * + */ +export function registerCreateCommand(program: Command): void { + program + .command('create') + .argument('[project-name]', 'Project directory name') + .description('Create a Vite + React + TypeScript app with OpenZeppelin UI wiring.') + .option('-d, --directory ', 'Parent directory for the generated project', process.cwd()) + .option('--preset ', 'minimal, dapp, app-shell, or wizard') + .option('--ecosystem ', 'Target ecosystem (v1: evm)', 'evm') + .option('--wallet ', 'none, custom, or rainbowkit') + .option('--routing ', 'none or react-router') + .option('--with ', 'Comma-separated feature toggles to include') + .option('--without ', 'Comma-separated feature toggles to exclude') + .option('--package-manager ', 'npm, pnpm, or yarn') + .option('--skip-install', 'Generate files without installing dependencies') + .option('--force', 'Overwrite generated files in a non-empty target directory') + .option('-y, --yes', 'Use defaults and skip interactive prompts') + .option('--json', 'Emit machine-readable JSON output') + .action(async (projectName: string | undefined, options: CreateCommandOptions) => { + try { + const baseOptions = { + projectName: projectName ?? '', + targetDirectory: path.resolve(options.directory ?? process.cwd()), + preset: options.preset, + ecosystem: options.ecosystem, + wallet: options.wallet, + routing: options.routing, + withFeatures: parseFeatureList(options.with), + withoutFeatures: parseFeatureList(options.without), + packageManager: options.packageManager, + skipInstall: Boolean(options.skipInstall), + force: Boolean(options.force), + }; + + const userOptions = + options.yes || options.json ? baseOptions : await promptForCreateOptions(baseOptions); + const resolvedOptions = resolveCreateOptions(userOptions); + const scaffoldResult = scaffoldProject(resolvedOptions); + const result: CreateResult = { + ok: true, + action: 'create', + ...scaffoldResult, + }; + + if (options.json) { + printJson(result); + return; + } + + renderSuccess(result); + } catch (error) { + printError(error, Boolean(options.json)); + } + }); +} diff --git a/packages/cli/src/create/interactive.ts b/packages/cli/src/create/interactive.ts new file mode 100644 index 0000000..10452bc --- /dev/null +++ b/packages/cli/src/create/interactive.ts @@ -0,0 +1,89 @@ +import { createInterface } from 'node:readline/promises'; + +import { parseFeatureList } from './options'; +import type { CreateFeature, CreatePreset, CreateUserOptions, CreateWallet } from './types'; + +function normalizeAnswer(answer: string): string { + return answer.trim().toLowerCase(); +} + +async function askChoice( + question: (prompt: string) => Promise, + prompt: string, + choices: readonly T[], + defaultValue: T +): Promise { + const answer = normalizeAnswer( + await question(`${prompt} (${choices.join('/')}) [${defaultValue}]: `) + ); + if (!answer) return defaultValue; + if (choices.includes(answer as T)) return answer as T; + throw new Error(`Unsupported answer "${answer}" for ${prompt}.`); +} + +/** + * + */ +export async function promptForCreateOptions( + initial: Partial +): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const question = rl.question.bind(rl); + const projectName = + initial.projectName ?? (await question('Project directory name: ')).trim() ?? ''; + const preset = await askChoice( + question, + 'Preset', + ['minimal', 'dapp', 'app-shell', 'wizard'], + initial.preset ?? 'dapp' + ); + const wallet = + preset === 'minimal' + ? await askChoice( + question, + 'Wallet integration', + ['none', 'custom', 'rainbowkit'], + initial.wallet ?? 'none' + ) + : await askChoice( + question, + 'Wallet integration', + ['custom', 'rainbowkit', 'none'], + initial.wallet ?? 'custom' + ); + + const advanced = normalizeAnswer( + await question('Configure advanced scaffold components? (y/n) [n]: ') + ); + const withFeatures: CreateFeature[] = [...(initial.withFeatures ?? [])]; + const withoutFeatures: CreateFeature[] = [...(initial.withoutFeatures ?? [])]; + + if (advanced === 'y' || advanced === 'yes') { + const withAnswer = await question( + 'Add features (comma-separated: router,sidebar,theme,toasts,tooltips,wizard,status-panel) []: ' + ); + const withoutAnswer = await question( + 'Remove features (comma-separated: wallet,router,theme,toasts,tooltips,status-panel) []: ' + ); + withFeatures.push(...parseFeatureList(withAnswer)); + withoutFeatures.push(...parseFeatureList(withoutAnswer)); + } + + return { + ...initial, + projectName, + preset, + ecosystem: 'evm', + wallet, + withFeatures, + withoutFeatures, + }; + } finally { + rl.close(); + } +} diff --git a/packages/cli/src/create/options.ts b/packages/cli/src/create/options.ts new file mode 100644 index 0000000..6d6d51b --- /dev/null +++ b/packages/cli/src/create/options.ts @@ -0,0 +1,144 @@ +import path from 'node:path'; + +import { detectPackageManager } from '../utils/framework'; +import type { + CreateFeature, + CreatePreset, + CreateUserOptions, + ResolvedCreateOptions, +} from './types'; + +const PRESETS = ['minimal', 'dapp', 'app-shell', 'wizard'] as const; +const ECOSYSTEMS = ['evm'] as const; +const WALLETS = ['none', 'custom', 'rainbowkit'] as const; +const ROUTING = ['none', 'react-router'] as const; +const FEATURES = [ + 'wallet', + 'router', + 'sidebar', + 'theme', + 'toasts', + 'tooltips', + 'wizard', + 'status-panel', +] as const; + +const PRESET_FEATURES: Record = { + minimal: ['theme', 'toasts', 'tooltips'], + dapp: ['wallet', 'theme', 'toasts', 'tooltips', 'status-panel'], + 'app-shell': ['wallet', 'router', 'sidebar', 'theme', 'toasts', 'tooltips', 'status-panel'], + wizard: ['wallet', 'theme', 'toasts', 'tooltips', 'wizard', 'status-panel'], +}; + +function assertOneOf(value: string, allowed: readonly T[], label: string): T { + if (allowed.includes(value as T)) return value as T; + throw new Error(`Unsupported ${label} "${value}". Expected one of: ${allowed.join(', ')}`); +} + +/** + * + */ +export function parseFeatureList(value: string | string[] | undefined): CreateFeature[] { + if (!value) return []; + const rawValues = Array.isArray(value) ? value : [value]; + return rawValues.flatMap((entry) => + entry + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => assertOneOf(item, FEATURES, 'feature')) + ); +} + +function normalizeProjectName(projectName: string): string { + const trimmed = projectName.trim(); + if (!trimmed) { + throw new Error('Project name is required.'); + } + if (trimmed.includes('/') || trimmed.includes('\\')) { + throw new Error('Project name must be a directory name, not a path.'); + } + return trimmed; +} + +function uniqueFeatures(features: Iterable): CreateFeature[] { + return [...new Set(features)]; +} + +/** + * + */ +export function resolveCreateOptions(options: CreateUserOptions): ResolvedCreateOptions { + const projectName = normalizeProjectName(options.projectName); + const preset = options.preset ?? 'dapp'; + const ecosystem = options.ecosystem ?? 'evm'; + const requestedWallet = options.wallet ?? (preset === 'minimal' ? 'none' : 'custom'); + const targetDirectory = path.resolve(options.targetDirectory ?? process.cwd()); + const projectRoot = path.join(targetDirectory, projectName); + const presetValue = assertOneOf(preset, PRESETS, 'preset'); + const ecosystemValue = assertOneOf(ecosystem, ECOSYSTEMS, 'ecosystem'); + const walletValue = assertOneOf(requestedWallet, WALLETS, 'wallet'); + const withFeatures = uniqueFeatures(options.withFeatures ?? []); + const withoutFeatures = uniqueFeatures(options.withoutFeatures ?? []); + + for (const feature of withFeatures) { + if (withoutFeatures.includes(feature)) { + throw new Error(`Feature "${feature}" cannot be both included and excluded.`); + } + } + + if (withoutFeatures.includes('wallet') && walletValue !== 'none') { + throw new Error(`Cannot use --without wallet with --wallet ${walletValue}.`); + } + + const impliedFeatures: Record = {} as Record; + const featureSet = new Set(PRESET_FEATURES[presetValue]); + + for (const feature of withFeatures) featureSet.add(feature); + for (const feature of withoutFeatures) featureSet.delete(feature); + + if (walletValue !== 'none') { + featureSet.add('wallet'); + } else { + featureSet.delete('wallet'); + } + + if (featureSet.has('sidebar') && !featureSet.has('router')) { + featureSet.add('router'); + impliedFeatures.router = 'sidebar requires route-aware navigation'; + } + + if (presetValue === 'app-shell' && !featureSet.has('router')) { + featureSet.add('router'); + impliedFeatures.router = 'app-shell uses sidebar navigation'; + } + + if (presetValue === 'wizard') { + featureSet.add('wizard'); + } + + const requestedRouting = options.routing ?? (featureSet.has('router') ? 'react-router' : 'none'); + const routingValue = assertOneOf(requestedRouting, ROUTING, 'routing'); + + if (routingValue === 'react-router') { + featureSet.add('router'); + } + + if (routingValue === 'none' && featureSet.has('router')) { + throw new Error('Cannot disable routing while the resolved scaffold includes router/sidebar.'); + } + + return { + projectName, + projectRoot, + preset: presetValue, + ecosystem: ecosystemValue, + wallet: featureSet.has('wallet') ? walletValue : 'none', + routing: featureSet.has('router') ? 'react-router' : routingValue, + features: uniqueFeatures(featureSet).sort(), + packageManager: options.packageManager ?? detectPackageManager(targetDirectory), + skipInstall: Boolean(options.skipInstall), + force: Boolean(options.force), + impliedFeatures, + }; +} diff --git a/packages/cli/src/create/recipes.ts b/packages/cli/src/create/recipes.ts new file mode 100644 index 0000000..e6cc70f --- /dev/null +++ b/packages/cli/src/create/recipes.ts @@ -0,0 +1,108 @@ +import type { + CreateAppSpec, + CreateContent, + CreateLayout, + CreateNavigationSection, + ResolvedCreateOptions, +} from './types'; + +function resolveContent(options: ResolvedCreateOptions): CreateContent { + if (options.preset === 'wizard' || options.features.includes('wizard')) return 'wizard'; + if (options.preset === 'minimal') return 'landing'; + return 'dapp-dashboard'; +} + +function resolveLayout(options: ResolvedCreateOptions): CreateLayout { + if (options.features.includes('sidebar')) return 'sidebar-shell'; + if (options.preset === 'minimal') return 'plain'; + return 'topbar'; +} + +function navigationFor(layout: CreateLayout, content: CreateContent): CreateNavigationSection[] { + if (layout !== 'sidebar-shell') return []; + + if (content === 'wizard') { + return [ + { + title: 'Workflow', + items: [ + { label: 'Wizard', path: '/', disabled: false }, + { label: 'Review', disabled: true, badge: 'Soon' }, + ], + }, + { + title: 'Resources', + items: [{ label: 'Documentation', href: 'https://docs.openzeppelin.com/ui-builder' }], + }, + ]; + } + + return [ + { + title: 'Overview', + items: [ + { label: 'Dashboard', path: '/' }, + { label: 'Activity', path: '/activity' }, + { label: 'Deployments', path: '/deployments' }, + ], + }, + { + title: 'Workspace', + items: [ + { label: 'Contracts', path: '/contracts' }, + { label: 'Workflows', path: '/workflows' }, + { label: 'Team', path: '/team' }, + { label: 'Settings', path: '/settings' }, + ], + }, + { + title: 'Resources', + items: [ + { label: 'Documentation', href: 'https://docs.openzeppelin.com/ui-builder' }, + { label: 'Templates', disabled: true, badge: 'Soon' }, + ], + }, + ]; +} + +function titleFor(content: CreateContent): string { + if (content === 'wizard') return 'OpenZeppelin UI wizard'; + return 'OpenZeppelin UI app'; +} + +function subtitleFor(content: CreateContent): string | null { + if (content === 'wizard') return 'Guided workflow starter'; + if (content === 'dapp-dashboard') return 'Vite + React starter'; + return null; +} + +/** + * Converts validated CLI options into the normalized recipe used by file templates. + */ +export function resolveCreateAppSpec(options: ResolvedCreateOptions): CreateAppSpec { + const layout = resolveLayout(options); + const content = resolveContent(options); + const hasSidebar = layout === 'sidebar-shell'; + const hasWizard = content === 'wizard'; + + return { + preset: options.preset, + layout, + content, + title: titleFor(content), + subtitle: subtitleFor(content), + features: options.features, + wallet: options.wallet, + routing: options.routing, + hasWallet: options.features.includes('wallet'), + hasRouter: options.features.includes('router'), + hasSidebar, + hasTheme: options.features.includes('theme'), + hasToasts: options.features.includes('toasts'), + hasTooltips: options.features.includes('tooltips'), + hasWizard, + hasStatusPanel: options.features.includes('status-panel'), + requiresLogoAsset: layout !== 'plain', + navigation: navigationFor(layout, content), + }; +} diff --git a/packages/cli/src/create/scaffold.ts b/packages/cli/src/create/scaffold.ts new file mode 100644 index 0000000..d395241 --- /dev/null +++ b/packages/cli/src/create/scaffold.ts @@ -0,0 +1,100 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { buildCreateFiles } from './templates'; +import type { CreateScaffoldResult, ResolvedCreateOptions } from './types'; + +function installCommand(packageManager: ResolvedCreateOptions['packageManager']): string { + switch (packageManager) { + case 'pnpm': + return 'pnpm install'; + case 'yarn': + return 'yarn install'; + case 'npm': + return 'npm install'; + } +} + +function assertProjectDirectoryWritable(options: ResolvedCreateOptions): void { + if (!fs.existsSync(options.projectRoot)) return; + + const entries = fs.readdirSync(options.projectRoot); + if (entries.length > 0 && !options.force) { + throw new Error( + `Target directory ${options.projectRoot} is not empty. Re-run with --force to overwrite generated files.` + ); + } +} + +function writeGeneratedFiles(options: ResolvedCreateOptions): { + filesWritten: string[]; + filesSkipped: string[]; +} { + const filesWritten: string[] = []; + const filesSkipped: string[] = []; + for (const file of buildCreateFiles(options)) { + const filePath = path.join(options.projectRoot, file.path); + + if (fs.existsSync(filePath) && !options.force) { + filesSkipped.push(file.path); + continue; + } + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, file.content, 'utf8'); + filesWritten.push(file.path); + } + + return { filesWritten, filesSkipped }; +} + +function buildNextSteps(options: ResolvedCreateOptions): string[] { + const pmRun = options.packageManager === 'npm' ? 'npm run' : `${options.packageManager}`; + const devCommand = options.packageManager === 'npm' ? `${pmRun} dev` : `${pmRun} dev`; + const steps = [`cd ${options.projectName}`]; + + if (options.skipInstall) { + steps.push(installCommand(options.packageManager)); + } + + if (options.features.includes('wallet')) { + steps.push('Edit public/app.config.json or .env.local with your WalletConnect project ID'); + steps.push('Customize adapters and wallet setup in src/oz'); + } + + steps.push(devCommand); + return steps; +} + +/** + * + */ +export function scaffoldProject(options: ResolvedCreateOptions): CreateScaffoldResult { + assertProjectDirectoryWritable(options); + fs.mkdirSync(options.projectRoot, { recursive: true }); + + const { filesWritten, filesSkipped } = writeGeneratedFiles(options); + const command = options.skipInstall ? null : installCommand(options.packageManager); + + if (command) { + execSync(command, { cwd: options.projectRoot, stdio: 'pipe' }); + } + + return { + projectName: options.projectName, + projectRoot: options.projectRoot, + preset: options.preset, + ecosystem: options.ecosystem, + wallet: options.wallet, + routing: options.routing, + features: options.features, + impliedFeatures: options.impliedFeatures, + filesWritten, + filesSkipped, + packageManager: options.packageManager, + installCommand: command, + installRan: Boolean(command), + nextSteps: buildNextSteps(options), + }; +} diff --git a/packages/cli/src/create/templates.ts b/packages/cli/src/create/templates.ts new file mode 100644 index 0000000..45ba294 --- /dev/null +++ b/packages/cli/src/create/templates.ts @@ -0,0 +1,963 @@ +import { CLI_VERSION } from '../branding'; +import { resolveCreateAppSpec } from './recipes'; +import type { CreateAppSpec, CreateFile, ResolvedCreateOptions } from './types'; + +const UI_VERSIONS = { + cli: CLI_VERSION === '0.0.0' ? 'latest' : `^${CLI_VERSION}`, + components: '^2.3.1', + react: '^2.0.1', + renderer: '^2.0.1', + styles: '^1.1.0', + types: '^2.0.0', + utils: '^2.0.0', +}; + +const OZ_LOGO_BLACK_BG_SVG = ` + + + + + + + + + + + + + + + + +`; + +function packageJson(options: ResolvedCreateOptions, spec: CreateAppSpec): string { + const dependencies: Record = { + '@openzeppelin/ui-components': UI_VERSIONS.components, + '@openzeppelin/ui-styles': UI_VERSIONS.styles, + '@openzeppelin/ui-utils': UI_VERSIONS.utils, + react: '^19.2.1', + 'react-dom': '^19.2.1', + }; + + if (spec.hasWallet) { + dependencies['@openzeppelin/adapter-evm'] = '^2.0.1'; + dependencies['@openzeppelin/ui-react'] = UI_VERSIONS.react; + dependencies['@openzeppelin/ui-types'] = UI_VERSIONS.types; + dependencies['@tanstack/react-query'] = '^5.84.1'; + dependencies['@wagmi/core'] = '^2.20.3'; + dependencies['react-hook-form'] = '^7.71.1'; + dependencies.viem = '^2.33.3'; + dependencies.wagmi = '^2.17.0'; + } + + if (options.wallet === 'rainbowkit') { + dependencies['@rainbow-me/rainbowkit'] = '^2.2.8'; + } + + if (spec.hasRouter) { + dependencies['react-router-dom'] = '^7.14.0'; + } + + if (spec.hasTheme) { + dependencies['next-themes'] = '^0.4.6'; + } + + const devDependencies: Record = { + '@tailwindcss/vite': '^4.2.2', + '@types/react': '^19.2.14', + '@types/react-dom': '^19.2.3', + '@vitejs/plugin-react': '^4.7.0', + tailwindcss: '^4.2.2', + typescript: '^5.8.3', + vite: '^7.1.5', + }; + + if (spec.hasWallet) { + devDependencies['@openzeppelin/adapters-vite'] = '^2.0.0'; + } + + return `${JSON.stringify( + { + name: options.projectName, + private: true, + version: '0.0.0', + type: 'module', + scripts: { + dev: 'vite', + build: 'tsc --noEmit && vite build', + preview: 'vite preview', + typecheck: 'tsc --noEmit', + 'oz-ui': 'oz-ui', + }, + dependencies, + devDependencies: { + '@openzeppelin/ui-cli': UI_VERSIONS.cli, + ...devDependencies, + }, + }, + null, + 2 + )}\n`; +} + +function viteConfig(spec: CreateAppSpec): string { + if (!spec.hasWallet) { + return `import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': new URL('./src', import.meta.url).pathname, + }, + }, +}); +`; + } + + return `import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineOpenZeppelinAdapterViteConfig } from '@openzeppelin/adapters-vite'; +import type { UserConfig } from 'vite'; + +const viteConfig: Promise = defineOpenZeppelinAdapterViteConfig({ + ecosystems: ['evm'], + config: { + plugins: [react(), tailwindcss()], + define: { + global: 'globalThis', + }, + resolve: { + alias: { + '@': new URL('./src', import.meta.url).pathname, + }, + dedupe: ['react', 'react-dom', '@openzeppelin/ui-utils', '@openzeppelin/ui-types'], + }, + optimizeDeps: { + esbuildOptions: { + define: { + global: 'globalThis', + }, + }, + include: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@openzeppelin/ui-react', + '@openzeppelin/adapter-evm', + '@wagmi/core', + 'viem/chains', + 'wagmi', + ], + }, + }, +}); + +export default viteConfig; +`; +} + +function appConfig(options: ResolvedCreateOptions): string { + return `${JSON.stringify( + { + $comment: 'Base runtime config. Override with VITE_APP_CFG_* values in .env.local.', + featureFlags: {}, + rpcEndpoints: {}, + indexerEndpoints: {}, + globalServiceConfigs: { + walletconnect: { + projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', + }, + walletui: { + evm: { + kitName: options.wallet, + kitConfig: {}, + }, + default: { + kitName: options.wallet, + kitConfig: {}, + }, + }, + }, + }, + null, + 2 + )}\n`; +} + +function mainTsx(spec: CreateAppSpec): string { + const imports = [ + "import React from 'react';", + "import { createRoot } from 'react-dom/client';", + spec.hasTheme ? "import { ThemeProvider } from 'next-themes';" : '', + spec.hasToasts ? "import { Toaster } from '@openzeppelin/ui-components';" : '', + spec.hasWallet ? "import { initializeAppConfig } from './oz/config';" : '', + spec.hasWallet ? "import { OzProviders } from './oz/OzProviders';" : '', + "import App from './App';", + "import './index.css';", + ].filter(Boolean); + const appTree = spec.hasWallet ? '' : ''; + const themedTree = spec.hasTheme + ? `${appTree}` + : appTree; + const toaster = spec.hasToasts ? '\n ' : ''; + + return `${imports.join('\n')} + +async function bootstrap() { +${spec.hasWallet ? ' await initializeAppConfig();\n' : ''} createRoot(document.getElementById('root')!).render( + + ${themedTree}${toaster} + + ); +} + +void bootstrap(); +`; +} + +function appTsx(spec: CreateAppSpec): string { + if (spec.content === 'wizard') { + return wizardAppTsx(spec); + } + if (spec.layout === 'sidebar-shell') { + return appShellTsx(spec); + } + return dappTsx(spec); +} + +function sharedAppImports(spec: CreateAppSpec): string[] { + return [ + spec.hasTooltips ? 'TooltipProvider' : '', + 'Button', + 'Card', + 'CardContent', + 'CardDescription', + 'CardHeader', + 'CardTitle', + 'Footer', + spec.layout === 'sidebar-shell' ? 'Header' : '', + ].filter(Boolean); +} + +function walletHeaderImport(spec: CreateAppSpec): string { + return spec.hasWallet ? "import { WalletConnectionUI } from '@openzeppelin/ui-react';\n" : ''; +} + +function runtimeStatusImport(spec: CreateAppSpec): string { + return spec.hasStatusPanel ? "import { RuntimeStatus } from './components/RuntimeStatus';\n" : ''; +} + +function walletHeader(spec: CreateAppSpec): string { + return spec.hasWallet ? '' : 'null'; +} + +function statusPanel(spec: CreateAppSpec): string { + return spec.hasStatusPanel ? '' : ''; +} + +function maybeTooltipWrapper(spec: CreateAppSpec, body: string): string { + return spec.hasTooltips ? `\n${body}\n ` : body.trimStart(); +} + +function dappTsx(spec: CreateAppSpec): string { + const imports = sharedAppImports(spec); + const body = spec.content === 'landing' ? minimalBody() : dappBody(spec); + return `import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; +${walletHeaderImport(spec)}${runtimeStatusImport(spec)} +export default function App() { + return ( + ${maybeTooltipWrapper(spec, body)} + ); +} +`; +} + +function minimalBody(): string { + return `
+
+ + + OpenZeppelin UI app + Vite + React + TypeScript with OpenZeppelin UI styles. + + + + + +
+
`; +} + +function dappBody(spec: CreateAppSpec): string { + return `
+
+
+ OpenZeppelin +
+
+
${spec.title}
+
${spec.subtitle ?? ''}
+
+
{${walletHeader(spec)}}
+
+
+
+ + + Your dApp shell is ready + + This starter proves the selected OpenZeppelin UI wiring and gives you a clean place + to add contract interactions. + + + +

+ Edit src/oz to customize + adapters, wallet config, networks, and runtime behavior. +

+ +
+
+ ${statusPanel(spec)} +
+
+
`; +} + +function appShellTsx(spec: CreateAppSpec): string { + const imports = [ + ...sharedAppImports(spec), + 'SidebarButton', + 'SidebarGroup', + 'SidebarLayout', + 'SidebarSection', + ]; + return `import { useState } from 'react'; +import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; +${walletHeaderImport(spec)}${runtimeStatusImport(spec)} +const primaryRoutes = [ + { path: '/', label: 'Dashboard', description: 'Track activity, network status, and app health.' }, + { path: '/activity', label: 'Activity', description: 'Review recent user and contract events.' }, + { path: '/deployments', label: 'Deployments', description: 'Monitor contracts and environments.' }, +]; + +const buildRoutes = [ + { path: '/contracts', label: 'Contracts', description: 'Organize ABI-backed contract screens.' }, + { path: '/workflows', label: 'Workflows', description: 'Model multi-step transaction flows.' }, +]; + +const manageRoutes = [ + { path: '/team', label: 'Team', description: 'Invite collaborators and manage access.' }, + { path: '/settings', label: 'Settings', description: 'Customize generated providers in src/oz.' }, +]; + +function Dashboard() { + return ( +
+ + + Dashboard + Your route-aware app shell is ready. + + + + + + ${statusPanel(spec)} +
+ ); +} + +function PlaceholderPage({ title, description }: { title: string; description: string }) { + return ( + + + {title} + {description} + + + + + + ); +} + +function SidebarLogo() { + return ( +
+ OpenZeppelin Logo +
+ ); +} + +function SidebarNav({ onNavigate }: { onNavigate?: () => void }) { + const location = useLocation(); + const navigate = useNavigate(); + const goTo = (path: string) => { + navigate(path); + onNavigate?.(); + }; + + return ( +
+ + {primaryRoutes.map((item) => ( + goTo(item.path)} + > + {item.label} + + ))} + + + + + {buildRoutes.map((item) => ( + goTo(item.path)} + > + {item.label} + + ))} + + + {manageRoutes.map((item) => ( + goTo(item.path)} + > + {item.label} + + ))} + + + + + + Documentation + + + Templates + + +
+ ); +} + +function Shell() { + const [mobileOpen, setMobileOpen] = useState(false); + return ( +
+ } + mobileOpen={mobileOpen} + onMobileOpenChange={setMobileOpen} + mobileAriaLabel="Navigation menu" + background="bg-sidebar" + width={280} + > + setMobileOpen(false)} /> + +
+
setMobileOpen(true)} + rightContent={${walletHeader(spec)}} + /> +
+
+
+ + } /> + {[...primaryRoutes.slice(1), ...buildRoutes, ...manageRoutes].map((item) => ( + } + /> + ))} + +
+
+
+
+
+
+ ); +} + +export default function App() { + return ( + + ${maybeTooltipWrapper(spec, '')} + + ); +} +`; +} + +function wizardAppTsx(spec: CreateAppSpec): string { + if (spec.layout === 'sidebar-shell') { + return wizardSidebarAppTsx(spec); + } + + const imports = [...sharedAppImports(spec), 'WizardLayout', 'type WizardStepConfig']; + return `import { useState } from 'react'; +import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; +${walletHeaderImport(spec)}${runtimeStatusImport(spec)} +function IntroStep() { + return ( + + + Start your workflow + Use this step to collect the first decision from your user. + + + + + + ); +} + +function RuntimeStep() { + return ${statusPanel(spec) || 'Runtime'}; +} + +export default function App() { + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const steps: WizardStepConfig[] = [ + { id: 'intro', title: 'Intro', component: }, + { id: 'runtime', title: 'Runtime', component: }, + { + id: 'ship', + title: 'Ship', + component: ( + + + Ship it + Replace these placeholders with your product workflow. + + + + + + ), + }, + ]; + + return ( + ${maybeTooltipWrapper( + spec, + `
+
+
+ OpenZeppelin +
+
+
${spec.title}
+
${spec.subtitle ?? ''}
+
+
{${walletHeader(spec)}}
+
+
+
+
+ setCurrentStepIndex(0)} + onComplete={() => setCurrentStepIndex(0)} + lastStepLabel="Finish" + lastStepSecondaryLabel="Preview" + onLastStepSecondary={() => setCurrentStepIndex(0)} + variant="vertical" + /> +
+
+
+
` + )} + ); +} +`; +} + +function wizardSidebarAppTsx(spec: CreateAppSpec): string { + const imports = [ + ...sharedAppImports(spec), + 'SidebarButton', + 'SidebarLayout', + 'SidebarSection', + 'WizardLayout', + 'type WizardStepConfig', + ]; + return `import { useState } from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; +${walletHeaderImport(spec)}${runtimeStatusImport(spec)} +function IntroStep() { + return ( + + + Start your workflow + Use this step to collect the first decision from your user. + + + + + + ); +} + +function RuntimeStep() { + return ${statusPanel(spec) || 'Runtime'}; +} + +function WizardContent() { + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const steps: WizardStepConfig[] = [ + { id: 'intro', title: 'Intro', component: }, + { id: 'runtime', title: 'Runtime', component: }, + { + id: 'ship', + title: 'Ship', + component: ( + + + Ship it + Replace these placeholders with your product workflow. + + + + + + ), + }, + ]; + + return ( + setCurrentStepIndex(0)} + onComplete={() => setCurrentStepIndex(0)} + lastStepLabel="Finish" + lastStepSecondaryLabel="Preview" + onLastStepSecondary={() => setCurrentStepIndex(0)} + variant="vertical" + /> + ); +} + +function SidebarLogo() { + return ( +
+ OpenZeppelin Logo +
+ ); +} + +function WizardSidebar() { + return ( +
+ + Wizard + + Review + + + + + Documentation + + +
+ ); +} + +function Shell() { + const [mobileOpen, setMobileOpen] = useState(false); + + return ( +
+ } + mobileOpen={mobileOpen} + onMobileOpenChange={setMobileOpen} + mobileAriaLabel="Navigation menu" + background="bg-sidebar" + width={280} + > + + +
+
setMobileOpen(true)} + rightContent={${walletHeader(spec)}} + /> +
+
+
+
+ +
+
+
+
+
+
+
+ ); +} + +export default function App() { + return ( + + ${maybeTooltipWrapper(spec, '')} + + ); +} +`; +} + +function runtimeStatus(): string { + return `import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@openzeppelin/ui-components'; +import { useWalletState } from '@openzeppelin/ui-react'; + +export function RuntimeStatus() { + const { activeNetworkConfig, activeRuntime, isRuntimeLoading } = useWalletState(); + + return ( + + + Runtime status + Confirms your adapter, network, and wallet provider are wired. + + +
+
Network
+
{activeNetworkConfig?.name ?? 'Not selected'}
+
+
+
Ecosystem
+
{activeNetworkConfig?.ecosystem ?? 'Unknown'}
+
+
+
Runtime
+
+ {isRuntimeLoading ? 'Loading...' : activeRuntime ? 'Ready' : 'Not loaded'} +
+
+
+
+ ); +} +`; +} + +function ozConfig(): string { + return `import { appConfigService } from '@openzeppelin/ui-utils'; + +export async function initializeAppConfig(): Promise { + await appConfigService.initialize([ + { type: 'json', path: '/app.config.json' }, + { type: 'viteEnv', env: import.meta.env }, + ]); +} +`; +} + +function ozRuntime(options: ResolvedCreateOptions): string { + return `import { ecosystemDefinition } from '@openzeppelin/adapter-evm'; +import { evmNetworks } from '@openzeppelin/adapter-evm/networks'; +import type { EcosystemRuntime, NetworkConfig } from '@openzeppelin/ui-types'; + +export const DEFAULT_NETWORK_ID = 'ethereum-sepolia'; +export const supportedNetworks = evmNetworks; + +export function getDefaultNetworkId(): string { + return DEFAULT_NETWORK_ID; +} + +export function getNetworkById(id: string): NetworkConfig | undefined { + return supportedNetworks.find((network) => network.id === id); +} + +export async function resolveRuntime(networkConfig: NetworkConfig): Promise { + return ecosystemDefinition.createRuntime('composer', networkConfig, { + uiKit: '${options.wallet}', + }); +} +`; +} + +function ozProviders(): string { + return `import { useCallback, type ReactNode } from 'react'; +import { RuntimeProvider, WalletStateProvider } from '@openzeppelin/ui-react'; +import type { NativeConfigLoader } from '@openzeppelin/ui-types'; + +import { getDefaultNetworkId, getNetworkById, resolveRuntime } from './runtime'; + +const walletConfigImporters = import.meta.glob('./wallet/*.config.ts'); + +const loadConfigModule: NativeConfigLoader = async (relativePath) => { + const normalizedPath = relativePath.startsWith('./config/wallet/') + ? relativePath.replace('./config/wallet/', './wallet/') + : relativePath; + const importer = + walletConfigImporters[normalizedPath] ?? + walletConfigImporters[\`\${normalizedPath}.ts\`] ?? + walletConfigImporters[\`\${normalizedPath}.tsx\`]; + if (!importer) return null; + const module = (await importer()) as { default?: Record } & Record< + string, + unknown + >; + return module.default ?? module; +}; + +export function OzProviders({ children }: { children: ReactNode }) { + const getNetworkConfigById = useCallback((networkId: string) => getNetworkById(networkId), []); + + return ( + + + {children} + + + ); +} +`; +} + +function rainbowKitConfig(options: ResolvedCreateOptions): string { + return `const rainbowKitConfig = { + wagmiParams: { + appName: '${options.projectName}', + projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', + ssr: false, + }, + providerProps: { + showRecentTransactions: true, + appInfo: { + appName: '${options.projectName}', + learnMoreUrl: 'https://openzeppelin.com', + }, + }, + customizations: { + connectButton: { + chainStatus: 'icon', + accountStatus: 'full', + showBalance: false, + }, + }, +}; + +export default rainbowKitConfig; +`; +} + +function indexCss(): string { + return `@layer base, components, utilities; + +@import 'tailwindcss' source(none); + +@source './'; +@source '../node_modules/@openzeppelin'; +@import '@openzeppelin/ui-styles/global.css'; +`; +} + +/** + * + */ +export function buildCreateFiles(options: ResolvedCreateOptions): CreateFile[] { + const spec = resolveCreateAppSpec(options); + const files: CreateFile[] = [ + { path: 'package.json', content: packageJson(options, spec) }, + { + path: 'index.html', + content: `
\n`, + }, + { + path: 'tsconfig.json', + content: `${JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + useDefineForClassFields: true, + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + allowJs: false, + skipLibCheck: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + strict: true, + forceConsistentCasingInFileNames: true, + module: 'ESNext', + moduleResolution: 'Bundler', + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + types: ['vite/client'], + baseUrl: '.', + paths: { + '@/*': ['./src/*'], + }, + }, + include: ['src'], + references: [], + }, + null, + 2 + )}\n`, + }, + { path: 'vite.config.ts', content: viteConfig(spec) }, + { path: 'src/main.tsx', content: mainTsx(spec) }, + { path: 'src/App.tsx', content: appTsx(spec) }, + { path: 'src/index.css', content: indexCss() }, + { path: 'src/vite-env.d.ts', content: '/// \n' }, + ]; + + if (spec.hasWallet) { + files.push( + { path: 'public/app.config.json', content: appConfig(options) }, + { path: 'src/oz/config.ts', content: ozConfig() }, + { path: 'src/oz/runtime.ts', content: ozRuntime(options) }, + { path: 'src/oz/OzProviders.tsx', content: ozProviders() } + ); + } + + if (spec.requiresLogoAsset) { + files.push({ path: 'public/OZ-Logo-BlackBG.svg', content: OZ_LOGO_BLACK_BG_SVG }); + } + + if (spec.hasStatusPanel) { + files.push({ path: 'src/components/RuntimeStatus.tsx', content: runtimeStatus() }); + } + + if (options.wallet === 'rainbowkit') { + files.push({ path: 'src/oz/wallet/rainbowkit.config.ts', content: rainbowKitConfig(options) }); + } + + return files; +} diff --git a/packages/cli/src/create/types.ts b/packages/cli/src/create/types.ts new file mode 100644 index 0000000..0630681 --- /dev/null +++ b/packages/cli/src/create/types.ts @@ -0,0 +1,99 @@ +export type CreatePreset = 'minimal' | 'dapp' | 'app-shell' | 'wizard'; +export type CreateEcosystem = 'evm'; +export type CreateWallet = 'none' | 'custom' | 'rainbowkit'; +export type CreateRouting = 'none' | 'react-router'; +export type CreateLayout = 'plain' | 'topbar' | 'sidebar-shell'; +export type CreateContent = 'landing' | 'dapp-dashboard' | 'wizard'; +export type CreateFeature = + | 'wallet' + | 'router' + | 'sidebar' + | 'theme' + | 'toasts' + | 'tooltips' + | 'wizard' + | 'status-panel'; + +export interface CreateUserOptions { + projectName: string; + targetDirectory?: string; + preset?: CreatePreset; + ecosystem?: CreateEcosystem; + wallet?: CreateWallet; + routing?: CreateRouting; + withFeatures?: CreateFeature[]; + withoutFeatures?: CreateFeature[]; + packageManager?: 'npm' | 'pnpm' | 'yarn'; + skipInstall?: boolean; + force?: boolean; +} + +export interface ResolvedCreateOptions { + projectName: string; + projectRoot: string; + preset: CreatePreset; + ecosystem: CreateEcosystem; + wallet: CreateWallet; + routing: CreateRouting; + features: CreateFeature[]; + packageManager: 'npm' | 'pnpm' | 'yarn'; + skipInstall: boolean; + force: boolean; + impliedFeatures: Record; +} + +export interface CreateFile { + path: string; + content: string; +} + +export interface CreateNavigationItem { + label: string; + path?: string; + disabled?: boolean; + badge?: string; + href?: string; +} + +export interface CreateNavigationSection { + title: string; + items: CreateNavigationItem[]; +} + +export interface CreateAppSpec { + preset: CreatePreset; + layout: CreateLayout; + content: CreateContent; + title: string; + subtitle: string | null; + features: CreateFeature[]; + wallet: CreateWallet; + routing: CreateRouting; + hasWallet: boolean; + hasRouter: boolean; + hasSidebar: boolean; + hasTheme: boolean; + hasToasts: boolean; + hasTooltips: boolean; + hasWizard: boolean; + hasStatusPanel: boolean; + requiresLogoAsset: boolean; + navigation: CreateNavigationSection[]; +} + +export interface CreateScaffoldResult { + projectName: string; + projectRoot: string; + preset: CreatePreset; + ecosystem: CreateEcosystem; + wallet: CreateWallet; + routing: CreateRouting; + features: CreateFeature[]; + impliedFeatures: Record; + filesWritten: string[]; + filesSkipped: string[]; + packageManager: 'npm' | 'pnpm' | 'yarn'; + installCommand: string | null; + installRan: boolean; + nextSteps: string[]; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 16f285f..df88b64 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander'; +import { registerCreateCommand } from './commands/create'; import { registerMigrateCommand } from './commands/migrate'; import { CLI_VERSION } from './branding'; @@ -12,6 +13,7 @@ program .description('OpenZeppelin UI CLI — scaffold, migrate, and manage OZ UI applications.') .version(CLI_VERSION); +registerCreateCommand(program); registerMigrateCommand(program); program.parse(); From 72225436bf48910294d2f18acba7138288179fac Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Mon, 27 Apr 2026 23:43:47 +0300 Subject: [PATCH 02/11] refactor(common): split create template modules and add option coverage Extract package, vite, and support-file renderers. Expand create tests and --with help. Link recipes to the create matrix. --- packages/cli/docs/CREATE_RECIPES.md | 2 + packages/cli/src/commands/create.test.ts | 75 +++++ packages/cli/src/commands/create.ts | 7 +- packages/cli/src/create/package-json.ts | 85 ++++++ packages/cli/src/create/support-files.ts | 202 ++++++++++++++ packages/cli/src/create/templates.ts | 333 +---------------------- packages/cli/src/create/vite-template.ts | 65 +++++ 7 files changed, 445 insertions(+), 324 deletions(-) create mode 100644 packages/cli/src/create/package-json.ts create mode 100644 packages/cli/src/create/support-files.ts create mode 100644 packages/cli/src/create/vite-template.ts diff --git a/packages/cli/docs/CREATE_RECIPES.md b/packages/cli/docs/CREATE_RECIPES.md index ca7833a..fc1375c 100644 --- a/packages/cli/docs/CREATE_RECIPES.md +++ b/packages/cli/docs/CREATE_RECIPES.md @@ -1,5 +1,7 @@ # `oz-ui create` Recipes +For the user-facing preset and generated-output reference, see [Create matrix](./CREATE_MATRIX.md). + The `create` command resolves user input in two stages: 1. `resolveCreateOptions` validates CLI flags and expands feature implications. diff --git a/packages/cli/src/commands/create.test.ts b/packages/cli/src/commands/create.test.ts index 44fedbb..f342ca3 100644 --- a/packages/cli/src/commands/create.test.ts +++ b/packages/cli/src/commands/create.test.ts @@ -267,6 +267,81 @@ describe('create command', () => { ); }); + it('generates no wallet wiring when wallet is disabled', async () => { + const dir = createTempDir(); + const stdout = await runCreate([ + 'no-wallet-app', + '--directory', + dir, + '--preset', + 'dapp', + '--wallet', + 'none', + '--skip-install', + '--yes', + '--json', + ]); + const payload = JSON.parse(stdout); + const projectRoot = path.join(dir, 'no-wallet-app'); + const packageJson = readFile(path.join(projectRoot, 'package.json')); + const app = readFile(path.join(projectRoot, 'src', 'App.tsx')); + + expect(payload.wallet).toBe('none'); + expect(payload.features).not.toContain('wallet'); + expect(fs.existsSync(path.join(projectRoot, 'src', 'oz'))).toBe(false); + expect(packageJson).not.toContain('@wagmi/core'); + expect(packageJson).not.toContain('@openzeppelin/ui-react'); + expect(app).toContain('
{null}
'); + }); + + it('respects removable optional features', async () => { + const dir = createTempDir(); + const stdout = await runCreate([ + 'quiet-app', + '--directory', + dir, + '--preset', + 'dapp', + '--without', + 'toasts,status-panel', + '--skip-install', + '--yes', + '--json', + ]); + const payload = JSON.parse(stdout); + const projectRoot = path.join(dir, 'quiet-app'); + const main = readFile(path.join(projectRoot, 'src', 'main.tsx')); + const app = readFile(path.join(projectRoot, 'src', 'App.tsx')); + + expect(payload.features).not.toContain('toasts'); + expect(payload.features).not.toContain('status-panel'); + expect(main).not.toContain('Toaster'); + expect(app).not.toContain('RuntimeStatus'); + expect(fs.existsSync(path.join(projectRoot, 'src', 'components', 'RuntimeStatus.tsx'))).toBe( + false + ); + }); + + it('documents available feature names in create help', async () => { + const root = new Command(); + registerCreateCommand(root); + const createCommand = root.commands.find((command) => command.name() === 'create'); + const stdout = createCommand?.helpInformation() ?? ''; + + for (const feature of [ + 'wallet', + 'router', + 'sidebar', + 'theme', + 'toasts', + 'tooltips', + 'wizard', + ]) { + expect(stdout).toContain(feature); + } + expect(stdout).toContain('status-panel'); + }); + it('rejects contradictory wallet options', async () => { const dir = createTempDir(); diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 55c7735..95492d5 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -95,8 +95,11 @@ export function registerCreateCommand(program: Command): void { .option('--ecosystem ', 'Target ecosystem (v1: evm)', 'evm') .option('--wallet ', 'none, custom, or rainbowkit') .option('--routing ', 'none or react-router') - .option('--with ', 'Comma-separated feature toggles to include') - .option('--without ', 'Comma-separated feature toggles to exclude') + .option( + '--with ', + 'Comma-separated features: wallet, router, sidebar, theme, toasts, tooltips, wizard, status-panel' + ) + .option('--without ', 'Comma-separated features to remove from the preset defaults') .option('--package-manager ', 'npm, pnpm, or yarn') .option('--skip-install', 'Generate files without installing dependencies') .option('--force', 'Overwrite generated files in a non-empty target directory') diff --git a/packages/cli/src/create/package-json.ts b/packages/cli/src/create/package-json.ts new file mode 100644 index 0000000..ff01cd9 --- /dev/null +++ b/packages/cli/src/create/package-json.ts @@ -0,0 +1,85 @@ +import { CLI_VERSION } from '../branding'; +import type { CreateAppSpec, ResolvedCreateOptions } from './types'; + +const UI_VERSIONS = { + cli: CLI_VERSION === '0.0.0' ? 'latest' : `^${CLI_VERSION}`, + components: '^2.3.1', + react: '^2.0.1', + renderer: '^2.0.1', + styles: '^1.1.0', + types: '^2.0.0', + utils: '^2.0.0', +}; + +/** + * Renders the generated app package manifest. + */ +export function packageJson(options: ResolvedCreateOptions, spec: CreateAppSpec): string { + const dependencies: Record = { + '@openzeppelin/ui-components': UI_VERSIONS.components, + '@openzeppelin/ui-styles': UI_VERSIONS.styles, + '@openzeppelin/ui-utils': UI_VERSIONS.utils, + react: '^19.2.1', + 'react-dom': '^19.2.1', + }; + + if (spec.hasWallet) { + dependencies['@openzeppelin/adapter-evm'] = '^2.0.1'; + dependencies['@openzeppelin/ui-react'] = UI_VERSIONS.react; + dependencies['@openzeppelin/ui-types'] = UI_VERSIONS.types; + dependencies['@tanstack/react-query'] = '^5.84.1'; + dependencies['@wagmi/core'] = '^2.20.3'; + dependencies['react-hook-form'] = '^7.71.1'; + dependencies.viem = '^2.33.3'; + dependencies.wagmi = '^2.17.0'; + } + + if (options.wallet === 'rainbowkit') { + dependencies['@rainbow-me/rainbowkit'] = '^2.2.8'; + } + + if (spec.hasRouter) { + dependencies['react-router-dom'] = '^7.14.0'; + } + + if (spec.hasTheme) { + dependencies['next-themes'] = '^0.4.6'; + } + + const devDependencies: Record = { + '@tailwindcss/vite': '^4.2.2', + '@types/react': '^19.2.14', + '@types/react-dom': '^19.2.3', + '@vitejs/plugin-react': '^4.7.0', + tailwindcss: '^4.2.2', + typescript: '^5.8.3', + vite: '^7.1.5', + }; + + if (spec.hasWallet) { + devDependencies['@openzeppelin/adapters-vite'] = '^2.0.0'; + } + + return `${JSON.stringify( + { + name: options.projectName, + private: true, + version: '0.0.0', + type: 'module', + scripts: { + dev: 'vite', + build: 'tsc --noEmit && vite build', + preview: 'vite preview', + typecheck: 'tsc --noEmit', + 'oz-ui': 'oz-ui', + }, + dependencies, + devDependencies: { + '@openzeppelin/ui-cli': UI_VERSIONS.cli, + ...devDependencies, + }, + }, + null, + 2 + )}\n`; +} diff --git a/packages/cli/src/create/support-files.ts b/packages/cli/src/create/support-files.ts new file mode 100644 index 0000000..97291ff --- /dev/null +++ b/packages/cli/src/create/support-files.ts @@ -0,0 +1,202 @@ +import type { ResolvedCreateOptions } from './types'; + +/** + * Renders the generated runtime config JSON. + */ +export function appConfig(options: ResolvedCreateOptions): string { + return `${JSON.stringify( + { + $comment: 'Base runtime config. Override with VITE_APP_CFG_* values in .env.local.', + featureFlags: {}, + rpcEndpoints: {}, + indexerEndpoints: {}, + globalServiceConfigs: { + walletconnect: { + projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', + }, + walletui: { + evm: { + kitName: options.wallet, + kitConfig: {}, + }, + default: { + kitName: options.wallet, + kitConfig: {}, + }, + }, + }, + }, + null, + 2 + )}\n`; +} + +/** + * Renders the generated runtime status component. + */ +export function runtimeStatus(): string { + return `import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@openzeppelin/ui-components'; +import { useWalletState } from '@openzeppelin/ui-react'; + +export function RuntimeStatus() { + const { activeNetworkConfig, activeRuntime, isRuntimeLoading } = useWalletState(); + + return ( + + + Runtime status + Confirms your adapter, network, and wallet provider are wired. + + +
+
Network
+
{activeNetworkConfig?.name ?? 'Not selected'}
+
+
+
Ecosystem
+
{activeNetworkConfig?.ecosystem ?? 'Unknown'}
+
+
+
Runtime
+
+ {isRuntimeLoading ? 'Loading...' : activeRuntime ? 'Ready' : 'Not loaded'} +
+
+
+
+ ); +} +`; +} + +/** + * Renders the generated app config initializer. + */ +export function ozConfig(): string { + return `import { appConfigService } from '@openzeppelin/ui-utils'; + +export async function initializeAppConfig(): Promise { + await appConfigService.initialize([ + { type: 'json', path: '/app.config.json' }, + { type: 'viteEnv', env: import.meta.env }, + ]); +} +`; +} + +/** + * Renders the generated EVM runtime adapter wiring. + */ +export function ozRuntime(options: ResolvedCreateOptions): string { + return `import { ecosystemDefinition } from '@openzeppelin/adapter-evm'; +import { evmNetworks } from '@openzeppelin/adapter-evm/networks'; +import type { EcosystemRuntime, NetworkConfig } from '@openzeppelin/ui-types'; + +export const DEFAULT_NETWORK_ID = 'ethereum-sepolia'; +export const supportedNetworks = evmNetworks; + +export function getDefaultNetworkId(): string { + return DEFAULT_NETWORK_ID; +} + +export function getNetworkById(id: string): NetworkConfig | undefined { + return supportedNetworks.find((network) => network.id === id); +} + +export async function resolveRuntime(networkConfig: NetworkConfig): Promise { + return ecosystemDefinition.createRuntime('composer', networkConfig, { + uiKit: '${options.wallet}', + }); +} +`; +} + +/** + * Renders the generated OpenZeppelin React provider wrapper. + */ +export function ozProviders(): string { + return `import { useCallback, type ReactNode } from 'react'; +import { RuntimeProvider, WalletStateProvider } from '@openzeppelin/ui-react'; +import type { NativeConfigLoader } from '@openzeppelin/ui-types'; + +import { getDefaultNetworkId, getNetworkById, resolveRuntime } from './runtime'; + +const walletConfigImporters = import.meta.glob('./wallet/*.config.ts'); + +const loadConfigModule: NativeConfigLoader = async (relativePath) => { + const normalizedPath = relativePath.startsWith('./config/wallet/') + ? relativePath.replace('./config/wallet/', './wallet/') + : relativePath; + const importer = + walletConfigImporters[normalizedPath] ?? + walletConfigImporters[\`\${normalizedPath}.ts\`] ?? + walletConfigImporters[\`\${normalizedPath}.tsx\`]; + if (!importer) return null; + const module = (await importer()) as { default?: Record } & Record< + string, + unknown + >; + return module.default ?? module; +}; + +export function OzProviders({ children }: { children: ReactNode }) { + const getNetworkConfigById = useCallback((networkId: string) => getNetworkById(networkId), []); + + return ( + + + {children} + + + ); +} +`; +} + +/** + * Renders the generated RainbowKit native config module. + */ +export function rainbowKitConfig(options: ResolvedCreateOptions): string { + return `const rainbowKitConfig = { + wagmiParams: { + appName: '${options.projectName}', + projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', + ssr: false, + }, + providerProps: { + showRecentTransactions: true, + appInfo: { + appName: '${options.projectName}', + learnMoreUrl: 'https://openzeppelin.com', + }, + }, + customizations: { + connectButton: { + chainStatus: 'icon', + accountStatus: 'full', + showBalance: false, + }, + }, +}; + +export default rainbowKitConfig; +`; +} + +/** + * Renders the generated app entry stylesheet. + */ +export function indexCss(): string { + return `@layer base, components, utilities; + +@import 'tailwindcss' source(none); + +@source './'; +@source '../node_modules/@openzeppelin'; +@import '@openzeppelin/ui-styles/global.css'; +`; +} diff --git a/packages/cli/src/create/templates.ts b/packages/cli/src/create/templates.ts index 45ba294..c22096b 100644 --- a/packages/cli/src/create/templates.ts +++ b/packages/cli/src/create/templates.ts @@ -1,16 +1,16 @@ -import { CLI_VERSION } from '../branding'; +import { packageJson } from './package-json'; import { resolveCreateAppSpec } from './recipes'; +import { + appConfig, + indexCss, + ozConfig, + ozProviders, + ozRuntime, + rainbowKitConfig, + runtimeStatus, +} from './support-files'; import type { CreateAppSpec, CreateFile, ResolvedCreateOptions } from './types'; - -const UI_VERSIONS = { - cli: CLI_VERSION === '0.0.0' ? 'latest' : `^${CLI_VERSION}`, - components: '^2.3.1', - react: '^2.0.1', - renderer: '^2.0.1', - styles: '^1.1.0', - types: '^2.0.0', - utils: '^2.0.0', -}; +import { viteConfig } from './vite-template'; const OZ_LOGO_BLACK_BG_SVG = ` @@ -31,165 +31,6 @@ const OZ_LOGO_BLACK_BG_SVG = ` `; -function packageJson(options: ResolvedCreateOptions, spec: CreateAppSpec): string { - const dependencies: Record = { - '@openzeppelin/ui-components': UI_VERSIONS.components, - '@openzeppelin/ui-styles': UI_VERSIONS.styles, - '@openzeppelin/ui-utils': UI_VERSIONS.utils, - react: '^19.2.1', - 'react-dom': '^19.2.1', - }; - - if (spec.hasWallet) { - dependencies['@openzeppelin/adapter-evm'] = '^2.0.1'; - dependencies['@openzeppelin/ui-react'] = UI_VERSIONS.react; - dependencies['@openzeppelin/ui-types'] = UI_VERSIONS.types; - dependencies['@tanstack/react-query'] = '^5.84.1'; - dependencies['@wagmi/core'] = '^2.20.3'; - dependencies['react-hook-form'] = '^7.71.1'; - dependencies.viem = '^2.33.3'; - dependencies.wagmi = '^2.17.0'; - } - - if (options.wallet === 'rainbowkit') { - dependencies['@rainbow-me/rainbowkit'] = '^2.2.8'; - } - - if (spec.hasRouter) { - dependencies['react-router-dom'] = '^7.14.0'; - } - - if (spec.hasTheme) { - dependencies['next-themes'] = '^0.4.6'; - } - - const devDependencies: Record = { - '@tailwindcss/vite': '^4.2.2', - '@types/react': '^19.2.14', - '@types/react-dom': '^19.2.3', - '@vitejs/plugin-react': '^4.7.0', - tailwindcss: '^4.2.2', - typescript: '^5.8.3', - vite: '^7.1.5', - }; - - if (spec.hasWallet) { - devDependencies['@openzeppelin/adapters-vite'] = '^2.0.0'; - } - - return `${JSON.stringify( - { - name: options.projectName, - private: true, - version: '0.0.0', - type: 'module', - scripts: { - dev: 'vite', - build: 'tsc --noEmit && vite build', - preview: 'vite preview', - typecheck: 'tsc --noEmit', - 'oz-ui': 'oz-ui', - }, - dependencies, - devDependencies: { - '@openzeppelin/ui-cli': UI_VERSIONS.cli, - ...devDependencies, - }, - }, - null, - 2 - )}\n`; -} - -function viteConfig(spec: CreateAppSpec): string { - if (!spec.hasWallet) { - return `import tailwindcss from '@tailwindcss/vite'; -import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [react(), tailwindcss()], - resolve: { - alias: { - '@': new URL('./src', import.meta.url).pathname, - }, - }, -}); -`; - } - - return `import tailwindcss from '@tailwindcss/vite'; -import react from '@vitejs/plugin-react'; -import { defineOpenZeppelinAdapterViteConfig } from '@openzeppelin/adapters-vite'; -import type { UserConfig } from 'vite'; - -const viteConfig: Promise = defineOpenZeppelinAdapterViteConfig({ - ecosystems: ['evm'], - config: { - plugins: [react(), tailwindcss()], - define: { - global: 'globalThis', - }, - resolve: { - alias: { - '@': new URL('./src', import.meta.url).pathname, - }, - dedupe: ['react', 'react-dom', '@openzeppelin/ui-utils', '@openzeppelin/ui-types'], - }, - optimizeDeps: { - esbuildOptions: { - define: { - global: 'globalThis', - }, - }, - include: [ - 'react', - 'react-dom', - 'react-dom/client', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - '@openzeppelin/ui-react', - '@openzeppelin/adapter-evm', - '@wagmi/core', - 'viem/chains', - 'wagmi', - ], - }, - }, -}); - -export default viteConfig; -`; -} - -function appConfig(options: ResolvedCreateOptions): string { - return `${JSON.stringify( - { - $comment: 'Base runtime config. Override with VITE_APP_CFG_* values in .env.local.', - featureFlags: {}, - rpcEndpoints: {}, - indexerEndpoints: {}, - globalServiceConfigs: { - walletconnect: { - projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', - }, - walletui: { - evm: { - kitName: options.wallet, - kitConfig: {}, - }, - default: { - kitName: options.wallet, - kitConfig: {}, - }, - }, - }, - }, - null, - 2 - )}\n`; -} - function mainTsx(spec: CreateAppSpec): string { const imports = [ "import React from 'react';", @@ -735,158 +576,6 @@ export default function App() { `; } -function runtimeStatus(): string { - return `import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@openzeppelin/ui-components'; -import { useWalletState } from '@openzeppelin/ui-react'; - -export function RuntimeStatus() { - const { activeNetworkConfig, activeRuntime, isRuntimeLoading } = useWalletState(); - - return ( - - - Runtime status - Confirms your adapter, network, and wallet provider are wired. - - -
-
Network
-
{activeNetworkConfig?.name ?? 'Not selected'}
-
-
-
Ecosystem
-
{activeNetworkConfig?.ecosystem ?? 'Unknown'}
-
-
-
Runtime
-
- {isRuntimeLoading ? 'Loading...' : activeRuntime ? 'Ready' : 'Not loaded'} -
-
- - - ); -} -`; -} - -function ozConfig(): string { - return `import { appConfigService } from '@openzeppelin/ui-utils'; - -export async function initializeAppConfig(): Promise { - await appConfigService.initialize([ - { type: 'json', path: '/app.config.json' }, - { type: 'viteEnv', env: import.meta.env }, - ]); -} -`; -} - -function ozRuntime(options: ResolvedCreateOptions): string { - return `import { ecosystemDefinition } from '@openzeppelin/adapter-evm'; -import { evmNetworks } from '@openzeppelin/adapter-evm/networks'; -import type { EcosystemRuntime, NetworkConfig } from '@openzeppelin/ui-types'; - -export const DEFAULT_NETWORK_ID = 'ethereum-sepolia'; -export const supportedNetworks = evmNetworks; - -export function getDefaultNetworkId(): string { - return DEFAULT_NETWORK_ID; -} - -export function getNetworkById(id: string): NetworkConfig | undefined { - return supportedNetworks.find((network) => network.id === id); -} - -export async function resolveRuntime(networkConfig: NetworkConfig): Promise { - return ecosystemDefinition.createRuntime('composer', networkConfig, { - uiKit: '${options.wallet}', - }); -} -`; -} - -function ozProviders(): string { - return `import { useCallback, type ReactNode } from 'react'; -import { RuntimeProvider, WalletStateProvider } from '@openzeppelin/ui-react'; -import type { NativeConfigLoader } from '@openzeppelin/ui-types'; - -import { getDefaultNetworkId, getNetworkById, resolveRuntime } from './runtime'; - -const walletConfigImporters = import.meta.glob('./wallet/*.config.ts'); - -const loadConfigModule: NativeConfigLoader = async (relativePath) => { - const normalizedPath = relativePath.startsWith('./config/wallet/') - ? relativePath.replace('./config/wallet/', './wallet/') - : relativePath; - const importer = - walletConfigImporters[normalizedPath] ?? - walletConfigImporters[\`\${normalizedPath}.ts\`] ?? - walletConfigImporters[\`\${normalizedPath}.tsx\`]; - if (!importer) return null; - const module = (await importer()) as { default?: Record } & Record< - string, - unknown - >; - return module.default ?? module; -}; - -export function OzProviders({ children }: { children: ReactNode }) { - const getNetworkConfigById = useCallback((networkId: string) => getNetworkById(networkId), []); - - return ( - - - {children} - - - ); -} -`; -} - -function rainbowKitConfig(options: ResolvedCreateOptions): string { - return `const rainbowKitConfig = { - wagmiParams: { - appName: '${options.projectName}', - projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', - ssr: false, - }, - providerProps: { - showRecentTransactions: true, - appInfo: { - appName: '${options.projectName}', - learnMoreUrl: 'https://openzeppelin.com', - }, - }, - customizations: { - connectButton: { - chainStatus: 'icon', - accountStatus: 'full', - showBalance: false, - }, - }, -}; - -export default rainbowKitConfig; -`; -} - -function indexCss(): string { - return `@layer base, components, utilities; - -@import 'tailwindcss' source(none); - -@source './'; -@source '../node_modules/@openzeppelin'; -@import '@openzeppelin/ui-styles/global.css'; -`; -} - /** * */ diff --git a/packages/cli/src/create/vite-template.ts b/packages/cli/src/create/vite-template.ts new file mode 100644 index 0000000..810ecae --- /dev/null +++ b/packages/cli/src/create/vite-template.ts @@ -0,0 +1,65 @@ +import type { CreateAppSpec } from './types'; + +/** + * Renders the generated app Vite configuration. + */ +export function viteConfig(spec: CreateAppSpec): string { + if (!spec.hasWallet) { + return `import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': new URL('./src', import.meta.url).pathname, + }, + }, +}); +`; + } + + return `import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineOpenZeppelinAdapterViteConfig } from '@openzeppelin/adapters-vite'; +import type { UserConfig } from 'vite'; + +const viteConfig: Promise = defineOpenZeppelinAdapterViteConfig({ + ecosystems: ['evm'], + config: { + plugins: [react(), tailwindcss()], + define: { + global: 'globalThis', + }, + resolve: { + alias: { + '@': new URL('./src', import.meta.url).pathname, + }, + dedupe: ['react', 'react-dom', '@openzeppelin/ui-utils', '@openzeppelin/ui-types'], + }, + optimizeDeps: { + esbuildOptions: { + define: { + global: 'globalThis', + }, + }, + include: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@openzeppelin/ui-react', + '@openzeppelin/adapter-evm', + '@wagmi/core', + 'viem/chains', + 'wagmi', + ], + }, + }, +}); + +export default viteConfig; +`; +} From b17bce363e9830bad1627e9733faab6c3600f393 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Tue, 28 Apr 2026 10:58:06 +0300 Subject: [PATCH 03/11] feat(common): add json envelope and split oz-ui create templates Inject schemaVersion and cli metadata on every oz-ui --json payload; document the contract in README. Split create scaffolding from templates.ts into a templates/ directory per layout; add patch changeset for ui-cli. --- .changeset/quiet-jaguars-envelope.md | 5 + packages/cli/README.md | 40 ++ packages/cli/src/commands/create.test.ts | 4 + .../commands/migrate/commands-json.test.ts | 3 + .../cli/src/commands/migrate/json-results.ts | 23 + packages/cli/src/create/templates.ts | 652 ------------------ packages/cli/src/create/templates/app.ts | 16 + packages/cli/src/create/templates/assets.ts | 23 + packages/cli/src/create/templates/index.ts | 95 +++ .../src/create/templates/layouts/app-shell.ts | 191 +++++ .../cli/src/create/templates/layouts/dapp.ts | 79 +++ .../src/create/templates/layouts/wizard.ts | 241 +++++++ packages/cli/src/create/templates/main.ts | 37 + packages/cli/src/create/templates/shared.ts | 61 ++ packages/cli/src/utils/logger.ts | 35 +- 15 files changed, 850 insertions(+), 655 deletions(-) create mode 100644 .changeset/quiet-jaguars-envelope.md delete mode 100644 packages/cli/src/create/templates.ts create mode 100644 packages/cli/src/create/templates/app.ts create mode 100644 packages/cli/src/create/templates/assets.ts create mode 100644 packages/cli/src/create/templates/index.ts create mode 100644 packages/cli/src/create/templates/layouts/app-shell.ts create mode 100644 packages/cli/src/create/templates/layouts/dapp.ts create mode 100644 packages/cli/src/create/templates/layouts/wizard.ts create mode 100644 packages/cli/src/create/templates/main.ts create mode 100644 packages/cli/src/create/templates/shared.ts diff --git a/.changeset/quiet-jaguars-envelope.md b/.changeset/quiet-jaguars-envelope.md new file mode 100644 index 0000000..ffd7344 --- /dev/null +++ b/.changeset/quiet-jaguars-envelope.md @@ -0,0 +1,5 @@ +--- +"@openzeppelin/ui-cli": patch +--- + +Add a stable JSON output envelope to every `oz-ui --json` payload (success and error). Each payload now ships with `schemaVersion` and `cli: { name, version }` so agent consumers (e.g. the `scaffold-dapp` and `migrate-to-oz-uikit` skills) can detect contract drift the same way they do for `migration-manifest.json`. Existing payload fields are unchanged. Internally, `oz-ui create`'s template generator was split from a single 650-line module into a `templates/` directory (per-layout modules, shared helpers, embedded asset) so new layouts and contents can be added without churning a mega-file. diff --git a/packages/cli/README.md b/packages/cli/README.md index 8c37134..d2f9f3d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -124,6 +124,46 @@ oz-ui migrate complete --manifest migration-manifest.json --task oz-ui migrate fail --manifest migration-manifest.json --task --reason "" ``` +## JSON output envelope + +Every `oz-ui --json` payload (success **and** error) ships with a stable envelope so agents and CI can detect drift without parsing command-specific fields: + + +| Field | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------- | +| `schemaVersion` | Semantic version of the JSON envelope contract. Bumped on breaking changes to the agent-facing payloads. | +| `cli.name` | Always `@openzeppelin/ui-cli`. | +| `cli.version` | Version of the binary that produced the output. | +| `ok` | `true` for successful runs, `false` for errors. | +| `action` | Command identifier such as `create`, `migrate-init`, `migrate-analyze`. Absent on error payloads. | + + +Example success payload (truncated): + +```json +{ + "schemaVersion": "1.0.0", + "cli": { "name": "@openzeppelin/ui-cli", "version": "0.1.0" }, + "ok": true, + "action": "create", + "preset": "dapp", + "filesWritten": ["package.json", "src/main.tsx", "src/App.tsx"] +} +``` + +Example error payload: + +```json +{ + "schemaVersion": "1.0.0", + "cli": { "name": "@openzeppelin/ui-cli", "version": "0.1.0" }, + "ok": false, + "error": "Project name is required." +} +``` + +Agent guidance: read `schemaVersion` once at startup and refuse to operate when the major version is newer than supported, mirroring the `migration-manifest.json` versioning model. + ## Development ```bash diff --git a/packages/cli/src/commands/create.test.ts b/packages/cli/src/commands/create.test.ts index f342ca3..55a8c41 100644 --- a/packages/cli/src/commands/create.test.ts +++ b/packages/cli/src/commands/create.test.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { Command } from 'commander'; import { afterEach, describe, expect, it } from 'vitest'; +import { CLI_PACKAGE_NAME, JSON_SCHEMA_VERSION } from './migrate/json-results'; + import { resolveCreateOptions } from '../create/options'; import { resolveCreateAppSpec } from '../create/recipes'; import { scaffoldProject } from '../create/scaffold'; @@ -69,6 +71,8 @@ describe('create command', () => { expect(payload.action).toBe('create'); expect(payload.ok).toBe(true); + expect(payload.schemaVersion).toBe(JSON_SCHEMA_VERSION); + expect(payload.cli).toEqual({ name: CLI_PACKAGE_NAME, version: expect.any(String) }); expect(payload.preset).toBe('dapp'); expect(payload.wallet).toBe('custom'); expect(payload.routing).toBe('none'); diff --git a/packages/cli/src/commands/migrate/commands-json.test.ts b/packages/cli/src/commands/migrate/commands-json.test.ts index 5121bd3..d745498 100644 --- a/packages/cli/src/commands/migrate/commands-json.test.ts +++ b/packages/cli/src/commands/migrate/commands-json.test.ts @@ -13,6 +13,7 @@ import { registerDoctorCommand } from './doctor'; import { registerExecuteCommand } from './execute'; import { registerFailCommand } from './fail'; import { registerInitCommand } from './init'; +import { CLI_PACKAGE_NAME, JSON_SCHEMA_VERSION } from './json-results'; import { registerPlanCommand } from './plan'; import { registerStatusCommand } from './status'; @@ -139,6 +140,8 @@ describe('migrate command JSON output', () => { const payload = JSON.parse(stdout); expect(payload.action).toBe('migrate-init'); expect(payload.ok).toBe(true); + expect(payload.schemaVersion).toBe(JSON_SCHEMA_VERSION); + expect(payload.cli).toEqual({ name: CLI_PACKAGE_NAME, version: expect.any(String) }); expect(payload.project).toBe(dir); expect(payload.agentAssetProfiles).toEqual(['standard', 'claude']); expect(payload.agentProfileSelectionWritten).toBe('.oz-ui-migrate.json'); diff --git a/packages/cli/src/commands/migrate/json-results.ts b/packages/cli/src/commands/migrate/json-results.ts index e09120e..3efdff2 100644 --- a/packages/cli/src/commands/migrate/json-results.ts +++ b/packages/cli/src/commands/migrate/json-results.ts @@ -3,6 +3,29 @@ export interface JsonCommandResult { action: TAction; } +/** + * Stable identity envelope injected by the JSON printer onto every command result. + * + * Agent consumers (e.g. the `scaffold-dapp` and `migrate-to-oz-uikit` skills) read + * `schemaVersion` to detect breaking changes and `cli` to know which binary produced + * the output. The shape mirrors `migration-manifest.json` (`schemaVersion` / + * `catalogVersion`) so all `oz-ui` artifacts share one drift-detection mechanism. + */ +export interface JsonEnvelope { + schemaVersion: string; + cli: { name: string; version: string }; +} + +/** + * The shape an agent sees on stdout: the in-process command result plus the + * stable envelope. Construct results as `JsonCommandResult` and let the printer + * produce `JsonCommandOutput`. + */ +export type JsonCommandOutput = JsonCommandResult & JsonEnvelope; + +export const JSON_SCHEMA_VERSION = '1.0.0'; +export const CLI_PACKAGE_NAME = '@openzeppelin/ui-cli'; + export interface JsonTaskStateSummary { id: string; phase: string; diff --git a/packages/cli/src/create/templates.ts b/packages/cli/src/create/templates.ts deleted file mode 100644 index c22096b..0000000 --- a/packages/cli/src/create/templates.ts +++ /dev/null @@ -1,652 +0,0 @@ -import { packageJson } from './package-json'; -import { resolveCreateAppSpec } from './recipes'; -import { - appConfig, - indexCss, - ozConfig, - ozProviders, - ozRuntime, - rainbowKitConfig, - runtimeStatus, -} from './support-files'; -import type { CreateAppSpec, CreateFile, ResolvedCreateOptions } from './types'; -import { viteConfig } from './vite-template'; - -const OZ_LOGO_BLACK_BG_SVG = ` - - - - - - - - - - - - - - - - -`; - -function mainTsx(spec: CreateAppSpec): string { - const imports = [ - "import React from 'react';", - "import { createRoot } from 'react-dom/client';", - spec.hasTheme ? "import { ThemeProvider } from 'next-themes';" : '', - spec.hasToasts ? "import { Toaster } from '@openzeppelin/ui-components';" : '', - spec.hasWallet ? "import { initializeAppConfig } from './oz/config';" : '', - spec.hasWallet ? "import { OzProviders } from './oz/OzProviders';" : '', - "import App from './App';", - "import './index.css';", - ].filter(Boolean); - const appTree = spec.hasWallet ? '' : ''; - const themedTree = spec.hasTheme - ? `${appTree}` - : appTree; - const toaster = spec.hasToasts ? '\n ' : ''; - - return `${imports.join('\n')} - -async function bootstrap() { -${spec.hasWallet ? ' await initializeAppConfig();\n' : ''} createRoot(document.getElementById('root')!).render( - - ${themedTree}${toaster} - - ); -} - -void bootstrap(); -`; -} - -function appTsx(spec: CreateAppSpec): string { - if (spec.content === 'wizard') { - return wizardAppTsx(spec); - } - if (spec.layout === 'sidebar-shell') { - return appShellTsx(spec); - } - return dappTsx(spec); -} - -function sharedAppImports(spec: CreateAppSpec): string[] { - return [ - spec.hasTooltips ? 'TooltipProvider' : '', - 'Button', - 'Card', - 'CardContent', - 'CardDescription', - 'CardHeader', - 'CardTitle', - 'Footer', - spec.layout === 'sidebar-shell' ? 'Header' : '', - ].filter(Boolean); -} - -function walletHeaderImport(spec: CreateAppSpec): string { - return spec.hasWallet ? "import { WalletConnectionUI } from '@openzeppelin/ui-react';\n" : ''; -} - -function runtimeStatusImport(spec: CreateAppSpec): string { - return spec.hasStatusPanel ? "import { RuntimeStatus } from './components/RuntimeStatus';\n" : ''; -} - -function walletHeader(spec: CreateAppSpec): string { - return spec.hasWallet ? '' : 'null'; -} - -function statusPanel(spec: CreateAppSpec): string { - return spec.hasStatusPanel ? '' : ''; -} - -function maybeTooltipWrapper(spec: CreateAppSpec, body: string): string { - return spec.hasTooltips ? `\n${body}\n ` : body.trimStart(); -} - -function dappTsx(spec: CreateAppSpec): string { - const imports = sharedAppImports(spec); - const body = spec.content === 'landing' ? minimalBody() : dappBody(spec); - return `import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; -${walletHeaderImport(spec)}${runtimeStatusImport(spec)} -export default function App() { - return ( - ${maybeTooltipWrapper(spec, body)} - ); -} -`; -} - -function minimalBody(): string { - return `
-
- - - OpenZeppelin UI app - Vite + React + TypeScript with OpenZeppelin UI styles. - - - - - -
-
`; -} - -function dappBody(spec: CreateAppSpec): string { - return `
-
-
- OpenZeppelin -
-
-
${spec.title}
-
${spec.subtitle ?? ''}
-
-
{${walletHeader(spec)}}
-
-
-
- - - Your dApp shell is ready - - This starter proves the selected OpenZeppelin UI wiring and gives you a clean place - to add contract interactions. - - - -

- Edit src/oz to customize - adapters, wallet config, networks, and runtime behavior. -

- -
-
- ${statusPanel(spec)} -
-
-
`; -} - -function appShellTsx(spec: CreateAppSpec): string { - const imports = [ - ...sharedAppImports(spec), - 'SidebarButton', - 'SidebarGroup', - 'SidebarLayout', - 'SidebarSection', - ]; - return `import { useState } from 'react'; -import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; -import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; -${walletHeaderImport(spec)}${runtimeStatusImport(spec)} -const primaryRoutes = [ - { path: '/', label: 'Dashboard', description: 'Track activity, network status, and app health.' }, - { path: '/activity', label: 'Activity', description: 'Review recent user and contract events.' }, - { path: '/deployments', label: 'Deployments', description: 'Monitor contracts and environments.' }, -]; - -const buildRoutes = [ - { path: '/contracts', label: 'Contracts', description: 'Organize ABI-backed contract screens.' }, - { path: '/workflows', label: 'Workflows', description: 'Model multi-step transaction flows.' }, -]; - -const manageRoutes = [ - { path: '/team', label: 'Team', description: 'Invite collaborators and manage access.' }, - { path: '/settings', label: 'Settings', description: 'Customize generated providers in src/oz.' }, -]; - -function Dashboard() { - return ( -
- - - Dashboard - Your route-aware app shell is ready. - - - - - - ${statusPanel(spec)} -
- ); -} - -function PlaceholderPage({ title, description }: { title: string; description: string }) { - return ( - - - {title} - {description} - - - - - - ); -} - -function SidebarLogo() { - return ( -
- OpenZeppelin Logo -
- ); -} - -function SidebarNav({ onNavigate }: { onNavigate?: () => void }) { - const location = useLocation(); - const navigate = useNavigate(); - const goTo = (path: string) => { - navigate(path); - onNavigate?.(); - }; - - return ( -
- - {primaryRoutes.map((item) => ( - goTo(item.path)} - > - {item.label} - - ))} - - - - - {buildRoutes.map((item) => ( - goTo(item.path)} - > - {item.label} - - ))} - - - {manageRoutes.map((item) => ( - goTo(item.path)} - > - {item.label} - - ))} - - - - - - Documentation - - - Templates - - -
- ); -} - -function Shell() { - const [mobileOpen, setMobileOpen] = useState(false); - return ( -
- } - mobileOpen={mobileOpen} - onMobileOpenChange={setMobileOpen} - mobileAriaLabel="Navigation menu" - background="bg-sidebar" - width={280} - > - setMobileOpen(false)} /> - -
-
setMobileOpen(true)} - rightContent={${walletHeader(spec)}} - /> -
-
-
- - } /> - {[...primaryRoutes.slice(1), ...buildRoutes, ...manageRoutes].map((item) => ( - } - /> - ))} - -
-
-
-
-
-
- ); -} - -export default function App() { - return ( - - ${maybeTooltipWrapper(spec, '')} - - ); -} -`; -} - -function wizardAppTsx(spec: CreateAppSpec): string { - if (spec.layout === 'sidebar-shell') { - return wizardSidebarAppTsx(spec); - } - - const imports = [...sharedAppImports(spec), 'WizardLayout', 'type WizardStepConfig']; - return `import { useState } from 'react'; -import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; -${walletHeaderImport(spec)}${runtimeStatusImport(spec)} -function IntroStep() { - return ( - - - Start your workflow - Use this step to collect the first decision from your user. - - - - - - ); -} - -function RuntimeStep() { - return ${statusPanel(spec) || 'Runtime'}; -} - -export default function App() { - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const steps: WizardStepConfig[] = [ - { id: 'intro', title: 'Intro', component: }, - { id: 'runtime', title: 'Runtime', component: }, - { - id: 'ship', - title: 'Ship', - component: ( - - - Ship it - Replace these placeholders with your product workflow. - - - - - - ), - }, - ]; - - return ( - ${maybeTooltipWrapper( - spec, - `
-
-
- OpenZeppelin -
-
-
${spec.title}
-
${spec.subtitle ?? ''}
-
-
{${walletHeader(spec)}}
-
-
-
-
- setCurrentStepIndex(0)} - onComplete={() => setCurrentStepIndex(0)} - lastStepLabel="Finish" - lastStepSecondaryLabel="Preview" - onLastStepSecondary={() => setCurrentStepIndex(0)} - variant="vertical" - /> -
-
-
-
` - )} - ); -} -`; -} - -function wizardSidebarAppTsx(spec: CreateAppSpec): string { - const imports = [ - ...sharedAppImports(spec), - 'SidebarButton', - 'SidebarLayout', - 'SidebarSection', - 'WizardLayout', - 'type WizardStepConfig', - ]; - return `import { useState } from 'react'; -import { BrowserRouter } from 'react-router-dom'; -import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; -${walletHeaderImport(spec)}${runtimeStatusImport(spec)} -function IntroStep() { - return ( - - - Start your workflow - Use this step to collect the first decision from your user. - - - - - - ); -} - -function RuntimeStep() { - return ${statusPanel(spec) || 'Runtime'}; -} - -function WizardContent() { - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const steps: WizardStepConfig[] = [ - { id: 'intro', title: 'Intro', component: }, - { id: 'runtime', title: 'Runtime', component: }, - { - id: 'ship', - title: 'Ship', - component: ( - - - Ship it - Replace these placeholders with your product workflow. - - - - - - ), - }, - ]; - - return ( - setCurrentStepIndex(0)} - onComplete={() => setCurrentStepIndex(0)} - lastStepLabel="Finish" - lastStepSecondaryLabel="Preview" - onLastStepSecondary={() => setCurrentStepIndex(0)} - variant="vertical" - /> - ); -} - -function SidebarLogo() { - return ( -
- OpenZeppelin Logo -
- ); -} - -function WizardSidebar() { - return ( -
- - Wizard - - Review - - - - - Documentation - - -
- ); -} - -function Shell() { - const [mobileOpen, setMobileOpen] = useState(false); - - return ( -
- } - mobileOpen={mobileOpen} - onMobileOpenChange={setMobileOpen} - mobileAriaLabel="Navigation menu" - background="bg-sidebar" - width={280} - > - - -
-
setMobileOpen(true)} - rightContent={${walletHeader(spec)}} - /> -
-
-
-
- -
-
-
-
-
-
-
- ); -} - -export default function App() { - return ( - - ${maybeTooltipWrapper(spec, '')} - - ); -} -`; -} - -/** - * - */ -export function buildCreateFiles(options: ResolvedCreateOptions): CreateFile[] { - const spec = resolveCreateAppSpec(options); - const files: CreateFile[] = [ - { path: 'package.json', content: packageJson(options, spec) }, - { - path: 'index.html', - content: `
\n`, - }, - { - path: 'tsconfig.json', - content: `${JSON.stringify( - { - compilerOptions: { - target: 'ES2022', - useDefineForClassFields: true, - lib: ['ES2022', 'DOM', 'DOM.Iterable'], - allowJs: false, - skipLibCheck: true, - esModuleInterop: true, - allowSyntheticDefaultImports: true, - strict: true, - forceConsistentCasingInFileNames: true, - module: 'ESNext', - moduleResolution: 'Bundler', - resolveJsonModule: true, - isolatedModules: true, - noEmit: true, - jsx: 'react-jsx', - types: ['vite/client'], - baseUrl: '.', - paths: { - '@/*': ['./src/*'], - }, - }, - include: ['src'], - references: [], - }, - null, - 2 - )}\n`, - }, - { path: 'vite.config.ts', content: viteConfig(spec) }, - { path: 'src/main.tsx', content: mainTsx(spec) }, - { path: 'src/App.tsx', content: appTsx(spec) }, - { path: 'src/index.css', content: indexCss() }, - { path: 'src/vite-env.d.ts', content: '/// \n' }, - ]; - - if (spec.hasWallet) { - files.push( - { path: 'public/app.config.json', content: appConfig(options) }, - { path: 'src/oz/config.ts', content: ozConfig() }, - { path: 'src/oz/runtime.ts', content: ozRuntime(options) }, - { path: 'src/oz/OzProviders.tsx', content: ozProviders() } - ); - } - - if (spec.requiresLogoAsset) { - files.push({ path: 'public/OZ-Logo-BlackBG.svg', content: OZ_LOGO_BLACK_BG_SVG }); - } - - if (spec.hasStatusPanel) { - files.push({ path: 'src/components/RuntimeStatus.tsx', content: runtimeStatus() }); - } - - if (options.wallet === 'rainbowkit') { - files.push({ path: 'src/oz/wallet/rainbowkit.config.ts', content: rainbowKitConfig(options) }); - } - - return files; -} diff --git a/packages/cli/src/create/templates/app.ts b/packages/cli/src/create/templates/app.ts new file mode 100644 index 0000000..3f821c2 --- /dev/null +++ b/packages/cli/src/create/templates/app.ts @@ -0,0 +1,16 @@ +import { appShellTsx } from './layouts/app-shell'; +import { dappAppTsx } from './layouts/dapp'; +import { wizardAppTsx } from './layouts/wizard'; + +import type { CreateAppSpec } from '../types'; + +/** + * Selects the layout-specific `App.tsx` renderer for a given recipe. Content + * wins over layout: a wizard recipe always renders wizard content, even when + * `layout === 'sidebar-shell'`. + */ +export function appTsx(spec: CreateAppSpec): string { + if (spec.content === 'wizard') return wizardAppTsx(spec); + if (spec.layout === 'sidebar-shell') return appShellTsx(spec); + return dappAppTsx(spec); +} diff --git a/packages/cli/src/create/templates/assets.ts b/packages/cli/src/create/templates/assets.ts new file mode 100644 index 0000000..83b87b7 --- /dev/null +++ b/packages/cli/src/create/templates/assets.ts @@ -0,0 +1,23 @@ +/** + * Static OpenZeppelin logo asset embedded into generated projects whose layout + * renders a header (topbar or sidebar shell). Inlined as a string literal to + * keep the CLI bundle self-contained and avoid a runtime file lookup. + */ +export const OZ_LOGO_BLACK_BG_SVG = ` + + + + + + + + + + + + + + + + +`; diff --git a/packages/cli/src/create/templates/index.ts b/packages/cli/src/create/templates/index.ts new file mode 100644 index 0000000..01dd10b --- /dev/null +++ b/packages/cli/src/create/templates/index.ts @@ -0,0 +1,95 @@ +import { packageJson } from '../package-json'; +import { resolveCreateAppSpec } from '../recipes'; +import { + appConfig, + indexCss, + ozConfig, + ozProviders, + ozRuntime, + rainbowKitConfig, + runtimeStatus, +} from '../support-files'; +import type { CreateFile, ResolvedCreateOptions } from '../types'; +import { viteConfig } from '../vite-template'; +import { appTsx } from './app'; +import { OZ_LOGO_BLACK_BG_SVG } from './assets'; +import { mainTsx } from './main'; + +function tsconfigJson(): string { + return `${JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + useDefineForClassFields: true, + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + allowJs: false, + skipLibCheck: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + strict: true, + forceConsistentCasingInFileNames: true, + module: 'ESNext', + moduleResolution: 'Bundler', + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + types: ['vite/client'], + baseUrl: '.', + paths: { + '@/*': ['./src/*'], + }, + }, + include: ['src'], + references: [], + }, + null, + 2 + )}\n`; +} + +/** + * Builds the full set of files emitted by `oz-ui create`. The recipe layer + * (`resolveCreateAppSpec`) decides app shape; this function decides which files + * are needed and delegates content rendering to the per-domain modules + * (layouts, support files, package manifest, vite config). + */ +export function buildCreateFiles(options: ResolvedCreateOptions): CreateFile[] { + const spec = resolveCreateAppSpec(options); + const files: CreateFile[] = [ + { path: 'package.json', content: packageJson(options, spec) }, + { + path: 'index.html', + content: `
\n`, + }, + { path: 'tsconfig.json', content: tsconfigJson() }, + { path: 'vite.config.ts', content: viteConfig(spec) }, + { path: 'src/main.tsx', content: mainTsx(spec) }, + { path: 'src/App.tsx', content: appTsx(spec) }, + { path: 'src/index.css', content: indexCss() }, + { path: 'src/vite-env.d.ts', content: '/// \n' }, + ]; + + if (spec.hasWallet) { + files.push( + { path: 'public/app.config.json', content: appConfig(options) }, + { path: 'src/oz/config.ts', content: ozConfig() }, + { path: 'src/oz/runtime.ts', content: ozRuntime(options) }, + { path: 'src/oz/OzProviders.tsx', content: ozProviders() } + ); + } + + if (spec.requiresLogoAsset) { + files.push({ path: 'public/OZ-Logo-BlackBG.svg', content: OZ_LOGO_BLACK_BG_SVG }); + } + + if (spec.hasStatusPanel) { + files.push({ path: 'src/components/RuntimeStatus.tsx', content: runtimeStatus() }); + } + + if (options.wallet === 'rainbowkit') { + files.push({ path: 'src/oz/wallet/rainbowkit.config.ts', content: rainbowKitConfig(options) }); + } + + return files; +} diff --git a/packages/cli/src/create/templates/layouts/app-shell.ts b/packages/cli/src/create/templates/layouts/app-shell.ts new file mode 100644 index 0000000..8934fcc --- /dev/null +++ b/packages/cli/src/create/templates/layouts/app-shell.ts @@ -0,0 +1,191 @@ +import type { CreateAppSpec } from '../../types'; +import { + maybeTooltipWrapper, + runtimeStatusImport, + sharedAppImports, + statusPanel, + walletHeader, + walletHeaderImport, +} from '../shared'; + +/** + * Renders the generated `src/App.tsx` for the sidebar shell layout: header, + * `SidebarLayout`, route-aware navigation, and placeholder routes. + */ +export function appShellTsx(spec: CreateAppSpec): string { + const imports = [ + ...sharedAppImports(spec), + 'SidebarButton', + 'SidebarGroup', + 'SidebarLayout', + 'SidebarSection', + ]; + return `import { useState } from 'react'; +import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; +${walletHeaderImport(spec)}${runtimeStatusImport(spec)} +const primaryRoutes = [ + { path: '/', label: 'Dashboard', description: 'Track activity, network status, and app health.' }, + { path: '/activity', label: 'Activity', description: 'Review recent user and contract events.' }, + { path: '/deployments', label: 'Deployments', description: 'Monitor contracts and environments.' }, +]; + +const buildRoutes = [ + { path: '/contracts', label: 'Contracts', description: 'Organize ABI-backed contract screens.' }, + { path: '/workflows', label: 'Workflows', description: 'Model multi-step transaction flows.' }, +]; + +const manageRoutes = [ + { path: '/team', label: 'Team', description: 'Invite collaborators and manage access.' }, + { path: '/settings', label: 'Settings', description: 'Customize generated providers in src/oz.' }, +]; + +function Dashboard() { + return ( +
+ + + Dashboard + Your route-aware app shell is ready. + + + + + + ${statusPanel(spec)} +
+ ); +} + +function PlaceholderPage({ title, description }: { title: string; description: string }) { + return ( + + + {title} + {description} + + + + + + ); +} + +function SidebarLogo() { + return ( +
+ OpenZeppelin Logo +
+ ); +} + +function SidebarNav({ onNavigate }: { onNavigate?: () => void }) { + const location = useLocation(); + const navigate = useNavigate(); + const goTo = (path: string) => { + navigate(path); + onNavigate?.(); + }; + + return ( +
+ + {primaryRoutes.map((item) => ( + goTo(item.path)} + > + {item.label} + + ))} + + + + + {buildRoutes.map((item) => ( + goTo(item.path)} + > + {item.label} + + ))} + + + {manageRoutes.map((item) => ( + goTo(item.path)} + > + {item.label} + + ))} + + + + + + Documentation + + + Templates + + +
+ ); +} + +function Shell() { + const [mobileOpen, setMobileOpen] = useState(false); + return ( +
+ } + mobileOpen={mobileOpen} + onMobileOpenChange={setMobileOpen} + mobileAriaLabel="Navigation menu" + background="bg-sidebar" + width={280} + > + setMobileOpen(false)} /> + +
+
setMobileOpen(true)} + rightContent={${walletHeader(spec)}} + /> +
+
+
+ + } /> + {[...primaryRoutes.slice(1), ...buildRoutes, ...manageRoutes].map((item) => ( + } + /> + ))} + +
+
+
+
+
+
+ ); +} + +export default function App() { + return ( + + ${maybeTooltipWrapper(spec, '')} + + ); +} +`; +} diff --git a/packages/cli/src/create/templates/layouts/dapp.ts b/packages/cli/src/create/templates/layouts/dapp.ts new file mode 100644 index 0000000..9834627 --- /dev/null +++ b/packages/cli/src/create/templates/layouts/dapp.ts @@ -0,0 +1,79 @@ +import type { CreateAppSpec } from '../../types'; +import { + maybeTooltipWrapper, + runtimeStatusImport, + sharedAppImports, + statusPanel, + walletHeader, + walletHeaderImport, +} from '../shared'; + +function minimalBody(): string { + return `
+
+ + + OpenZeppelin UI app + Vite + React + TypeScript with OpenZeppelin UI styles. + + + + + +
+
`; +} + +function dappBody(spec: CreateAppSpec): string { + return `
+
+
+ OpenZeppelin +
+
+
${spec.title}
+
${spec.subtitle ?? ''}
+
+
{${walletHeader(spec)}}
+
+
+
+ + + Your dApp shell is ready + + This starter proves the selected OpenZeppelin UI wiring and gives you a clean place + to add contract interactions. + + + +

+ Edit src/oz to customize + adapters, wallet config, networks, and runtime behavior. +

+ +
+
+ ${statusPanel(spec)} +
+
+
`; +} + +/** + * Renders the generated `src/App.tsx` for the topbar layout, including both the + * minimal landing variant (`content === 'landing'`) and the default dApp dashboard. + * Sidebar and wizard layouts live in their own modules. + */ +export function dappAppTsx(spec: CreateAppSpec): string { + const imports = sharedAppImports(spec); + const body = spec.content === 'landing' ? minimalBody() : dappBody(spec); + return `import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; +${walletHeaderImport(spec)}${runtimeStatusImport(spec)} +export default function App() { + return ( + ${maybeTooltipWrapper(spec, body)} + ); +} +`; +} diff --git a/packages/cli/src/create/templates/layouts/wizard.ts b/packages/cli/src/create/templates/layouts/wizard.ts new file mode 100644 index 0000000..34de905 --- /dev/null +++ b/packages/cli/src/create/templates/layouts/wizard.ts @@ -0,0 +1,241 @@ +import type { CreateAppSpec } from '../../types'; +import { + maybeTooltipWrapper, + runtimeStatusImport, + sharedAppImports, + statusPanel, + walletHeader, + walletHeaderImport, +} from '../shared'; + +function wizardTopbarTsx(spec: CreateAppSpec): string { + const imports = [...sharedAppImports(spec), 'WizardLayout', 'type WizardStepConfig']; + return `import { useState } from 'react'; +import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; +${walletHeaderImport(spec)}${runtimeStatusImport(spec)} +function IntroStep() { + return ( + + + Start your workflow + Use this step to collect the first decision from your user. + + + + + + ); +} + +function RuntimeStep() { + return ${statusPanel(spec) || 'Runtime'}; +} + +export default function App() { + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const steps: WizardStepConfig[] = [ + { id: 'intro', title: 'Intro', component: }, + { id: 'runtime', title: 'Runtime', component: }, + { + id: 'ship', + title: 'Ship', + component: ( + + + Ship it + Replace these placeholders with your product workflow. + + + + + + ), + }, + ]; + + return ( + ${maybeTooltipWrapper( + spec, + `
+
+
+ OpenZeppelin +
+
+
${spec.title}
+
${spec.subtitle ?? ''}
+
+
{${walletHeader(spec)}}
+
+
+
+
+ setCurrentStepIndex(0)} + onComplete={() => setCurrentStepIndex(0)} + lastStepLabel="Finish" + lastStepSecondaryLabel="Preview" + onLastStepSecondary={() => setCurrentStepIndex(0)} + variant="vertical" + /> +
+
+
+
` + )} + ); +} +`; +} + +function wizardSidebarTsx(spec: CreateAppSpec): string { + const imports = [ + ...sharedAppImports(spec), + 'SidebarButton', + 'SidebarLayout', + 'SidebarSection', + 'WizardLayout', + 'type WizardStepConfig', + ]; + return `import { useState } from 'react'; +import { BrowserRouter } from 'react-router-dom'; +import { ${imports.join(', ')} } from '@openzeppelin/ui-components'; +${walletHeaderImport(spec)}${runtimeStatusImport(spec)} +function IntroStep() { + return ( + + + Start your workflow + Use this step to collect the first decision from your user. + + + + + + ); +} + +function RuntimeStep() { + return ${statusPanel(spec) || 'Runtime'}; +} + +function WizardContent() { + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const steps: WizardStepConfig[] = [ + { id: 'intro', title: 'Intro', component: }, + { id: 'runtime', title: 'Runtime', component: }, + { + id: 'ship', + title: 'Ship', + component: ( + + + Ship it + Replace these placeholders with your product workflow. + + + + + + ), + }, + ]; + + return ( + setCurrentStepIndex(0)} + onComplete={() => setCurrentStepIndex(0)} + lastStepLabel="Finish" + lastStepSecondaryLabel="Preview" + onLastStepSecondary={() => setCurrentStepIndex(0)} + variant="vertical" + /> + ); +} + +function SidebarLogo() { + return ( +
+ OpenZeppelin Logo +
+ ); +} + +function WizardSidebar() { + return ( +
+ + Wizard + + Review + + + + + Documentation + + +
+ ); +} + +function Shell() { + const [mobileOpen, setMobileOpen] = useState(false); + + return ( +
+ } + mobileOpen={mobileOpen} + onMobileOpenChange={setMobileOpen} + mobileAriaLabel="Navigation menu" + background="bg-sidebar" + width={280} + > + + +
+
setMobileOpen(true)} + rightContent={${walletHeader(spec)}} + /> +
+
+
+
+ +
+
+
+
+
+
+
+ ); +} + +export default function App() { + return ( + + ${maybeTooltipWrapper(spec, '')} + + ); +} +`; +} + +/** + * Renders the generated `src/App.tsx` for wizard content. The wizard preset + * supports two outer frames (topbar or sidebar shell); the recipe's `layout` + * field decides which variant is emitted. + */ +export function wizardAppTsx(spec: CreateAppSpec): string { + return spec.layout === 'sidebar-shell' ? wizardSidebarTsx(spec) : wizardTopbarTsx(spec); +} diff --git a/packages/cli/src/create/templates/main.ts b/packages/cli/src/create/templates/main.ts new file mode 100644 index 0000000..fdc7b46 --- /dev/null +++ b/packages/cli/src/create/templates/main.ts @@ -0,0 +1,37 @@ +import type { CreateAppSpec } from '../types'; + +/** + * Renders the generated `src/main.tsx` entry point. Imports, async bootstrap + * (when wallet wiring requires `initializeAppConfig`), provider tree, and the + * top-level `` mount are all driven from the recipe. + */ +export function mainTsx(spec: CreateAppSpec): string { + const imports = [ + "import React from 'react';", + "import { createRoot } from 'react-dom/client';", + spec.hasTheme ? "import { ThemeProvider } from 'next-themes';" : '', + spec.hasToasts ? "import { Toaster } from '@openzeppelin/ui-components';" : '', + spec.hasWallet ? "import { initializeAppConfig } from './oz/config';" : '', + spec.hasWallet ? "import { OzProviders } from './oz/OzProviders';" : '', + "import App from './App';", + "import './index.css';", + ].filter(Boolean); + const appTree = spec.hasWallet ? '' : ''; + const themedTree = spec.hasTheme + ? `${appTree}` + : appTree; + const toaster = spec.hasToasts ? '\n ' : ''; + + return `${imports.join('\n')} + +async function bootstrap() { +${spec.hasWallet ? ' await initializeAppConfig();\n' : ''} createRoot(document.getElementById('root')!).render( + + ${themedTree}${toaster} + + ); +} + +void bootstrap(); +`; +} diff --git a/packages/cli/src/create/templates/shared.ts b/packages/cli/src/create/templates/shared.ts new file mode 100644 index 0000000..14a3868 --- /dev/null +++ b/packages/cli/src/create/templates/shared.ts @@ -0,0 +1,61 @@ +import type { CreateAppSpec } from '../types'; + +/** + * Imports from `@openzeppelin/ui-components` shared by every generated `App.tsx`, + * regardless of layout. Layout modules append their own imports on top of this list. + */ +export function sharedAppImports(spec: CreateAppSpec): string[] { + return [ + spec.hasTooltips ? 'TooltipProvider' : '', + 'Button', + 'Card', + 'CardContent', + 'CardDescription', + 'CardHeader', + 'CardTitle', + 'Footer', + spec.layout === 'sidebar-shell' ? 'Header' : '', + ].filter(Boolean); +} + +/** + * Adds the `WalletConnectionUI` import to a generated `App.tsx` when wallet + * wiring is enabled. Returns an empty string otherwise so the `@openzeppelin/ui-react` + * dependency is not implied by the generated source. + */ +export function walletHeaderImport(spec: CreateAppSpec): string { + return spec.hasWallet ? "import { WalletConnectionUI } from '@openzeppelin/ui-react';\n" : ''; +} + +/** + * Adds the local `RuntimeStatus` import to a generated `App.tsx` when the + * status panel feature is enabled. + */ +export function runtimeStatusImport(spec: CreateAppSpec): string { + return spec.hasStatusPanel ? "import { RuntimeStatus } from './components/RuntimeStatus';\n" : ''; +} + +/** + * Renders the JSX expression placed in the header's wallet slot. Yields the + * literal `null` placeholder for the no-wallet recipe so the template's + * `{...}` interpolation stays valid. + */ +export function walletHeader(spec: CreateAppSpec): string { + return spec.hasWallet ? '' : 'null'; +} + +/** + * Renders the JSX node placed in the status-panel slot, or an empty string + * when the feature is disabled (callers concatenate the result directly). + */ +export function statusPanel(spec: CreateAppSpec): string { + return spec.hasStatusPanel ? '' : ''; +} + +/** + * Wraps body JSX in a `TooltipProvider` when the recipe enables tooltips, and + * preserves the indentation contract callers rely on. + */ +export function maybeTooltipWrapper(spec: CreateAppSpec, body: string): string { + return spec.hasTooltips ? `\n${body}\n ` : body.trimStart(); +} diff --git a/packages/cli/src/utils/logger.ts b/packages/cli/src/utils/logger.ts index 14cd3ea..997f969 100644 --- a/packages/cli/src/utils/logger.ts +++ b/packages/cli/src/utils/logger.ts @@ -1,10 +1,38 @@ import pc from 'picocolors'; +import { CLI_VERSION } from '../branding'; +import { + CLI_PACKAGE_NAME, + JSON_SCHEMA_VERSION, + type JsonEnvelope, +} from '../commands/migrate/json-results'; + +function buildEnvelope(): JsonEnvelope { + return { + schemaVersion: JSON_SCHEMA_VERSION, + cli: { name: CLI_PACKAGE_NAME, version: CLI_VERSION }, + }; +} + /** - * + * Wrap an object payload with the stable JSON envelope. Non-object payloads + * (arrays, primitives) are returned unchanged because the envelope only makes + * sense at the top of a command-result object. + */ +function withEnvelope(payload: unknown): unknown { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return payload; + } + return { ...buildEnvelope(), ...(payload as Record) }; +} + +/** + * Print a command result as JSON to stdout, prefixed with the stable envelope + * (`schemaVersion`, `cli`). All `oz-ui --json` outputs flow through here so + * agent consumers can rely on a single envelope shape. */ export function printJson(payload: unknown): void { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(withEnvelope(payload), null, 2)}\n`); } /** @@ -13,7 +41,8 @@ export function printJson(payload: unknown): void { export function printError(error: unknown, json: boolean): never { const message = error instanceof Error ? error.message : String(error); if (json) { - process.stderr.write(JSON.stringify({ ok: false, error: message }, null, 2) + '\n'); + const payload = withEnvelope({ ok: false, error: message }); + process.stderr.write(JSON.stringify(payload, null, 2) + '\n'); } else { process.stderr.write(pc.red(`Error: ${message}`) + '\n'); } From 79f654b370292b1b1fbab13f4e7415a57c718829 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Wed, 29 Apr 2026 10:26:28 +0300 Subject: [PATCH 04/11] feat(common): add oz-ui create init and scaffold-dapp skill --- .changeset/keen-foxes-scaffold-skill.md | 5 + packages/cli/README.md | 58 ++++-- packages/cli/src/agent-assets/index.ts | 3 + .../cli/src/agent-assets/profiles.test.ts | 59 ++++++ packages/cli/src/agent-assets/profiles.ts | 83 +++++--- packages/cli/src/commands/create.ts | 152 ++------------- packages/cli/src/commands/create/init.test.ts | 127 +++++++++++++ packages/cli/src/commands/create/init.ts | 179 ++++++++++++++++++ packages/cli/src/commands/create/scaffold.ts | 149 +++++++++++++++ packages/cli/src/execution/task-executor.ts | 3 +- packages/cli/src/init/setup.ts | 7 +- .../templates/skills/scaffold-dapp/SKILL.md | 133 +++++++++++++ packages/cli/src/verification/checker.ts | 6 +- 13 files changed, 790 insertions(+), 174 deletions(-) create mode 100644 .changeset/keen-foxes-scaffold-skill.md create mode 100644 packages/cli/src/commands/create/init.test.ts create mode 100644 packages/cli/src/commands/create/init.ts create mode 100644 packages/cli/src/commands/create/scaffold.ts create mode 100644 packages/cli/src/templates/skills/scaffold-dapp/SKILL.md diff --git a/.changeset/keen-foxes-scaffold-skill.md b/.changeset/keen-foxes-scaffold-skill.md new file mode 100644 index 0000000..eb99c98 --- /dev/null +++ b/.changeset/keen-foxes-scaffold-skill.md @@ -0,0 +1,5 @@ +--- +"@openzeppelin/ui-cli": minor +--- + +Add `oz-ui create init` for installing the `scaffold-dapp` AI skill into a workspace, mirroring the assistant-asset bootstrap that `oz-ui migrate init` provides for the migrate flow. The new subcommand copies `templates/skills/scaffold-dapp/SKILL.md` into the selected agent profile destinations (`.agents/skills/scaffold-dapp`, `.claude/skills/scaffold-dapp`, `.cursor/skills/scaffold-dapp`) and records the selection in `.oz-ui-create.json` for follow-up runs. Like the migrate flow, the JSON envelope (`action: "create-init"`) carries `schemaVersion` and `cli` metadata. Internal: `agent-assets/profiles.ts` was refactored so its `skillDirectoriesForProfiles` and `expectedSkillPathsForProfiles` helpers take a `skillId` argument, letting the same registry serve both `migrate-to-oz-uikit` and `scaffold-dapp` without per-skill duplication. diff --git a/packages/cli/README.md b/packages/cli/README.md index d2f9f3d..001f961 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -36,6 +36,19 @@ The `oz-ui` binary is organized by **command group** (e.g. `oz-ui …`). The create internals use normalized layout/content recipes so feature combinations such as `--preset wizard --with sidebar` keep the wizard as the primary content while changing only the surrounding shell. See the [create recipes architecture](./docs/CREATE_RECIPES.md). +All `oz-ui create` subcommands support `--json` and return machine-readable payloads with an `action` field plus command-specific data. Invoke each row as `oz-ui create ` (or `npx` / `pnpm exec` as needed). The bare command (no subcommand) scaffolds a project; `init` only installs the AI skill. + + +| Subcommand | Description | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[project-name]` | Default action (no subcommand). Scaffold a new Vite + React + TypeScript app with OpenZeppelin UI wiring. Human mode runs an interactive prompt flow; agents and CI should pass `--yes --json`. | +| `init` | Install the `scaffold-dapp` AI skill into a workspace so an assistant can orchestrate `oz-ui create` from natural-language intent. Does **not** generate project files. Requires `--agent-profile` on first run. | + + +#### `create [project-name]` + +Generates a runnable app under `/` with the selected preset, wallet, and feature toggles. + | Preset | Description | | ----------- | ----------------------------------------------------------------------------------------------------------------- | @@ -45,8 +58,6 @@ The create internals use normalized layout/content recipes so feature combinatio | `wizard` | Adds a generic multi-step wizard shell for guided workflows. | -Key options: - | Option | Description | | ---------------------- | --------------------------------------------------------------------------------------------- | @@ -79,7 +90,32 @@ Generated apps keep OpenZeppelin-specific integration code in `src/oz/` so it is - `src/oz/config.ts` initializes `appConfigService` - `public/app.config.json` stores runtime service defaults -Create documentation: +#### `create init` + +Installs the `scaffold-dapp` skill template into the selected agent profile destinations and records the selection in `.oz-ui-create.json` so follow-up runs can reuse it. Run it from a *parent* directory like `~/projects` — the skill is most useful before a new project exists. + + +| Option | Description | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--target ` | Workspace directory where skill assets and `.oz-ui-create.json` are written. Defaults to the current directory. | +| `--agent-profile ` | Comma-separated list: `standard`, `claude`, `legacy-cursor`, `all`, or `none`. Required on first run; on subsequent runs you can omit it to reuse the stored selection. | +| `--json` | Emit a machine-readable payload with `action: "create-init"`. | + + +Examples: + +```bash +# Install for Claude Code and the shared .agents directory +oz-ui create init --agent-profile claude,standard + +# Install for legacy Cursor (.cursor/skills) only +oz-ui create init --agent-profile legacy-cursor + +# Re-run after editing the skill template; reuses the stored selection +oz-ui create init +``` + +#### Create documentation - [Create generation matrix](./docs/CREATE_MATRIX.md) - User-facing matrix of presets, options, features, generated files, and combinations. - [Create recipes architecture](./docs/CREATE_RECIPES.md) - How `oz-ui create` resolves presets, layouts, content, and feature combinations. @@ -88,7 +124,7 @@ Create documentation: > **Experimental** — Migration features are under active development. Do not expect perfect automation on every project. The workflow uses deterministic `oz-ui migrate` commands and has been exercised on small- to medium-sized apps; it should still beat a fully manual migration. For the full disclaimer and an **LLM-assisted** flow (prompts, manifest workflow, skills), see the [LLM-led migration reference](./docs/LLM_MIGRATION_REFERENCE.md) ([on GitHub](https://github.com/OpenZeppelin/openzeppelin-ui/blob/main/packages/cli/docs/LLM_MIGRATION_REFERENCE.md)). -All `oz-ui migrate` * subcommands support `--json` and return machine-readable payloads with an `action` field plus command-specific data. Invoke each row as `oz-ui migrate ` (or `npx` / `pnpm exec` as needed). +All `oz-ui migrate` subcommands support `--json` and return machine-readable payloads with an `action` field plus command-specific data. Invoke each row as `oz-ui migrate ` (or `npx` / `pnpm exec` as needed). | Subcommand | Description | @@ -129,13 +165,13 @@ oz-ui migrate fail --manifest migration-manifest.json --task --reason Every `oz-ui --json` payload (success **and** error) ships with a stable envelope so agents and CI can detect drift without parsing command-specific fields: -| Field | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------- | -| `schemaVersion` | Semantic version of the JSON envelope contract. Bumped on breaking changes to the agent-facing payloads. | -| `cli.name` | Always `@openzeppelin/ui-cli`. | -| `cli.version` | Version of the binary that produced the output. | -| `ok` | `true` for successful runs, `false` for errors. | -| `action` | Command identifier such as `create`, `migrate-init`, `migrate-analyze`. Absent on error payloads. | +| Field | Description | +| --------------- | ---------------------------------------------------------------------------------------------------------------- | +| `schemaVersion` | Semantic version of the JSON envelope contract. Bumped on breaking changes to the agent-facing payloads. | +| `cli.name` | Always `@openzeppelin/ui-cli`. | +| `cli.version` | Version of the binary that produced the output. | +| `ok` | `true` for successful runs, `false` for errors. | +| `action` | Command identifier such as `create`, `create-init`, `migrate-init`, `migrate-analyze`. Absent on error payloads. | Example success payload (truncated): diff --git a/packages/cli/src/agent-assets/index.ts b/packages/cli/src/agent-assets/index.ts index 79e7ecc..4b05715 100644 --- a/packages/cli/src/agent-assets/index.ts +++ b/packages/cli/src/agent-assets/index.ts @@ -3,6 +3,9 @@ export { AGENT_ASSET_PROFILE_REGISTRY, AGENT_PROFILE_SELECTION_FILENAME, AGENT_ASSET_PROFILE_IDS, + CREATE_PROFILE_SELECTION_FILENAME, + MIGRATE_SKILL_ID, + SCAFFOLD_DAPP_SKILL_ID, agentDirectoriesForProfiles, expectedAgentPathsForProfiles, expectedSkillPathsForProfiles, diff --git a/packages/cli/src/agent-assets/profiles.test.ts b/packages/cli/src/agent-assets/profiles.test.ts index ffc20fd..d9fd66d 100644 --- a/packages/cli/src/agent-assets/profiles.test.ts +++ b/packages/cli/src/agent-assets/profiles.test.ts @@ -5,9 +5,14 @@ import { afterEach, describe, expect, it } from 'vitest'; import { AGENT_PROFILE_SELECTION_FILENAME, + CREATE_PROFILE_SELECTION_FILENAME, + expectedSkillPathsForProfiles, + MIGRATE_SKILL_ID, parseAgentProfileArg, readAgentProfileSelection, resolveAgentProfilesForInit, + SCAFFOLD_DAPP_SKILL_ID, + skillDirectoriesForProfiles, writeAgentProfileSelection, } from './profiles'; @@ -89,3 +94,57 @@ describe('resolveAgentProfilesForInit', () => { ); }); }); + +describe('skill-id-aware path helpers', () => { + it('builds the expected skill paths per profile + skill id', () => { + expect(skillDirectoriesForProfiles(['standard'], MIGRATE_SKILL_ID)).toEqual([ + '.agents/skills/migrate-to-oz-uikit', + ]); + expect(skillDirectoriesForProfiles(['claude'], SCAFFOLD_DAPP_SKILL_ID)).toEqual([ + '.claude/skills/scaffold-dapp', + ]); + expect(skillDirectoriesForProfiles(['legacy-cursor'], SCAFFOLD_DAPP_SKILL_ID)).toEqual([ + '.cursor/skills/scaffold-dapp', + ]); + }); + + it('de-duplicates identical directories across profiles', () => { + expect(skillDirectoriesForProfiles(['standard', 'standard'], SCAFFOLD_DAPP_SKILL_ID)).toEqual([ + '.agents/skills/scaffold-dapp', + ]); + }); + + it('routes the same profile to different paths per skill', () => { + const migrate = skillDirectoriesForProfiles(['claude'], MIGRATE_SKILL_ID); + const scaffold = skillDirectoriesForProfiles(['claude'], SCAFFOLD_DAPP_SKILL_ID); + expect(migrate).not.toEqual(scaffold); + expect(migrate[0].endsWith('migrate-to-oz-uikit')).toBe(true); + expect(scaffold[0].endsWith('scaffold-dapp')).toBe(true); + }); + + it('produces SKILL.md paths for doctor / dry-run from the directory paths', () => { + expect(expectedSkillPathsForProfiles(['claude', 'standard'], SCAFFOLD_DAPP_SKILL_ID)).toEqual([ + '.claude/skills/scaffold-dapp/SKILL.md', + '.agents/skills/scaffold-dapp/SKILL.md', + ]); + }); +}); + +describe('create-init profile persistence', () => { + it('round-trips profiles through .oz-ui-create.json', () => { + const dir = createTempDir(); + const written = writeAgentProfileSelection(dir, ['claude'], CREATE_PROFILE_SELECTION_FILENAME); + + expect(written).toBe(CREATE_PROFILE_SELECTION_FILENAME); + expect(readAgentProfileSelection(dir, CREATE_PROFILE_SELECTION_FILENAME)).toEqual(['claude']); + }); + + it('keeps create and migrate selections independent', () => { + const dir = createTempDir(); + writeAgentProfileSelection(dir, ['claude'], AGENT_PROFILE_SELECTION_FILENAME); + writeAgentProfileSelection(dir, ['standard'], CREATE_PROFILE_SELECTION_FILENAME); + + expect(readAgentProfileSelection(dir, AGENT_PROFILE_SELECTION_FILENAME)).toEqual(['claude']); + expect(readAgentProfileSelection(dir, CREATE_PROFILE_SELECTION_FILENAME)).toEqual(['standard']); + }); +}); diff --git a/packages/cli/src/agent-assets/profiles.ts b/packages/cli/src/agent-assets/profiles.ts index d07d6fa..8964959 100644 --- a/packages/cli/src/agent-assets/profiles.ts +++ b/packages/cli/src/agent-assets/profiles.ts @@ -1,6 +1,8 @@ /** - * Assistant install targets: each profile maps to concrete paths for migration - * SKILL and agent templates (shared `.agents` layout vs native `.claude` / legacy `.cursor` mirrors). + * Assistant install targets: each profile maps to base directories under + * which skill and agent assets are copied. The skill ID is supplied by the + * caller so a single registry can serve multiple skills (`migrate-to-oz-uikit`, + * `scaffold-dapp`, …). */ import fs from 'node:fs'; @@ -8,9 +10,24 @@ import path from 'node:path'; import type { AgentAssetProfile, MigrationManifest } from '../manifest/schema'; -const SKILL_ID = 'migrate-to-oz-uikit'; +/** + * Skill identifiers shipped by `@openzeppelin/ui-cli`. Adding a new skill is + * a matter of authoring `src/templates/skills//SKILL.md` and threading a + * new constant here through the consumers that copy and verify it. + */ +export const MIGRATE_SKILL_ID = 'migrate-to-oz-uikit'; +export const SCAFFOLD_DAPP_SKILL_ID = 'scaffold-dapp'; + +/** Profile-selection persistence filename written by `oz-ui migrate init`. */ export const AGENT_PROFILE_SELECTION_FILENAME = '.oz-ui-migrate.json'; +/** + * Profile-selection persistence filename written by `oz-ui create init`. + * Same shape as `AGENT_PROFILE_SELECTION_FILENAME`; lives next to the workspace + * where scaffold-dapp skill assets are installed. + */ +export const CREATE_PROFILE_SELECTION_FILENAME = '.oz-ui-create.json'; + const AGENT_FILES = [ 'migration-analyzer.md', 'migration-executor.md', @@ -19,24 +36,26 @@ const AGENT_FILES = [ interface AgentAssetProfileDefinition { id: AgentAssetProfile; - skillDirectory: string; + /** Base directory under which `/SKILL.md` is written. */ + skillBaseDirectory: string; + /** Optional shared agent directory (subagent prompts). Some skills do not ship subagents. */ agentDirectory?: string; } export const AGENT_ASSET_PROFILE_REGISTRY = { standard: { id: 'standard', - skillDirectory: `.agents/skills/${SKILL_ID}`, + skillBaseDirectory: '.agents/skills', agentDirectory: '.cursor/agents', }, claude: { id: 'claude', - skillDirectory: `.claude/skills/${SKILL_ID}`, + skillBaseDirectory: '.claude/skills', agentDirectory: '.claude/agents', }, 'legacy-cursor': { id: 'legacy-cursor', - skillDirectory: `.cursor/skills/${SKILL_ID}`, + skillBaseDirectory: '.cursor/skills', agentDirectory: '.cursor/agents', }, } as const satisfies Record; @@ -172,34 +191,44 @@ function validateAgentProfiles(profiles: unknown): AgentAssetProfile[] { } /** - * @description Absolute path to the init persistence file for `agentAssetProfiles`. + * @description Absolute path to a profile-selection persistence file relative to the given filename. + * + * `migrate init` writes `.oz-ui-migrate.json`; `create init` writes `.oz-ui-create.json`. The shape + * is identical so the same read/write helpers can serve both. */ -export function getAgentProfileSelectionPath(projectRoot: string): string { - return path.join(projectRoot, AGENT_PROFILE_SELECTION_FILENAME); +export function getAgentProfileSelectionPath( + projectRoot: string, + filename: string = AGENT_PROFILE_SELECTION_FILENAME +): string { + return path.join(projectRoot, filename); } /** - * @description Writes the chosen profiles to disk so `migrate plan` can load them without repeating flags. + * @description Writes the chosen profiles to disk so later commands can load them without repeating flags. * @returns Relative path of the written file (for CLI output). */ export function writeAgentProfileSelection( projectRoot: string, - profiles: readonly AgentAssetProfile[] + profiles: readonly AgentAssetProfile[], + filename: string = AGENT_PROFILE_SELECTION_FILENAME ): string { - const filePath = getAgentProfileSelectionPath(projectRoot); + const filePath = getAgentProfileSelectionPath(projectRoot, filename); const payload: AgentProfileSelectionFile = { agentAssetProfiles: [...profiles], updatedAt: new Date().toISOString(), }; fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); - return AGENT_PROFILE_SELECTION_FILENAME; + return filename; } /** * @description Reads profiles written by `writeAgentProfileSelection`; used when generating the migration manifest. */ -export function readAgentProfileSelection(projectRoot: string): AgentAssetProfile[] { - const filePath = getAgentProfileSelectionPath(projectRoot); +export function readAgentProfileSelection( + projectRoot: string, + filename: string = AGENT_PROFILE_SELECTION_FILENAME +): AgentAssetProfile[] { + const filePath = getAgentProfileSelectionPath(projectRoot, filename); if (!fs.existsSync(filePath)) { throw new Error( `Agent profile selection not found. Run \`oz-ui migrate init --agent-profile --project ${projectRoot}\` before generating a plan.` @@ -223,10 +252,13 @@ export function expectedAgentPathsForProfiles(profiles: AgentAssetProfile[]): st } /** - * @description Expected SKILL.md paths for doctor / dry-run, relative to project root. + * @description Expected SKILL.md paths for doctor / dry-run, relative to project root, for the given skill id. */ -export function expectedSkillPathsForProfiles(profiles: AgentAssetProfile[]): string[] { - return skillDirectoriesForProfiles(profiles).map((directory) => `${directory}/SKILL.md`); +export function expectedSkillPathsForProfiles( + profiles: AgentAssetProfile[], + skillId: string +): string[] { + return skillDirectoriesForProfiles(profiles, skillId).map((directory) => `${directory}/SKILL.md`); } /** @@ -244,10 +276,17 @@ export function agentDirectoriesForProfiles(profiles: readonly AgentAssetProfile } /** - * @description Skill install directory per profile (under `AGENT_ASSET_PROFILE_REGISTRY`), de-duplicated. + * @description Skill install directory per profile for the given skill id (e.g. `.agents/skills/scaffold-dapp`), de-duplicated. */ -export function skillDirectoriesForProfiles(profiles: readonly AgentAssetProfile[]): string[] { +export function skillDirectoriesForProfiles( + profiles: readonly AgentAssetProfile[], + skillId: string +): string[] { return [ - ...new Set(profiles.map((profile) => AGENT_ASSET_PROFILE_REGISTRY[profile].skillDirectory)), + ...new Set( + profiles.map( + (profile) => `${AGENT_ASSET_PROFILE_REGISTRY[profile].skillBaseDirectory}/${skillId}` + ) + ), ]; } diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 95492d5..63ea007 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,144 +1,24 @@ -import path from 'node:path'; import { Command } from 'commander'; -import pc from 'picocolors'; -import type { JsonCommandResult } from './migrate/json-results'; - -import { promptForCreateOptions } from '../create/interactive'; -import { parseFeatureList, resolveCreateOptions } from '../create/options'; -import { scaffoldProject } from '../create/scaffold'; -import type { CreateEcosystem, CreatePreset, CreateRouting, CreateWallet } from '../create/types'; -import { printError, printJson } from '../utils/logger'; - -interface CreateCommandOptions { - directory?: string; - preset?: CreatePreset; - ecosystem?: CreateEcosystem; - wallet?: CreateWallet; - routing?: CreateRouting; - with?: string; - without?: string; - packageManager?: 'npm' | 'pnpm' | 'yarn'; - skipInstall?: boolean; - force?: boolean; - yes?: boolean; - json?: boolean; -} - -interface CreateResult extends JsonCommandResult<'create'> { - projectName: string; - projectRoot: string; - preset: CreatePreset; - ecosystem: CreateEcosystem; - wallet: CreateWallet; - routing: CreateRouting; - features: string[]; - impliedFeatures: Record; - filesWritten: string[]; - filesSkipped: string[]; - packageManager: string; - installCommand: string | null; - installRan: boolean; - nextSteps: string[]; -} - -function renderSuccess(result: CreateResult): void { - process.stdout.write(pc.green(`Created ${result.projectName}\n`)); - process.stdout.write(` Preset: ${pc.cyan(result.preset)}\n`); - process.stdout.write(` Ecosystem: ${result.ecosystem}\n`); - process.stdout.write(` Wallet: ${result.wallet}\n`); - process.stdout.write(` Routing: ${result.routing}\n`); - process.stdout.write(` Components: ${result.features.join(', ') || 'none'}\n`); - - const impliedEntries = Object.entries(result.impliedFeatures); - if (impliedEntries.length > 0) { - process.stdout.write(` Auto-added:\n`); - for (const [feature, reason] of impliedEntries) { - process.stdout.write(` ${feature}: ${pc.dim(reason)}\n`); - } - } - - process.stdout.write(` Files written: ${result.filesWritten.length}\n`); - if (result.filesSkipped.length > 0) { - process.stdout.write(` Files skipped: ${result.filesSkipped.length}\n`); - } - - if (result.installRan) { - process.stdout.write(` Installed dependencies with ${pc.cyan(result.installCommand ?? '')}\n`); - } else { - process.stdout.write(` Dependency install skipped\n`); - } - - if (result.features.includes('wallet')) { - process.stdout.write(`\n${pc.bold('OZ wiring')}\n`); - process.stdout.write(` Edit ${pc.cyan('src/oz/runtime.ts')} for adapter/runtime behavior\n`); - process.stdout.write(` Edit ${pc.cyan('src/oz/OzProviders.tsx')} for provider wiring\n`); - process.stdout.write(` Edit ${pc.cyan('public/app.config.json')} for runtime config\n`); - } - - process.stdout.write(`\n${pc.bold('Next steps')}\n`); - for (const step of result.nextSteps) { - process.stdout.write(` ${pc.cyan(step)}\n`); - } -} +import { registerCreateInitCommand } from './create/init'; +import { registerScaffoldAction } from './create/scaffold'; /** + * Registers `oz-ui create` and its subcommands on the program. * + * The bare command (`oz-ui create [project-name]`) scaffolds a runnable Vite + * + React 19 + TypeScript dApp. The `init` subcommand (`oz-ui create init`) + * installs the `scaffold-dapp` AI skill into a workspace without generating + * any project files. + * + * Registration order matters: subcommands must be added BEFORE the bare + * action that accepts a positional `[project-name]`. Otherwise commander + * binds `init` to the optional positional and the scaffold action runs + * with `projectName="init"`. With this order, `oz-ui create init` routes + * to the subcommand and `oz-ui create my-dapp` continues to scaffold. */ export function registerCreateCommand(program: Command): void { - program - .command('create') - .argument('[project-name]', 'Project directory name') - .description('Create a Vite + React + TypeScript app with OpenZeppelin UI wiring.') - .option('-d, --directory ', 'Parent directory for the generated project', process.cwd()) - .option('--preset ', 'minimal, dapp, app-shell, or wizard') - .option('--ecosystem ', 'Target ecosystem (v1: evm)', 'evm') - .option('--wallet ', 'none, custom, or rainbowkit') - .option('--routing ', 'none or react-router') - .option( - '--with ', - 'Comma-separated features: wallet, router, sidebar, theme, toasts, tooltips, wizard, status-panel' - ) - .option('--without ', 'Comma-separated features to remove from the preset defaults') - .option('--package-manager ', 'npm, pnpm, or yarn') - .option('--skip-install', 'Generate files without installing dependencies') - .option('--force', 'Overwrite generated files in a non-empty target directory') - .option('-y, --yes', 'Use defaults and skip interactive prompts') - .option('--json', 'Emit machine-readable JSON output') - .action(async (projectName: string | undefined, options: CreateCommandOptions) => { - try { - const baseOptions = { - projectName: projectName ?? '', - targetDirectory: path.resolve(options.directory ?? process.cwd()), - preset: options.preset, - ecosystem: options.ecosystem, - wallet: options.wallet, - routing: options.routing, - withFeatures: parseFeatureList(options.with), - withoutFeatures: parseFeatureList(options.without), - packageManager: options.packageManager, - skipInstall: Boolean(options.skipInstall), - force: Boolean(options.force), - }; - - const userOptions = - options.yes || options.json ? baseOptions : await promptForCreateOptions(baseOptions); - const resolvedOptions = resolveCreateOptions(userOptions); - const scaffoldResult = scaffoldProject(resolvedOptions); - const result: CreateResult = { - ok: true, - action: 'create', - ...scaffoldResult, - }; - - if (options.json) { - printJson(result); - return; - } - - renderSuccess(result); - } catch (error) { - printError(error, Boolean(options.json)); - } - }); + const create = program.command('create'); + registerCreateInitCommand(create); + registerScaffoldAction(create); } diff --git a/packages/cli/src/commands/create/init.test.ts b/packages/cli/src/commands/create/init.test.ts new file mode 100644 index 0000000..296ca64 --- /dev/null +++ b/packages/cli/src/commands/create/init.test.ts @@ -0,0 +1,127 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { CREATE_PROFILE_SELECTION_FILENAME } from '../../agent-assets'; +import { registerCreateCommand } from '../create'; +import { CLI_PACKAGE_NAME, JSON_SCHEMA_VERSION } from '../migrate/json-results'; + +const temporaryDirectories: string[] = []; + +function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oz-ui-create-init-')); + temporaryDirectories.push(dir); + return dir; +} + +async function captureStdout(fn: () => Promise | void): Promise { + const chunks: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); + return true; + }) as typeof process.stdout.write; + + try { + await fn(); + } finally { + process.stdout.write = originalWrite; + } + + return chunks.join(''); +} + +async function runCreate(args: string[]): Promise { + const root = new Command(); + registerCreateCommand(root); + return captureStdout(async () => { + await root.parseAsync(['create', ...args], { from: 'user' }); + }); +} + +afterEach(() => { + process.exitCode = 0; + for (const dir of temporaryDirectories.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('create init command', () => { + it('emits a JSON envelope and copies the scaffold-dapp skill into each selected profile', async () => { + const dir = createTempDir(); + + const stdout = await runCreate([ + 'init', + '--target', + dir, + '--agent-profile', + 'claude,standard', + '--json', + ]); + const payload = JSON.parse(stdout); + + expect(payload.action).toBe('create-init'); + expect(payload.ok).toBe(true); + expect(payload.schemaVersion).toBe(JSON_SCHEMA_VERSION); + expect(payload.cli).toEqual({ name: CLI_PACKAGE_NAME, version: expect.any(String) }); + expect(payload.target).toBe(path.resolve(dir)); + expect(payload.agentAssetProfiles).toEqual(['claude', 'standard']); + expect(payload.agentProfileSelectionWritten).toBe(CREATE_PROFILE_SELECTION_FILENAME); + + expect(payload.skillCopied).toEqual( + expect.arrayContaining([ + '.claude/skills/scaffold-dapp/SKILL.md', + '.agents/skills/scaffold-dapp/SKILL.md', + ]) + ); + + expect(fs.existsSync(path.join(dir, '.claude/skills/scaffold-dapp/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(dir, '.agents/skills/scaffold-dapp/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(dir, CREATE_PROFILE_SELECTION_FILENAME))).toBe(true); + }); + + it('reports skipped files when SKILL.md already exists', async () => { + const dir = createTempDir(); + + await runCreate(['init', '--target', dir, '--agent-profile', 'claude', '--json']); + const stdout = await runCreate([ + 'init', + '--target', + dir, + '--agent-profile', + 'claude', + '--json', + ]); + const payload = JSON.parse(stdout); + + expect(payload.skillCopied).toEqual([]); + expect(payload.skillSkipped).toEqual( + expect.arrayContaining(['.claude/skills/scaffold-dapp/SKILL.md']) + ); + }); + + it('reuses the stored agent profile when --agent-profile is omitted on a follow-up run', async () => { + const dir = createTempDir(); + + await runCreate(['init', '--target', dir, '--agent-profile', 'legacy-cursor', '--json']); + const stdout = await runCreate(['init', '--target', dir, '--json']); + const payload = JSON.parse(stdout); + + expect(payload.ok).toBe(true); + expect(payload.agentAssetProfiles).toEqual(['legacy-cursor']); + }); + + it('writes nothing when --agent-profile=none', async () => { + const dir = createTempDir(); + + const stdout = await runCreate(['init', '--target', dir, '--agent-profile', 'none', '--json']); + const payload = JSON.parse(stdout); + + expect(payload.ok).toBe(true); + expect(payload.agentAssetProfiles).toEqual([]); + expect(payload.skillCopied).toEqual([]); + expect(fs.existsSync(path.join(dir, CREATE_PROFILE_SELECTION_FILENAME))).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/create/init.ts b/packages/cli/src/commands/create/init.ts new file mode 100644 index 0000000..45dc008 --- /dev/null +++ b/packages/cli/src/commands/create/init.ts @@ -0,0 +1,179 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Command } from 'commander'; +import pc from 'picocolors'; + +import { + AGENT_ASSET_PROFILE_IDS, + CREATE_PROFILE_SELECTION_FILENAME, + parseAgentProfileArg, + readAgentProfileSelection, + SCAFFOLD_DAPP_SKILL_ID, + skillDirectoriesForProfiles, + writeAgentProfileSelection, + type AgentAssetProfile, +} from '../../agent-assets'; +import { copyTemplateDirectory } from '../../templates'; +import { printError, printJson } from '../../utils/logger'; +import type { JsonCommandResult } from '../migrate/json-results'; + +interface CreateInitOptions { + target: string; + json?: boolean; + /** @description Comma-separated: standard, claude, legacy-cursor, all, none */ + agentProfile?: string; +} + +interface CreateInitResult extends JsonCommandResult<'create-init'> { + target: string; + agentAssetProfiles: string[]; + skillCopied: string[]; + skillSkipped: string[]; + agentProfileSelectionWritten: string; +} + +const SCAFFOLD_DAPP_SKILL_TEMPLATE = `skills/${SCAFFOLD_DAPP_SKILL_ID}`; + +/** + * Resolves the agent profiles for `oz-ui create init`. Mirrors + * `resolveAgentProfilesForInit` (used by `migrate init`) but reads + * `.oz-ui-create.json` and surfaces a create-specific error message. + */ +function resolveProfilesForCreateInit( + targetRoot: string, + raw: string | undefined +): AgentAssetProfile[] { + if (raw !== undefined && raw.trim() !== '') { + return parseAgentProfileArg(raw); + } + try { + return readAgentProfileSelection(targetRoot, CREATE_PROFILE_SELECTION_FILENAME); + } catch { + throw new Error( + `Missing required --agent-profile. Choose one of: ${AGENT_ASSET_PROFILE_IDS.join( + ', ' + )}, all, none. (After the first run, you may omit it to reuse the stored selection in ${CREATE_PROFILE_SELECTION_FILENAME}.)` + ); + } +} + +/** + * Copies `templates/skills/scaffold-dapp/SKILL.md` into each profile's skill + * destination under `targetRoot`. Returns relative paths of files copied vs + * skipped (skipped because they already exist — same semantics as + * `copyTemplateDirectory`). + */ +function copyScaffoldDappSkill( + targetRoot: string, + profiles: readonly AgentAssetProfile[] +): { copied: string[]; skipped: string[] } { + if (profiles.length === 0) return { copied: [], skipped: [] }; + const copied: string[] = []; + const skipped: string[] = []; + const root = path.resolve(targetRoot); + + for (const directory of skillDirectoriesForProfiles(profiles, SCAFFOLD_DAPP_SKILL_ID)) { + const result = copyTemplateDirectory(SCAFFOLD_DAPP_SKILL_TEMPLATE, path.join(root, directory)); + copied.push(...result.copied.map((f) => `${directory}/${f}`)); + skipped.push(...result.skipped.map((f) => `${directory}/${f}`)); + } + + return { copied, skipped }; +} + +/** + * Registers `oz-ui create init` — installs the `scaffold-dapp` skill assets + * into the user's workspace so an AI agent can orchestrate `oz-ui create` + * from natural-language intent. Decoupled from the bare scaffolding action + * (`oz-ui create [project-name]`) because the skill is most useful *before* + * a new project exists. + */ +export function registerCreateInitCommand(parent: Command): void { + parent + .command('init') + .description( + 'Install the scaffold-dapp AI skill into the current workspace (no project files generated).' + ) + .option( + '-t, --target ', + 'Target workspace directory where skill assets will be written', + process.cwd() + ) + .option('--json', 'Emit machine-readable JSON output') + .option( + '--agent-profile ', + `Required on first init: comma-separated ${AGENT_ASSET_PROFILE_IDS.join( + ', ' + )}, all, or none. On later inits, omit to reuse the selection stored in ${CREATE_PROFILE_SELECTION_FILENAME}.` + ) + .action((options: CreateInitOptions, cmd: Command) => { + // Commander 13 silently absorbs duplicate option values to the parent when + // both parent (`create`) and subcommand (`init`) declare the same flag. + // `--json` is declared on the parent for the bare scaffold action, so its + // value lands on `cmd.parent.opts()` here. We still declare `--json` on + // init above for help-text discoverability, but always resolve it from + // the parent (or, defensively, from the local options object). + const json = Boolean(cmd.parent?.opts().json ?? options.json); + try { + const targetRoot = path.resolve(options.target); + + if (!fs.existsSync(targetRoot)) { + throw new Error(`Target directory does not exist: ${targetRoot}`); + } + + const agentAssetProfiles = resolveProfilesForCreateInit(targetRoot, options.agentProfile); + + const { copied: skillCopied, skipped: skillSkipped } = copyScaffoldDappSkill( + targetRoot, + agentAssetProfiles + ); + const agentProfileSelectionWritten = writeAgentProfileSelection( + targetRoot, + agentAssetProfiles, + CREATE_PROFILE_SELECTION_FILENAME + ); + + const result: CreateInitResult = { + ok: true, + action: 'create-init', + target: targetRoot, + agentAssetProfiles: [...agentAssetProfiles], + skillCopied, + skillSkipped, + agentProfileSelectionWritten, + }; + + if (json) { + printJson(result); + return; + } + + process.stdout.write(pc.green(`scaffold-dapp skill installed to ${targetRoot}\n`)); + process.stdout.write( + ` Agent asset profiles: ${pc.dim(agentAssetProfiles.join(', ') || 'none')}\n` + ); + if (skillCopied.length > 0) { + process.stdout.write(` Skill files copied: ${skillCopied.length}\n`); + for (const file of skillCopied) { + process.stdout.write(` ${pc.dim(file)}\n`); + } + } + if (skillSkipped.length > 0) { + process.stdout.write(` Skill files skipped (already present): ${skillSkipped.length}\n`); + } + process.stdout.write( + ` Profile selection written: ${pc.dim(agentProfileSelectionWritten)}\n` + ); + + process.stdout.write('\n' + pc.bold('Next steps:\n')); + process.stdout.write(` 1. Restart your AI assistant so it picks up the new skill\n`); + process.stdout.write( + ` 2. Ask it to "scaffold a dApp" — the skill will run discovery, ask about preset/wallet, and invoke ${pc.cyan( + 'oz-ui create' + )} for you\n` + ); + } catch (error) { + printError(error, json); + } + }); +} diff --git a/packages/cli/src/commands/create/scaffold.ts b/packages/cli/src/commands/create/scaffold.ts new file mode 100644 index 0000000..3de6e7a --- /dev/null +++ b/packages/cli/src/commands/create/scaffold.ts @@ -0,0 +1,149 @@ +import path from 'node:path'; +import { Command } from 'commander'; +import pc from 'picocolors'; + +import { promptForCreateOptions } from '../../create/interactive'; +import { parseFeatureList, resolveCreateOptions } from '../../create/options'; +import { scaffoldProject } from '../../create/scaffold'; +import type { + CreateEcosystem, + CreatePreset, + CreateRouting, + CreateWallet, +} from '../../create/types'; +import { printError, printJson } from '../../utils/logger'; +import type { JsonCommandResult } from '../migrate/json-results'; + +interface CreateCommandOptions { + directory?: string; + preset?: CreatePreset; + ecosystem?: CreateEcosystem; + wallet?: CreateWallet; + routing?: CreateRouting; + with?: string; + without?: string; + packageManager?: 'npm' | 'pnpm' | 'yarn'; + skipInstall?: boolean; + force?: boolean; + yes?: boolean; + json?: boolean; +} + +interface CreateResult extends JsonCommandResult<'create'> { + projectName: string; + projectRoot: string; + preset: CreatePreset; + ecosystem: CreateEcosystem; + wallet: CreateWallet; + routing: CreateRouting; + features: string[]; + impliedFeatures: Record; + filesWritten: string[]; + filesSkipped: string[]; + packageManager: string; + installCommand: string | null; + installRan: boolean; + nextSteps: string[]; +} + +function renderSuccess(result: CreateResult): void { + process.stdout.write(pc.green(`Created ${result.projectName}\n`)); + process.stdout.write(` Preset: ${pc.cyan(result.preset)}\n`); + process.stdout.write(` Ecosystem: ${result.ecosystem}\n`); + process.stdout.write(` Wallet: ${result.wallet}\n`); + process.stdout.write(` Routing: ${result.routing}\n`); + process.stdout.write(` Components: ${result.features.join(', ') || 'none'}\n`); + + const impliedEntries = Object.entries(result.impliedFeatures); + if (impliedEntries.length > 0) { + process.stdout.write(` Auto-added:\n`); + for (const [feature, reason] of impliedEntries) { + process.stdout.write(` ${feature}: ${pc.dim(reason)}\n`); + } + } + + process.stdout.write(` Files written: ${result.filesWritten.length}\n`); + if (result.filesSkipped.length > 0) { + process.stdout.write(` Files skipped: ${result.filesSkipped.length}\n`); + } + + if (result.installRan) { + process.stdout.write(` Installed dependencies with ${pc.cyan(result.installCommand ?? '')}\n`); + } else { + process.stdout.write(` Dependency install skipped\n`); + } + + if (result.features.includes('wallet')) { + process.stdout.write(`\n${pc.bold('OZ wiring')}\n`); + process.stdout.write(` Edit ${pc.cyan('src/oz/runtime.ts')} for adapter/runtime behavior\n`); + process.stdout.write(` Edit ${pc.cyan('src/oz/OzProviders.tsx')} for provider wiring\n`); + process.stdout.write(` Edit ${pc.cyan('public/app.config.json')} for runtime config\n`); + } + + process.stdout.write(`\n${pc.bold('Next steps')}\n`); + for (const step of result.nextSteps) { + process.stdout.write(` ${pc.cyan(step)}\n`); + } +} + +/** + * Registers the bare `oz-ui create [project-name]` scaffolding action on the + * already-created `create` command. Keep this distinct from `oz-ui create init`, + * which lives in `./init.ts` and only installs skill assets. + */ +export function registerScaffoldAction(create: Command): void { + create + .argument('[project-name]', 'Project directory name') + .description('Create a Vite + React + TypeScript app with OpenZeppelin UI wiring.') + .option('-d, --directory ', 'Parent directory for the generated project', process.cwd()) + .option('--preset ', 'minimal, dapp, app-shell, or wizard') + .option('--ecosystem ', 'Target ecosystem (v1: evm)', 'evm') + .option('--wallet ', 'none, custom, or rainbowkit') + .option('--routing ', 'none or react-router') + .option( + '--with ', + 'Comma-separated features: wallet, router, sidebar, theme, toasts, tooltips, wizard, status-panel' + ) + .option('--without ', 'Comma-separated features to remove from the preset defaults') + .option('--package-manager ', 'npm, pnpm, or yarn') + .option('--skip-install', 'Generate files without installing dependencies') + .option('--force', 'Overwrite generated files in a non-empty target directory') + .option('-y, --yes', 'Use defaults and skip interactive prompts') + .option('--json', 'Emit machine-readable JSON output') + .action(async (projectName: string | undefined, options: CreateCommandOptions) => { + try { + const baseOptions = { + projectName: projectName ?? '', + targetDirectory: path.resolve(options.directory ?? process.cwd()), + preset: options.preset, + ecosystem: options.ecosystem, + wallet: options.wallet, + routing: options.routing, + withFeatures: parseFeatureList(options.with), + withoutFeatures: parseFeatureList(options.without), + packageManager: options.packageManager, + skipInstall: Boolean(options.skipInstall), + force: Boolean(options.force), + }; + + const userOptions = + options.yes || options.json ? baseOptions : await promptForCreateOptions(baseOptions); + const resolvedOptions = resolveCreateOptions(userOptions); + const scaffoldResult = scaffoldProject(resolvedOptions); + const result: CreateResult = { + ok: true, + action: 'create', + ...scaffoldResult, + }; + + if (options.json) { + printJson(result); + return; + } + + renderSuccess(result); + } catch (error) { + printError(error, Boolean(options.json)); + } + }); +} diff --git a/packages/cli/src/execution/task-executor.ts b/packages/cli/src/execution/task-executor.ts index 4f9aafa..4c2f6ff 100644 --- a/packages/cli/src/execution/task-executor.ts +++ b/packages/cli/src/execution/task-executor.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { expectedAgentPathsForProfiles, expectedSkillPathsForProfiles, + MIGRATE_SKILL_ID, resolveManifestAgentProfiles, type AgentAssetProfile, } from '../agent-assets'; @@ -182,7 +183,7 @@ function executeSetupTask( }; } case 'copy-skill': { - const files = expectedSkillPathsForProfiles(agentAssetProfiles); + const files = expectedSkillPathsForProfiles(agentAssetProfiles, MIGRATE_SKILL_ID); return { changedFiles: files, instructions: diff --git a/packages/cli/src/init/setup.ts b/packages/cli/src/init/setup.ts index 1c84879..6d99447 100644 --- a/packages/cli/src/init/setup.ts +++ b/packages/cli/src/init/setup.ts @@ -15,6 +15,7 @@ import { import { agentDirectoriesForProfiles, + MIGRATE_SKILL_ID, skillDirectoriesForProfiles, writeAgentProfileSelection, } from '../agent-assets'; @@ -458,7 +459,7 @@ export function copyAgentFiles( return copied; } -const SKILL_TEMPLATE = 'skills/migrate-to-oz-uikit'; +const MIGRATE_SKILL_TEMPLATE = `skills/${MIGRATE_SKILL_ID}`; /** * @description Installs `migrate-to-oz-uikit` skill assets per selected profiles. @@ -471,9 +472,9 @@ export function copySkillFiles( const copied: string[] = []; const root = path.resolve(projectRoot); - for (const directory of skillDirectoriesForProfiles(profiles)) { + for (const directory of skillDirectoriesForProfiles(profiles, MIGRATE_SKILL_ID)) { try { - const result = copyTemplateDirectory(SKILL_TEMPLATE, path.join(root, directory)); + const result = copyTemplateDirectory(MIGRATE_SKILL_TEMPLATE, path.join(root, directory)); copied.push(...result.copied.map((f) => `${directory}/${f}`)); } catch { // Skill templates may not exist yet diff --git a/packages/cli/src/templates/skills/scaffold-dapp/SKILL.md b/packages/cli/src/templates/skills/scaffold-dapp/SKILL.md new file mode 100644 index 0000000..f9a790e --- /dev/null +++ b/packages/cli/src/templates/skills/scaffold-dapp/SKILL.md @@ -0,0 +1,133 @@ +# scaffold-dapp + +Bootstrap a new React + Vite + TypeScript dApp wired to the OpenZeppelin UI Kit. This skill orchestrates the `oz-ui create` CLI: it discovers the user's situation, asks decision-oriented questions, invokes the CLI deterministically with `--yes --json`, and walks the user through post-scaffold edits. + +## Prerequisites + +- `@openzeppelin/ui-cli` must be invocable. Either install it globally (`npm i -g @openzeppelin/ui-cli`, then run `oz-ui ...`) or use `npx @openzeppelin/ui-cli ...` for one-off invocations. Inside an already-scaffolded project, `npx oz-ui ...` works because the CLI is pinned as a devDep. +- The user should be in a workspace directory where they intend to create a new project (typically a parent like `~/projects`). The new project will be created as a subdirectory. + +## Workflow + +1. **Discover** the user's cwd state (Step 1). +2. **Ask** decision-oriented questions to resolve preset, wallet, and optional features (Step 2). +3. **Confirm and invoke** `oz-ui create … --yes --json` (Step 3). +4. **Guide** through the first post-scaffold edits (Step 4). +5. **Suggest** follow-up skills (Step 5). + +### Step 1: Discovery + +Check the user's cwd before asking anything: + +```bash +ls package.json 2>/dev/null && grep -E '"name"|"@openzeppelin/' package.json +``` + +**Case A — empty directory or no `package.json`**: new-project flow. Proceed to Step 2. + +**Case B — `package.json` exists with `@openzeppelin/ui-react` or `@openzeppelin/ui-components`**: the user is already on the OZ UI Kit. Ask whether they want to scaffold a *separate* new project (offer to `cd ..` first), or whether they meant to add capabilities to the existing project — in which case suggest `oz-ui add` or the `wallet-integration` / `integrate-adapter` skills. + +**Case C — `package.json` exists, no OZ packages**: existing React app without OZ wiring. Reply with a plain-language explanation that `oz-ui create` is for new projects only and would clobber existing files, then suggest the user start a new session with the `migrate-to-oz-uikit` skill (or run `npx oz-ui migrate init` directly). + +If the user insists on scaffolding into a non-empty directory, they can re-run `oz-ui create … --force`, but only after explicit confirmation. + +Tooling context that affects flag selection: + +- `pnpm-workspace.yaml` in cwd or any parent → suggest `--package-manager pnpm` +- `package.json` in a parent (monorepo) → ask whether to scaffold inside or outside the workspace +- `.npmrc` → respect any registry pinning the user has configured + +### Step 2: Targeted Questions + +Ask **decision-oriented** questions, not flag-by-flag. The decision tree is exhaustive: + +``` +Q1: New project or adding to an existing one? + - new → continue to Q2 + - existing → reply with explanation + suggest migrate-to-oz-uikit (Case C) + +Q2: One main page or many? + - one page, just want to prove the wiring works → --preset minimal + - standard dApp dashboard (recommended default) → --preset dapp + - multiple routes / sidebar / app shell → --preset app-shell + - guided multi-step flow (RWA-style) → --preset wizard + +Q3: Wallet kit? (skipped for --preset minimal unless the user opts in) + - standard EVM users (RainbowKit + wagmi) → --wallet rainbowkit + - custom kit / multi-chain later / DIY → --wallet custom + - read-only or no wallet at all → --wallet none +``` + +**Never ask about features the preset already implies.** `dapp` includes `wallet`, `theme`, `toasts`, `tooltips`, and `status-panel`. Only surface optional toggles (`--with sidebar`, `--without status-panel`) when the user mentions related needs. + +If the user describes their app vaguely, map intent to flags inline: + +- "wizard", "multi-step", "guided flow" → `--preset wizard` +- "dashboard", "main app screen" → `--preset dapp` +- "admin console", "multiple sections", "sidebar" → `--preset app-shell` (sidebar implied) +- "just trying it out", "minimum viable wiring" → `--preset minimal` +- Non-EVM ecosystem (Stellar, Polkadot, Solana, Midnight) → flag the v1 EVM-only limitation and ask whether to proceed with EVM scaffolding now and swap adapters later. + +### Step 3: Confirmation and Invocation + +Summarize the resolved flag set in plain language (e.g. "preset: dapp · wallet: rainbowkit · in directory `my-dapp/`") and get explicit user confirmation. Then run the CLI in headless mode: + +```bash +oz-ui create my-dapp --preset dapp --wallet rainbowkit --yes --json +# or, without a global install: +npx @openzeppelin/ui-cli create my-dapp --preset dapp --wallet rainbowkit --yes --json +``` + +Always pass `--yes --json` for agent-driven runs; the interactive form expects a TTY. + +Validate the JSON envelope before continuing: + +- `ok: true` — proceed. +- `ok: false` — surface the `error` field verbatim and stop. Do not retry automatically; the user may need to fix flag values, free up the target directory, or pass `--force`. +- `schemaVersion` — refuse to operate on a payload whose major version is newer than the one this skill was authored against (`1.x`). +- `cli: { name, version }` — surface the version in the success message. + +Surface to the user in plain language (not a raw JSON dump): + +- **Files written** — count plus the most relevant paths (`src/main.tsx`, `src/App.tsx`, `src/oz/OzProviders.tsx` when wallet is enabled). From `filesWritten`. +- **Implied features** — e.g. "router was added because you chose sidebar". From `impliedFeatures`. +- **Install status** — whether `installRan` succeeded; if not, surface the `installCommand` for manual run. +- **Next steps** — verbatim from `nextSteps`. + +### Step 4: Post-Scaffold Guidance + +Walk the user through the first real edits, in priority order. Skip steps that don't apply (e.g. step 1 when `--wallet none` was selected). + +1. **Replace the WalletConnect placeholder** in `public/app.config.json`: + - Look for `"projectId": "YOUR_WALLETCONNECT_PROJECT_ID_HERE"`. + - Replace with a real project ID from [https://cloud.walletconnect.com/](https://cloud.walletconnect.com/), or set `VITE_APP_CFG_GLOBAL_SERVICE_CONFIGS_WALLETCONNECT_PROJECT_ID` in `.env.local`. +2. **Pick the runtime profile** in `src/oz/runtime.ts`: + - Default is `composer` (read + write + sign). + - Use `viewer` for read-only apps (skips wallet UI), `transactor` for sends-only apps (skips read-display paths). +3. **Run the dev server**: + ```bash + cd + run dev + ``` + The localhost URL printed by Vite is the success signal. +4. **Add a contract interaction**: + - For `RenderFormSchema` / `TransactionForm`-driven flows, suggest the `form-schema-builder` skill. + - For hand-rolled `useReadContract` / `useWriteContract`, point at `@openzeppelin/ui-react` examples. + +### Step 5: Suggest Follow-ups + +Once the dev server boots and WalletConnect is wired, the scaffold flow is complete. Optionally suggest: + +- `**migrate-to-oz-uikit`** — if the user has *another* React app to migrate onto the OZ UI Kit. +- `**oz-ui add adapter *`* — for adding a chain adapter later. +- `**oz-ui doctor**` — for validating the project's wiring after manual edits. + +## Important Rules + +- **Always run `oz-ui create` with `--yes --json`** when invoking from this skill. Never simulate the interactive prompts. +- **Validate `schemaVersion` and `cli.version`** on every JSON envelope. Refuse newer major schema versions. +- **Never delegate to or invoke another skill silently.** Skill-to-skill handoff is always a recommendation; let the user start a new session. +- **Never scaffold into a non-empty directory without `--force` and explicit user confirmation.** The CLI rejects this by default; respect that default. +- **The decision tree is exhaustive.** If you're asking outside Q1/Q2/Q3 or producing an undocumented flag combination, stop and re-read this file. +- **Do not modify the generated project's wiring during the skill's run.** The CLI owns `src/oz/`, `src/main.tsx`, `src/App.tsx`, and the Tailwind setup. Re-run the CLI with different flags rather than hand-editing. + diff --git a/packages/cli/src/verification/checker.ts b/packages/cli/src/verification/checker.ts index d1d22cc..525325d 100644 --- a/packages/cli/src/verification/checker.ts +++ b/packages/cli/src/verification/checker.ts @@ -6,6 +6,7 @@ import { doctorTailwindProject } from '@openzeppelin/ui-tailwind-utils'; import { expectedAgentPathsForProfiles, expectedSkillPathsForProfiles, + MIGRATE_SKILL_ID, type AgentAssetProfile, } from '../agent-assets'; import { CLI_BRANDING, CLI_FAMILIES } from '../branding'; @@ -707,7 +708,10 @@ export function checkTask( return checkProfileAwareArtifacts(task, projectRoot, agentExpected, 'agent file'); } case 'copy-skill': { - const skillExpected = expectedSkillPathsForProfiles(effectiveCopyProfiles(checkOptions)); + const skillExpected = expectedSkillPathsForProfiles( + effectiveCopyProfiles(checkOptions), + MIGRATE_SKILL_ID + ); return checkProfileAwareArtifacts(task, projectRoot, skillExpected, 'skill file'); } case 'remove-stale-deps': From 43de0aba6a6bafa15b5e99bcece265e58d7ab3eb Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 29 May 2026 13:22:19 +0300 Subject: [PATCH 05/11] feat(common): add oz-ui add wallet command with AST entry patching Adds `oz-ui add wallet`, which wires OpenZeppelin UI wallet/runtime support into an existing Vite + React project: installs dependencies, writes the `src/oz` runtime/provider files, merges `public/app.config.json`, and patches the React entry file to wrap the render tree with `` and run `initializeAppConfig()` before render. Entry-file patching is implemented with a shared, AST-based transformer (`src/codemod/entry-transform.ts`) instead of regex/brace-counting, so it handles JSX strings containing parentheses, legacy `ReactDOM.render`, and existing sync/async/arrow bootstrap functions without corrupting source. When the entry cannot be patched automatically the command reports `entryFilePatchReason` and emits manual-wiring next steps. Shared wallet dependency/version logic is extracted into `src/wallet/scaffold.ts` and `src/versions.ts` and reused by `oz-ui create`. --- .changeset/quiet-foxes-add-wallet.md | 5 + examples/basic-react-app/package.json | 4 +- packages/cli/README.md | 56 ++- packages/cli/docs/ADD_WALLET.md | 88 ++++ packages/cli/src/add/wallet/install.test.ts | 281 +++++++++++ packages/cli/src/add/wallet/install.ts | 333 ++++++++++++ .../cli/src/add/wallet/patch-entry.test.ts | 94 ++++ packages/cli/src/add/wallet/patch-entry.ts | 57 +++ .../cli/src/codemod/entry-transform.test.ts | 365 ++++++++++++++ packages/cli/src/codemod/entry-transform.ts | 475 ++++++++++++++++++ packages/cli/src/commands/add.ts | 14 + packages/cli/src/commands/add/wallet.test.ts | 105 ++++ packages/cli/src/commands/add/wallet.ts | 134 +++++ packages/cli/src/create/package-json.ts | 28 +- packages/cli/src/create/support-files.ts | 17 +- packages/cli/src/create/templates/index.ts | 22 +- packages/cli/src/index.ts | 2 + .../skills/migrate-to-oz-uikit/SKILL.md | 110 ++-- .../templates/skills/scaffold-dapp/SKILL.md | 69 ++- packages/cli/src/versions.ts | 14 + packages/cli/src/wallet/scaffold.ts | 93 ++++ pnpm-lock.yaml | 42 +- 22 files changed, 2273 insertions(+), 135 deletions(-) create mode 100644 .changeset/quiet-foxes-add-wallet.md create mode 100644 packages/cli/docs/ADD_WALLET.md create mode 100644 packages/cli/src/add/wallet/install.test.ts create mode 100644 packages/cli/src/add/wallet/install.ts create mode 100644 packages/cli/src/add/wallet/patch-entry.test.ts create mode 100644 packages/cli/src/add/wallet/patch-entry.ts create mode 100644 packages/cli/src/codemod/entry-transform.test.ts create mode 100644 packages/cli/src/codemod/entry-transform.ts create mode 100644 packages/cli/src/commands/add.ts create mode 100644 packages/cli/src/commands/add/wallet.test.ts create mode 100644 packages/cli/src/commands/add/wallet.ts create mode 100644 packages/cli/src/versions.ts create mode 100644 packages/cli/src/wallet/scaffold.ts diff --git a/.changeset/quiet-foxes-add-wallet.md b/.changeset/quiet-foxes-add-wallet.md new file mode 100644 index 0000000..e26bc2c --- /dev/null +++ b/.changeset/quiet-foxes-add-wallet.md @@ -0,0 +1,5 @@ +--- +"@openzeppelin/ui-cli": minor +--- + +Add `oz-ui add wallet` — installs OpenZeppelin UI wallet wiring (providers, runtime adapter, app config, optional RainbowKit kit config) into an existing project, patches the entry file to wrap the render tree with ``, and installs required dependencies. Idempotent on re-run; supports `--ecosystem`, `--kit`, `--skip-install`, `--force`, and `--json`. diff --git a/examples/basic-react-app/package.json b/examples/basic-react-app/package.json index adc5d54..9876c9a 100644 --- a/examples/basic-react-app/package.json +++ b/examples/basic-react-app/package.json @@ -16,8 +16,8 @@ }, "dependencies": { "@creit.tech/stellar-wallets-kit": "^1.8.0", - "@openzeppelin/adapter-evm": "^2.0.1", - "@openzeppelin/adapter-stellar": "^2.0.0", + "@openzeppelin/adapter-evm": "^2.0.2", + "@openzeppelin/adapter-stellar": "^2.0.2", "@openzeppelin/ui-components": "workspace:^", "@openzeppelin/ui-react": "workspace:^", "@openzeppelin/ui-renderer": "workspace:^", diff --git a/packages/cli/README.md b/packages/cli/README.md index 001f961..4ba4186 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -24,10 +24,11 @@ Consumer CLI for scaffolding, migrating, and managing OpenZeppelin UI applicatio The `oz-ui` binary is organized by **command group** (e.g. `oz-ui …`). This README documents the groups that are available today; **additional groups will be added here** as they ship. -| Group | Description | -| --------- | ----------------------------------------------------------------------------------- | -| `create` | Scaffold a Vite + React + TypeScript app with selectable OpenZeppelin UI wiring. | -| `migrate` | Move an existing React app onto the OpenZeppelin UI Kit (manifest-driven workflow). | +| Group | Description | +| --------- | --------------------------------------------------------------------------------------- | +| `add` | Add OpenZeppelin UI capabilities, such as wallet wiring, to an existing project. | +| `create` | Scaffold a Vite + React + TypeScript app with selectable OpenZeppelin UI wiring. | +| `migrate` | Move an existing React app onto the OpenZeppelin UI Kit (manifest-driven workflow). | ### `create` @@ -120,6 +121,53 @@ oz-ui create init - [Create generation matrix](./docs/CREATE_MATRIX.md) - User-facing matrix of presets, options, features, generated files, and combinations. - [Create recipes architecture](./docs/CREATE_RECIPES.md) - How `oz-ui create` resolves presets, layouts, content, and feature combinations. +### `add` + +`oz-ui add` applies OpenZeppelin UI capabilities to an existing project. The first additive command is `wallet`, which writes the same `src/oz` runtime/provider files used by `oz-ui create` and patches the React entry file to wrap the render tree with `OzProviders`. + +All `oz-ui add` subcommands support `--json` and return machine-readable payloads with an `action` field plus command-specific data. Invoke each row as `oz-ui add ` (or `npx` / `pnpm exec` as needed). + + +| Subcommand | Description | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `wallet` | Add EVM wallet/runtime wiring to an existing Vite + React app. Supports the `custom` kit by default and optional `rainbowkit` config via `--kit rainbowkit`. | + + +#### `add wallet` + +Adds OpenZeppelin wallet runtime support to an existing project: + +- installs missing wallet dependencies unless `--skip-install` is set +- writes `src/oz/config.ts`, `src/oz/runtime.ts`, and `src/oz/OzProviders.tsx` +- writes `src/oz/wallet/rainbowkit.config.ts` when `--kit rainbowkit` is selected +- creates or merges `public/app.config.json` without clobbering existing feature flags, RPC endpoints, or WalletConnect project IDs +- patches `src/main.tsx` / `src/index.tsx` (or JSX equivalents) to initialize app config and wrap the render tree with `` + + +| Option | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| `--project ` | Project root directory. Defaults to the current directory. | +| `--ecosystem `| Target ecosystem. V1 supports `evm`. | +| `--kit ` | Wallet kit: `custom` or `rainbowkit`. Defaults to `custom`. | +| `--package-manager ` | Package manager to use for dependency installation: `npm`, `pnpm`, or `yarn`. Auto-detected by default. | +| `--skip-install` | Write files and print the install command without running the package manager. | +| `--force` | Overwrite generated wallet files that already exist. Existing `app.config.json` is still merged. | +| `--yes` | Use defaults and skip interactive prompts. | +| `--json` | Emit a machine-readable payload with `action: "add-wallet"`. | + + +Examples: + +```bash +oz-ui add wallet +oz-ui add wallet --kit rainbowkit +oz-ui add wallet --project ./apps/web --kit custom --skip-install --json --yes +``` + +#### Add documentation + +- [Add wallet](./docs/ADD_WALLET.md) - Generated files, options, JSON payload shape, and idempotency behavior. + ### `migrate` > **Experimental** — Migration features are under active development. Do not expect perfect automation on every project. The workflow uses deterministic `oz-ui migrate` commands and has been exercised on small- to medium-sized apps; it should still beat a fully manual migration. For the full disclaimer and an **LLM-assisted** flow (prompts, manifest workflow, skills), see the [LLM-led migration reference](./docs/LLM_MIGRATION_REFERENCE.md) ([on GitHub](https://github.com/OpenZeppelin/openzeppelin-ui/blob/main/packages/cli/docs/LLM_MIGRATION_REFERENCE.md)). diff --git a/packages/cli/docs/ADD_WALLET.md b/packages/cli/docs/ADD_WALLET.md new file mode 100644 index 0000000..148ba49 --- /dev/null +++ b/packages/cli/docs/ADD_WALLET.md @@ -0,0 +1,88 @@ +# `oz-ui add wallet` + +`oz-ui add wallet` adds OpenZeppelin UI wallet/runtime wiring to an existing Vite + React project. It is the additive counterpart to the wallet feature generated by `oz-ui create`. + +## Usage + +```bash +oz-ui add wallet [--project ] [--ecosystem evm] [--kit custom|rainbowkit] + [--package-manager npm|pnpm|yarn] + [--skip-install] [--force] [--yes] [--json] +``` + +## Options + + +| Option | Values | Effect | +| ------------------------- | ---------------------- | ------------------------------------------------------------------------------------ | +| `--project ` | path | Project root. Defaults to the current directory. | +| `--ecosystem ` | `evm` | Target ecosystem. V1 supports EVM only. | +| `--kit ` | `custom`, `rainbowkit` | Wallet kit. Defaults to `custom`. | +| `--package-manager ` | `npm`, `pnpm`, `yarn` | Overrides package manager detection. | +| `--skip-install` | boolean flag | Writes files and reports the install command without running it. | +| `--force` | boolean flag | Rewrites generated wallet files. Existing `app.config.json` is merged, not replaced. | +| `--yes` | boolean flag | Uses defaults and skips prompts. | +| `--json` | boolean flag | Emits machine-readable output with `action: "add-wallet"`. | + + +## Generated Files + + +| Condition | Files | +| ------------------ | ------------------------------------------------------------------------------------------- | +| Always | `src/oz/config.ts`, `src/oz/runtime.ts`, `src/oz/OzProviders.tsx`, `public/app.config.json` | +| `--kit rainbowkit` | `src/oz/wallet/rainbowkit.config.ts` | + + +The command also patches the first detected React entry file among: + +- `src/main.tsx` +- `src/main.jsx` +- `src/index.tsx` +- `src/index.jsx` + +The patch initializes app config before rendering and wraps the existing render tree with ``. + +## Dependencies + +Missing packages are installed through the detected package manager unless `--skip-install` is set. Existing dependencies and dev dependencies are detected from `package.json` and reported separately in JSON output. + +Core packages include `@openzeppelin/ui-components`, `@openzeppelin/ui-styles`, `@openzeppelin/ui-utils`, `@openzeppelin/ui-react`, `@openzeppelin/ui-types`, `@openzeppelin/adapter-evm`, `@openzeppelin/adapters-vite`, `@tanstack/react-query`, `@wagmi/core`, `react-hook-form`, `viem`, and `wagmi`. + +`--kit rainbowkit` additionally requires `@rainbow-me/rainbowkit`. + +## Idempotency + +Re-running the command skips generated files that already exist and avoids re-patching an entry file that already references `OzProviders` or app config initialization. Use `--force` to rewrite generated wallet files. `public/app.config.json` is always merged so user-owned keys like `featureFlags`, `rpcEndpoints`, and existing WalletConnect project IDs are preserved. + +## JSON Output + +Example payload: + +```json +{ + "schemaVersion": "1.0.0", + "cli": { "name": "@openzeppelin/ui-cli", "version": "0.1.0" }, + "ok": true, + "action": "add-wallet", + "projectRoot": "/path/to/app", + "ecosystem": "evm", + "kit": "rainbowkit", + "filesWritten": ["src/oz/config.ts", "src/oz/runtime.ts"], + "filesSkipped": [], + "packagesInstalled": [], + "packagesAlreadyPresent": ["react"], + "packagesToInstall": ["@openzeppelin/adapter-evm@^2.0.1"], + "entryFilePatched": "src/main.tsx", + "appConfigPatched": "public/app.config.json", + "installCommand": "pnpm add @openzeppelin/adapter-evm@^2.0.1", + "installRan": false, + "nextSteps": [ + "pnpm add @openzeppelin/adapter-evm@^2.0.1", + "Edit public/app.config.json with your WalletConnect project ID", + "Customize adapters and wallet setup in src/oz", + "pnpm dev" + ] +} +``` + diff --git a/packages/cli/src/add/wallet/install.test.ts b/packages/cli/src/add/wallet/install.test.ts new file mode 100644 index 0000000..187485c --- /dev/null +++ b/packages/cli/src/add/wallet/install.test.ts @@ -0,0 +1,281 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { registerAddCommand } from '../../commands/add'; +import { CLI_PACKAGE_NAME, JSON_SCHEMA_VERSION } from '../../commands/migrate/json-results'; +import { addWalletToProject } from './install'; + +const temporaryDirectories: string[] = []; + +function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oz-ui-add-wallet-')); + temporaryDirectories.push(dir); + return dir; +} + +function writeFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +} + +function readFile(filePath: string): string { + return fs.readFileSync(filePath, 'utf8'); +} + +function readJson(filePath: string): Record { + return JSON.parse(readFile(filePath)) as Record; +} + +function writeFixtureProject( + projectRoot: string, + packageJson: Record = { + name: 'wallet-fixture', + dependencies: { react: '^19.0.0' }, + } +): void { + writeFile(path.join(projectRoot, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`); + writeFile( + path.join(projectRoot, 'src', 'main.tsx'), + [ + "import React from 'react';", + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + "createRoot(document.getElementById('root')!).render(", + ' ', + ' ', + ' ', + ');', + '', + ].join('\n') + ); +} + +async function captureStdout(fn: () => Promise | void): Promise { + const chunks: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); + return true; + }) as typeof process.stdout.write; + + try { + await fn(); + } finally { + process.stdout.write = originalWrite; + } + + return chunks.join(''); +} + +async function runAdd(args: string[]): Promise { + const root = new Command(); + registerAddCommand(root); + return captureStdout(async () => { + await root.parseAsync(['add', ...args], { from: 'user' }); + }); +} + +afterEach(() => { + process.exitCode = 0; + for (const dir of temporaryDirectories.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('addWalletToProject', () => { + it('adds custom wallet files, app config, and entry wiring', () => { + const projectRoot = createTempDir(); + writeFixtureProject(projectRoot); + + const result = addWalletToProject({ projectRoot, skipInstall: true }); + + expect(result.action).toBe('add-wallet'); + expect(result.ok).toBe(true); + expect(result.kit).toBe('custom'); + expect(result.filesWritten).toEqual( + expect.arrayContaining([ + 'src/oz/config.ts', + 'src/oz/runtime.ts', + 'src/oz/OzProviders.tsx', + 'public/app.config.json', + ]) + ); + expect(result.entryFilePatched).toBe('src/main.tsx'); + expect(result.installRan).toBe(false); + expect(result.packagesToInstall).toEqual( + expect.arrayContaining(['@openzeppelin/adapter-evm@^2.0.1', '@wagmi/core@^2.20.3']) + ); + + const main = readFile(path.join(projectRoot, 'src', 'main.tsx')); + expect(main).toContain("import { initializeAppConfig } from './oz/config';"); + expect(main).toContain("import { OzProviders } from './oz/OzProviders';"); + expect(main).toContain(''); + expect(main).toContain('await initializeAppConfig();'); + + const config = readJson(path.join(projectRoot, 'public', 'app.config.json')); + const globalServiceConfigs = config.globalServiceConfigs as Record; + const walletui = globalServiceConfigs.walletui as Record>; + expect(walletui.evm.kitName).toBe('custom'); + }); + + it('adds RainbowKit config and package planning when requested', () => { + const projectRoot = createTempDir(); + writeFixtureProject(projectRoot); + + const result = addWalletToProject({ + projectRoot, + kit: 'rainbowkit', + skipInstall: true, + }); + + expect(result.filesWritten).toContain('src/oz/wallet/rainbowkit.config.ts'); + expect(result.packagesToInstall).toContain('@rainbow-me/rainbowkit@^2.2.8'); + expect(readFile(path.join(projectRoot, 'src', 'oz', 'runtime.ts'))).toContain( + "uiKit: 'rainbowkit'" + ); + }); + + it('is idempotent on follow-up runs', () => { + const projectRoot = createTempDir(); + writeFixtureProject(projectRoot); + + addWalletToProject({ projectRoot, skipInstall: true }); + const result = addWalletToProject({ projectRoot, skipInstall: true }); + + expect(result.filesWritten).toEqual([]); + expect(result.filesSkipped).toEqual( + expect.arrayContaining([ + 'src/oz/config.ts', + 'src/oz/runtime.ts', + 'src/oz/OzProviders.tsx', + 'public/app.config.json', + ]) + ); + expect(result.entryFilePatched).toBeNull(); + }); + + it('rewrites generated files with force', () => { + const projectRoot = createTempDir(); + writeFixtureProject(projectRoot); + + addWalletToProject({ projectRoot, skipInstall: true }); + writeFile(path.join(projectRoot, 'src', 'oz', 'runtime.ts'), 'stale runtime\n'); + + const result = addWalletToProject({ projectRoot, force: true, skipInstall: true }); + + expect(result.filesWritten).toContain('src/oz/runtime.ts'); + expect(readFile(path.join(projectRoot, 'src', 'oz', 'runtime.ts'))).toContain('resolveRuntime'); + }); + + it('reports a manual-wiring reason and steps when the entry file cannot be patched', () => { + const projectRoot = createTempDir(); + writeFixtureProject(projectRoot); + writeFile(path.join(projectRoot, 'src', 'main.tsx'), "console.log('no render here');\n"); + + const result = addWalletToProject({ projectRoot, skipInstall: true }); + + expect(result.entryFilePatched).toBeNull(); + expect(result.entryFilePatchReason).toBe('no-render-call'); + expect(result.nextSteps).toEqual( + expect.arrayContaining([ + "Wrap your app's render tree with (import { OzProviders } from './oz/OzProviders')", + "Call await initializeAppConfig() before render (import { initializeAppConfig } from './oz/config')", + ]) + ); + }); + + it('reports a patched entry reason on a normal project', () => { + const projectRoot = createTempDir(); + writeFixtureProject(projectRoot); + + const result = addWalletToProject({ projectRoot, skipInstall: true }); + + expect(result.entryFilePatched).toBe('src/main.tsx'); + expect(result.entryFilePatchReason).toBe('patched'); + expect(result.nextSteps).not.toContain( + "Wrap your app's render tree with (import { OzProviders } from './oz/OzProviders')" + ); + }); + + it('tracks already-installed packages separately from missing packages', () => { + const projectRoot = createTempDir(); + writeFixtureProject(projectRoot, { + name: 'wallet-fixture', + dependencies: { + react: '^19.0.0', + '@openzeppelin/adapter-evm': '^2.0.1', + '@wagmi/core': '^2.20.3', + }, + }); + + const result = addWalletToProject({ projectRoot, skipInstall: true }); + + expect(result.packagesAlreadyPresent).toEqual( + expect.arrayContaining(['@openzeppelin/adapter-evm', '@wagmi/core']) + ); + expect(result.packagesToInstall).not.toContain('@openzeppelin/adapter-evm@^2.0.1'); + }); + + it('merges existing app.config.json without clobbering custom keys', () => { + const projectRoot = createTempDir(); + writeFixtureProject(projectRoot); + writeFile( + path.join(projectRoot, 'public', 'app.config.json'), + `${JSON.stringify( + { + featureFlags: { keepMe: true }, + globalServiceConfigs: { + walletconnect: { projectId: 'existing-project-id' }, + }, + rpcEndpoints: { sepolia: 'https://example.invalid' }, + }, + null, + 2 + )}\n` + ); + + addWalletToProject({ projectRoot, kit: 'rainbowkit', skipInstall: true }); + + const config = readJson(path.join(projectRoot, 'public', 'app.config.json')); + const globalServiceConfigs = config.globalServiceConfigs as Record; + const walletconnect = globalServiceConfigs.walletconnect as Record; + const walletui = globalServiceConfigs.walletui as Record>; + + expect((config.featureFlags as Record).keepMe).toBe(true); + expect((config.rpcEndpoints as Record).sepolia).toBe( + 'https://example.invalid' + ); + expect(walletconnect.projectId).toBe('existing-project-id'); + expect(walletui.evm.kitName).toBe('rainbowkit'); + }); +}); + +describe('add wallet command', () => { + it('emits the stable JSON envelope', async () => { + const projectRoot = createTempDir(); + writeFixtureProject(projectRoot); + + const stdout = await runAdd([ + 'wallet', + '--project', + projectRoot, + '--kit', + 'rainbowkit', + '--skip-install', + '--yes', + '--json', + ]); + const payload = JSON.parse(stdout); + + expect(payload.action).toBe('add-wallet'); + expect(payload.ok).toBe(true); + expect(payload.schemaVersion).toBe(JSON_SCHEMA_VERSION); + expect(payload.cli).toEqual({ name: CLI_PACKAGE_NAME, version: expect.any(String) }); + expect(payload.kit).toBe('rainbowkit'); + expect(payload.filesWritten).toContain('src/oz/wallet/rainbowkit.config.ts'); + }); +}); diff --git a/packages/cli/src/add/wallet/install.ts b/packages/cli/src/add/wallet/install.ts new file mode 100644 index 0000000..df9b22d --- /dev/null +++ b/packages/cli/src/add/wallet/install.ts @@ -0,0 +1,333 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { EntryTransformReason } from '../../codemod/entry-transform'; +import type { JsonCommandResult } from '../../commands/migrate/json-results'; +import type { + CreateEcosystem, + CreateFeature, + CreatePreset, + CreateRouting, + CreateWallet, + ResolvedCreateOptions, +} from '../../create/types'; +import { + buildInstallCommand, + detectPackageManager, + type PackageManager, +} from '../../utils/framework'; +import { + buildWalletSupportFiles, + walletAddDependenciesForKit, + walletAppConfigFile, +} from '../../wallet/scaffold'; +import { patchEntryFileForWallet } from './patch-entry'; + +export type AddWalletEcosystem = Extract; +export type AddWalletKit = Extract; + +export interface AddWalletOptions { + projectRoot: string; + ecosystem?: AddWalletEcosystem; + kit?: AddWalletKit; + packageManager?: PackageManager; + skipInstall?: boolean; + force?: boolean; +} + +export interface AddWalletResult extends JsonCommandResult<'add-wallet'> { + projectRoot: string; + ecosystem: AddWalletEcosystem; + kit: AddWalletKit; + filesWritten: string[]; + filesSkipped: string[]; + packagesInstalled: string[]; + packagesAlreadyPresent: string[]; + packagesToInstall: string[]; + entryFilePatched: string | null; + /** Outcome of the entry-file patch attempt (e.g. `patched`, `no-render-call`). */ + entryFilePatchReason: EntryTransformReason; + appConfigPatched: string | null; + installCommand: string | null; + installRan: boolean; + nextSteps: string[]; +} + +interface PackageJsonLike { + name?: string; + dependencies?: Record; + devDependencies?: Record; +} + +const SUPPORTED_ECOSYSTEMS: readonly AddWalletEcosystem[] = ['evm']; +const SUPPORTED_KITS: readonly AddWalletKit[] = ['custom', 'rainbowkit']; + +function assertOneOf(value: string, allowed: readonly T[], label: string): T { + if (allowed.includes(value as T)) return value as T; + throw new Error(`Unsupported ${label} "${value}". Expected one of: ${allowed.join(', ')}`); +} + +function readPackageJson(projectRoot: string): PackageJsonLike { + const packageJsonPath = path.join(projectRoot, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`No package.json found in ${projectRoot}. Is this a Node.js project?`); + } + + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJsonLike; +} + +function splitPackagesByPresence( + pkg: PackageJsonLike, + packages: Record +): { packagesToInstall: string[]; packagesAlreadyPresent: string[] } { + const existing = { ...pkg.dependencies, ...pkg.devDependencies }; + const packagesToInstall: string[] = []; + const packagesAlreadyPresent: string[] = []; + + for (const [name, version] of Object.entries(packages)) { + if (existing[name]) { + packagesAlreadyPresent.push(name); + } else { + packagesToInstall.push(`${name}@${version}`); + } + } + + return { packagesToInstall, packagesAlreadyPresent }; +} + +function createOptionsForWallet( + projectRoot: string, + packageManager: PackageManager, + pkg: PackageJsonLike, + ecosystem: AddWalletEcosystem, + kit: AddWalletKit, + force: boolean +): ResolvedCreateOptions { + const projectName = pkg.name?.trim() || path.basename(projectRoot); + return { + projectName, + projectRoot, + preset: 'dapp' satisfies CreatePreset, + ecosystem, + wallet: kit, + routing: 'none' satisfies CreateRouting, + features: ['wallet'], + packageManager, + skipInstall: false, + force, + impliedFeatures: {} as Record, + }; +} + +function writeGeneratedWalletFiles( + projectRoot: string, + files: ReturnType, + force: boolean +): { filesWritten: string[]; filesSkipped: string[] } { + const filesWritten: string[] = []; + const filesSkipped: string[] = []; + + for (const file of files) { + const filePath = path.join(projectRoot, file.path); + if (fs.existsSync(filePath) && !force) { + filesSkipped.push(file.path); + continue; + } + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, file.content, 'utf8'); + filesWritten.push(file.path); + } + + return { filesWritten, filesSkipped }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readJsonRecord(filePath: string): Record { + if (!fs.existsSync(filePath)) return {}; + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + if (!isRecord(parsed)) { + throw new Error(`${filePath} must contain a JSON object.`); + } + return parsed; +} + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function mergeAppConfig( + projectRoot: string, + options: ResolvedCreateOptions +): { appConfigPatched: string | null; skipped: boolean } { + const relativePath = 'public/app.config.json'; + const filePath = path.join(projectRoot, relativePath); + + if (!fs.existsSync(filePath)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, walletAppConfigFile(options).content, 'utf8'); + return { appConfigPatched: relativePath, skipped: false }; + } + + const existing = readJsonRecord(filePath); + const globalServiceConfigs = isRecord(existing.globalServiceConfigs) + ? existing.globalServiceConfigs + : {}; + const walletConnect = isRecord(globalServiceConfigs.walletconnect) + ? globalServiceConfigs.walletconnect + : {}; + const walletUi = isRecord(globalServiceConfigs.walletui) ? globalServiceConfigs.walletui : {}; + const rawEcosystemConfig = walletUi[options.ecosystem]; + const ecosystemConfig: Record = isRecord(rawEcosystemConfig) + ? rawEcosystemConfig + : {}; + const next = { + ...existing, + globalServiceConfigs: { + ...globalServiceConfigs, + walletconnect: { + projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', + ...walletConnect, + }, + walletui: { + ...walletUi, + [options.ecosystem]: { + ...ecosystemConfig, + kitName: options.wallet, + kitConfig: isRecord(ecosystemConfig.kitConfig) ? ecosystemConfig.kitConfig : {}, + }, + default: isRecord(walletUi.default) + ? walletUi.default + : { + kitName: options.wallet, + kitConfig: {}, + }, + }, + }, + }; + + if (JSON.stringify(existing) === JSON.stringify(next)) { + return { appConfigPatched: null, skipped: true }; + } + + writeJson(filePath, next); + return { appConfigPatched: relativePath, skipped: false }; +} + +/** + * Human-readable guidance for the cases where the entry file could not be + * wired automatically, so the user knows exactly what to wire by hand. + */ +function manualEntryWiringSteps(reason: EntryTransformReason): string[] { + const wrapStep = + "Wrap your app's render tree with (import { OzProviders } from './oz/OzProviders')"; + const initStep = + "Call await initializeAppConfig() before render (import { initializeAppConfig } from './oz/config')"; + + switch (reason) { + case 'no-entry-file': + return [ + 'Could not find a React entry file (src/main.tsx, src/index.tsx, or .jsx equivalents)', + `Manually: ${wrapStep}`, + `Manually: ${initStep}`, + ]; + case 'no-render-call': + case 'unsupported-shape': + return [ + 'Could not safely patch your entry file automatically — wire it manually:', + wrapStep, + initStep, + ]; + default: + return []; + } +} + +function buildNextSteps( + options: ResolvedCreateOptions, + installCommand: string | null, + entryReason: EntryTransformReason +): string[] { + const runCommand = + options.packageManager === 'npm' ? 'npm run dev' : `${options.packageManager} dev`; + const steps: string[] = []; + + if (installCommand) { + steps.push(installCommand); + } + + steps.push(...manualEntryWiringSteps(entryReason)); + steps.push('Edit public/app.config.json with your WalletConnect project ID'); + steps.push('Customize adapters and wallet setup in src/oz'); + steps.push(runCommand); + return steps; +} + +/** + * Adds OpenZeppelin UI wallet runtime files and entry wiring to an existing project. + */ +export function addWalletToProject(rawOptions: AddWalletOptions): AddWalletResult { + const projectRoot = path.resolve(rawOptions.projectRoot); + const ecosystem = assertOneOf(rawOptions.ecosystem ?? 'evm', SUPPORTED_ECOSYSTEMS, 'ecosystem'); + const kit = assertOneOf(rawOptions.kit ?? 'custom', SUPPORTED_KITS, 'wallet kit'); + const force = Boolean(rawOptions.force); + const pkg = readPackageJson(projectRoot); + const packageManager = rawOptions.packageManager ?? detectPackageManager(projectRoot); + const options = createOptionsForWallet(projectRoot, packageManager, pkg, ecosystem, kit, force); + + const dependencies = walletAddDependenciesForKit(kit); + const { packagesToInstall, packagesAlreadyPresent } = splitPackagesByPresence(pkg, dependencies); + const installCommand = + packagesToInstall.length > 0 ? buildInstallCommand(packageManager, packagesToInstall) : null; + + const { filesWritten, filesSkipped } = writeGeneratedWalletFiles( + projectRoot, + buildWalletSupportFiles(options, { includeAppConfig: false }), + force + ); + const appConfigResult = mergeAppConfig(projectRoot, options); + if (appConfigResult.appConfigPatched) { + filesWritten.push(appConfigResult.appConfigPatched); + } else if (appConfigResult.skipped) { + filesSkipped.push('public/app.config.json'); + } + + const entryResult = patchEntryFileForWallet(projectRoot); + const entryFilePatched = entryResult.patched ? entryResult.entryFile : null; + const packagesInstalled: string[] = []; + let installRan = false; + + if (installCommand && !rawOptions.skipInstall) { + execSync(installCommand, { cwd: projectRoot, stdio: 'pipe' }); + packagesInstalled.push(...packagesToInstall); + installRan = true; + } + + return { + ok: true, + action: 'add-wallet', + projectRoot, + ecosystem, + kit, + filesWritten, + filesSkipped, + packagesInstalled, + packagesAlreadyPresent, + packagesToInstall, + entryFilePatched, + entryFilePatchReason: entryResult.reason, + appConfigPatched: appConfigResult.appConfigPatched, + installCommand, + installRan, + nextSteps: buildNextSteps( + options, + rawOptions.skipInstall ? installCommand : null, + entryResult.reason + ), + }; +} diff --git a/packages/cli/src/add/wallet/patch-entry.test.ts b/packages/cli/src/add/wallet/patch-entry.test.ts new file mode 100644 index 0000000..03e7300 --- /dev/null +++ b/packages/cli/src/add/wallet/patch-entry.test.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveCreateOptions } from '../../create/options'; +import { resolveCreateAppSpec } from '../../create/recipes'; +import { mainTsx } from '../../create/templates/main'; +import { patchEntryFileForWallet } from './patch-entry'; + +const temporaryDirectories: string[] = []; + +function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oz-ui-patch-entry-')); + temporaryDirectories.push(dir); + return dir; +} + +function writeFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +} + +function readFile(filePath: string): string { + return fs.readFileSync(filePath, 'utf8'); +} + +function writePlainEntryProject(projectRoot: string): void { + writeFile( + path.join(projectRoot, 'src', 'main.tsx'), + [ + "import React from 'react';", + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + "createRoot(document.getElementById('root')!).render(", + ' ', + ' ', + ' ', + ');', + '', + ].join('\n') + ); +} + +function writeCreateMinimalEntryProject(projectRoot: string): void { + const options = resolveCreateOptions({ + projectName: 'smoke-app', + targetDirectory: projectRoot, + preset: 'minimal', + wallet: 'none', + skipInstall: true, + }); + const spec = resolveCreateAppSpec(options); + writeFile(path.join(projectRoot, 'src', 'main.tsx'), mainTsx(spec)); +} + +afterEach(() => { + for (const dir of temporaryDirectories.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('patchEntryFileForWallet', () => { + it('wraps a plain synchronous entry file in async bootstrap', () => { + const projectRoot = createTempDir(); + writePlainEntryProject(projectRoot); + + const result = patchEntryFileForWallet(projectRoot); + const main = readFile(path.join(projectRoot, 'src', 'main.tsx')); + + expect(result.patched).toBe(true); + expect(result.changes.wrappedAsyncBootstrap).toBe(true); + expect(main.match(/async function bootstrap\(\)/g)).toHaveLength(1); + expect(main.match(/void bootstrap\(\);/g)).toHaveLength(1); + expect(main).toContain('await initializeAppConfig();'); + expect(main).toContain(''); + }); + + it('injects initializeAppConfig into an existing bootstrap function', () => { + const projectRoot = createTempDir(); + writeCreateMinimalEntryProject(projectRoot); + + const result = patchEntryFileForWallet(projectRoot); + const main = readFile(path.join(projectRoot, 'src', 'main.tsx')); + + expect(result.patched).toBe(true); + expect(result.changes.wrappedAsyncBootstrap).toBe(true); + expect(main.match(/async function bootstrap\(\)/g)).toHaveLength(1); + expect(main.match(/void bootstrap\(\);/g)).toHaveLength(1); + expect(main.indexOf('await initializeAppConfig();')).toBeLessThan(main.indexOf('createRoot(')); + expect(main).toContain(''); + }); +}); diff --git a/packages/cli/src/add/wallet/patch-entry.ts b/packages/cli/src/add/wallet/patch-entry.ts new file mode 100644 index 0000000..66b1c2b --- /dev/null +++ b/packages/cli/src/add/wallet/patch-entry.ts @@ -0,0 +1,57 @@ +import { transformEntryFile, type EntryTransformReason } from '../../codemod/entry-transform'; + +const OZ_PROVIDERS_IMPORT = "import { OzProviders } from './oz/OzProviders';"; +const APP_CONFIG_IMPORT = "import { initializeAppConfig } from './oz/config';"; + +export interface PatchEntryResult { + /** Project-relative path of the entry file, or null if no candidate was found. */ + entryFile: string | null; + /** True if any change was written to disk. */ + patched: boolean; + /** Sub-edits applied to the entry file, useful for the CLI summary output. */ + changes: { + addedProvidersImport: boolean; + addedConfigImport: boolean; + wrappedRenderTree: boolean; + wrappedAsyncBootstrap: boolean; + }; + /** Reason why no patch was applied (idempotent skip vs no entry file vs unrecognized shape). */ + reason: EntryTransformReason; +} + +/** + * Patches the project's React entry file (`src/main.tsx` and friends) to wrap + * the render tree with `` and run `initializeAppConfig()` before + * the React render. + * + * Delegates to the shared AST-based entry transformer, which is idempotent + * (skips when `OzProviders`/`RuntimeProvider`/`initializeAppConfig` is already + * present) and bails without writing on unsupported entry shapes. + */ +export function patchEntryFileForWallet(projectRoot: string): PatchEntryResult { + const result = transformEntryFile(projectRoot, { + wrap: { + importLine: OZ_PROVIDERS_IMPORT, + components: ['OzProviders'], + skipIfPresent: ['OzProviders', 'RuntimeProvider'], + }, + asyncInit: { + importLine: APP_CONFIG_IMPORT, + initStatement: 'await initializeAppConfig();', + bootstrapName: 'bootstrap', + skipIfPresent: ['initializeAppConfig', 'appConfigService'], + }, + }); + + return { + entryFile: result.entryFile, + patched: result.patched, + changes: { + addedProvidersImport: result.changes.addedWrapImport, + addedConfigImport: result.changes.addedInitImport, + wrappedRenderTree: result.changes.wrappedRenderTree, + wrappedAsyncBootstrap: result.changes.injectedInit || result.changes.createdBootstrap, + }, + reason: result.reason, + }; +} diff --git a/packages/cli/src/codemod/entry-transform.test.ts b/packages/cli/src/codemod/entry-transform.test.ts new file mode 100644 index 0000000..5d58fa3 --- /dev/null +++ b/packages/cli/src/codemod/entry-transform.test.ts @@ -0,0 +1,365 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import ts from 'typescript'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + transformEntryFile, + type EntryAsyncInit, + type EntryWrapper, + type TransformEntryOptions, +} from './entry-transform'; + +const temporaryDirectories: string[] = []; + +function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oz-ui-entry-transform-')); + temporaryDirectories.push(dir); + return dir; +} + +function writeEntry(projectRoot: string, content: string, file = 'src/main.tsx'): void { + const filePath = path.join(projectRoot, file); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +} + +function readEntry(projectRoot: string, file = 'src/main.tsx'): string { + return fs.readFileSync(path.join(projectRoot, file), 'utf8'); +} + +function assertValidTsx(code: string): void { + const result = ts.transpileModule(code, { + reportDiagnostics: true, + compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest }, + }); + const syntaxErrors = (result.diagnostics ?? []).filter( + (d) => d.category === ts.DiagnosticCategory.Error + ); + expect(syntaxErrors.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'))).toEqual([]); +} + +const OZ_WRAP: EntryWrapper = { + importLine: "import { OzProviders } from './oz/OzProviders';", + components: ['OzProviders'], + skipIfPresent: ['OzProviders', 'RuntimeProvider'], +}; + +const OZ_INIT: EntryAsyncInit = { + importLine: "import { initializeAppConfig } from './oz/config';", + initStatement: 'await initializeAppConfig();', + bootstrapName: 'bootstrap', + skipIfPresent: ['initializeAppConfig', 'appConfigService'], +}; + +const WALLET_OPTIONS: TransformEntryOptions = { wrap: OZ_WRAP, asyncInit: OZ_INIT }; + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +afterEach(() => { + for (const dir of temporaryDirectories.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('transformEntryFile — render-site shapes', () => { + it('wraps a plain top-level createRoot().render and creates one async bootstrap', () => { + const dir = createTempDir(); + writeEntry( + dir, + [ + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + "createRoot(document.getElementById('root')!).render(", + ' ', + ' ', + ' ', + ');', + '', + ].join('\n') + ); + + const result = transformEntryFile(dir, WALLET_OPTIONS); + const out = readEntry(dir); + + expect(result.patched).toBe(true); + expect(result.changes.createdBootstrap).toBe(true); + expect(countOccurrences(out, 'async function bootstrap()')).toBe(1); + expect(countOccurrences(out, 'void bootstrap();')).toBe(1); + expect(out).toContain('await initializeAppConfig();'); + expect(out).toContain(''); + expect(out.indexOf('await initializeAppConfig();')).toBeLessThan(out.indexOf('createRoot(')); + assertValidTsx(out); + }); + + it('injects into an existing async bootstrap without duplicating it', () => { + const dir = createTempDir(); + writeEntry( + dir, + [ + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + 'async function bootstrap() {', + " createRoot(document.getElementById('root')!).render();", + '}', + '', + 'void bootstrap();', + '', + ].join('\n') + ); + + const result = transformEntryFile(dir, WALLET_OPTIONS); + const out = readEntry(dir); + + expect(result.changes.injectedInit).toBe(true); + expect(result.changes.createdBootstrap).toBe(false); + expect(countOccurrences(out, 'async function bootstrap()')).toBe(1); + expect(countOccurrences(out, 'void bootstrap();')).toBe(1); + expect(out).toContain(''); + assertValidTsx(out); + }); + + it('injects into a sync bootstrap and makes it async (no duplicate declaration)', () => { + const dir = createTempDir(); + writeEntry( + dir, + [ + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + 'function bootstrap() {', + " createRoot(document.getElementById('root')!).render();", + '}', + '', + 'bootstrap();', + '', + ].join('\n') + ); + + const result = transformEntryFile(dir, WALLET_OPTIONS); + const out = readEntry(dir); + + expect(result.changes.injectedInit).toBe(true); + expect(result.changes.madeFunctionAsync).toBe(true); + expect(countOccurrences(out, 'function bootstrap()')).toBe(1); + expect(out).toContain('async function bootstrap()'); + expect(out).toContain('await initializeAppConfig();'); + assertValidTsx(out); + }); + + it('injects into an arrow bootstrap and makes it async', () => { + const dir = createTempDir(); + writeEntry( + dir, + [ + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + 'const bootstrap = () => {', + " createRoot(document.getElementById('root')!).render();", + '};', + '', + 'bootstrap();', + '', + ].join('\n') + ); + + const result = transformEntryFile(dir, WALLET_OPTIONS); + const out = readEntry(dir); + + expect(result.changes.injectedInit).toBe(true); + expect(result.changes.madeFunctionAsync).toBe(true); + expect(out).toContain('const bootstrap = async () =>'); + expect(out).toContain('await initializeAppConfig();'); + assertValidTsx(out); + }); + + it('wraps only the JSX argument of legacy ReactDOM.render, preserving the container', () => { + const dir = createTempDir(); + writeEntry( + dir, + [ + "import ReactDOM from 'react-dom';", + "import App from './App';", + '', + "ReactDOM.render(, document.getElementById('root'));", + '', + ].join('\n') + ); + + const result = transformEntryFile(dir, WALLET_OPTIONS); + const out = readEntry(dir); + + expect(result.patched).toBe(true); + expect(out).toContain(''); + // The container argument must remain a sibling of (outside) the wrapped JSX. + expect(out).toContain(", document.getElementById('root')"); + assertValidTsx(out); + }); + + it('does not corrupt a JSX string attribute containing a close paren', () => { + const dir = createTempDir(); + writeEntry( + dir, + [ + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + "createRoot(document.getElementById('root')!).render(", + ' ', + ');', + '', + ].join('\n') + ); + + const result = transformEntryFile(dir, WALLET_OPTIONS); + const out = readEntry(dir); + + expect(result.patched).toBe(true); + expect(out).toContain('title="oops )"'); + assertValidTsx(out); + }); + + it('handles createRoot stored in a variable then rendered separately', () => { + const dir = createTempDir(); + writeEntry( + dir, + [ + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + "const root = createRoot(document.getElementById('root')!);", + 'root.render();', + '', + ].join('\n') + ); + + const result = transformEntryFile(dir, WALLET_OPTIONS); + const out = readEntry(dir); + + expect(result.patched).toBe(true); + expect(out).toContain(''); + expect(out).toContain('await initializeAppConfig();'); + assertValidTsx(out); + }); +}); + +describe('transformEntryFile — idempotency and guards', () => { + it('is idempotent when re-run on already-wired output', () => { + const dir = createTempDir(); + writeEntry( + dir, + [ + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + "createRoot(document.getElementById('root')!).render();", + '', + ].join('\n') + ); + + transformEntryFile(dir, WALLET_OPTIONS); + const first = readEntry(dir); + const second = transformEntryFile(dir, WALLET_OPTIONS); + + expect(second.patched).toBe(false); + expect(second.reason).toBe('already-wired'); + expect(readEntry(dir)).toBe(first); + }); + + it('returns no-entry-file when no candidate exists', () => { + const dir = createTempDir(); + const result = transformEntryFile(dir, WALLET_OPTIONS); + expect(result.reason).toBe('no-entry-file'); + expect(result.patched).toBe(false); + }); + + it('returns no-render-call when there is no render call', () => { + const dir = createTempDir(); + writeEntry(dir, "console.log('no render here');\n"); + const result = transformEntryFile(dir, WALLET_OPTIONS); + expect(result.reason).toBe('no-render-call'); + expect(result.patched).toBe(false); + }); + + it('bails on an expression-bodied arrow without writing (unsupported shape)', () => { + const dir = createTempDir(); + const source = [ + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + "const bootstrap = () => createRoot(document.getElementById('root')!).render();", + '', + 'bootstrap();', + '', + ].join('\n'); + writeEntry(dir, source); + + const result = transformEntryFile(dir, WALLET_OPTIONS); + + expect(result.reason).toBe('unsupported-shape'); + expect(result.patched).toBe(false); + expect(readEntry(dir)).toBe(source); + }); +}); + +describe('transformEntryFile — wrap-only (migrate providers)', () => { + const PROVIDERS_WRAP: EntryWrapper = { + importLine: "import { RuntimeProvider, WalletStateProvider } from './oz/runtime-providers';", + components: ['RuntimeProvider', 'WalletStateProvider'], + skipIfPresent: ['RuntimeProvider', 'OzProviders'], + }; + + it('wraps the render tree with nested providers and adds the import', () => { + const dir = createTempDir(); + writeEntry( + dir, + [ + "import { createRoot } from 'react-dom/client';", + "import App from './App';", + '', + "createRoot(document.getElementById('root')!).render(", + ' ', + ' ', + ' ', + ');', + '', + ].join('\n') + ); + + const result = transformEntryFile(dir, { wrap: PROVIDERS_WRAP }); + const out = readEntry(dir); + + expect(result.patched).toBe(true); + expect(result.changes.wrappedRenderTree).toBe(true); + expect(result.changes.createdBootstrap).toBe(false); + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).toContain(PROVIDERS_WRAP.importLine); + expect(out).not.toContain('async function'); + assertValidTsx(out); + }); + + it('skips when a provider token is already present', () => { + const dir = createTempDir(); + const source = [ + "import { RuntimeProvider, WalletStateProvider } from './oz/runtime-providers';", + "import { createRoot } from 'react-dom/client';", + '', + "createRoot(document.getElementById('root')!).render();", + '', + ].join('\n'); + writeEntry(dir, source); + + const result = transformEntryFile(dir, { wrap: PROVIDERS_WRAP }); + + expect(result.patched).toBe(false); + expect(result.reason).toBe('already-wired'); + expect(readEntry(dir)).toBe(source); + }); +}); diff --git a/packages/cli/src/codemod/entry-transform.ts b/packages/cli/src/codemod/entry-transform.ts new file mode 100644 index 0000000..1965d4b --- /dev/null +++ b/packages/cli/src/codemod/entry-transform.ts @@ -0,0 +1,475 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +/** + * AST-based transformer for React entry files (`src/main.tsx` and friends). + * + * Shared by `oz-ui add wallet` and `oz-ui migrate init` so there is a single, + * parser-backed implementation of "wrap the render tree" and "initialize config + * before render". Earlier revisions used regex/brace-counting string surgery, + * which corrupted files containing parentheses inside JSX strings, legacy + * `ReactDOM.render`, or pre-existing bootstrap functions. The parser handles all + * of those because it tokenizes the source instead of guessing. + */ + +export const ENTRY_FILE_CANDIDATES = [ + 'src/main.tsx', + 'src/main.jsx', + 'src/index.tsx', + 'src/index.jsx', +] as const; + +/** Nested JSX providers to wrap the render tree with (outermost first). */ +export interface EntryWrapper { + /** Import statement that brings the wrapper components into scope. */ + importLine: string; + /** Component names, outermost to innermost (e.g. `['RuntimeProvider', 'WalletStateProvider']`). */ + components: string[]; + /** If any token already appears in the source, the wrap is treated as done. */ + skipIfPresent: string[]; +} + +/** Async initialization to run before the React render call. */ +export interface EntryAsyncInit { + /** Import statement that brings the init helper into scope. */ + importLine: string; + /** Statement source to execute before render (may span multiple lines). */ + initStatement: string; + /** Name used when a fresh async bootstrap wrapper must be created. */ + bootstrapName: string; + /** If any token already appears in the source, the init is treated as done. */ + skipIfPresent: string[]; +} + +export interface TransformEntryOptions { + wrap?: EntryWrapper; + asyncInit?: EntryAsyncInit; +} + +export interface EntryTransformChanges { + addedWrapImport: boolean; + wrappedRenderTree: boolean; + addedInitImport: boolean; + injectedInit: boolean; + createdBootstrap: boolean; + madeFunctionAsync: boolean; +} + +export type EntryTransformReason = + | 'patched' + | 'already-wired' + | 'no-entry-file' + | 'no-render-call' + | 'unsupported-shape'; + +export interface EntryTransformResult { + entryFile: string | null; + patched: boolean; + changes: EntryTransformChanges; + reason: EntryTransformReason; +} + +interface TextEdit { + start: number; + end: number; + replacement: string; +} + +/** + * Locates the project's React entry file and applies the requested wrap and/or + * async-init transforms. Idempotent: each transform is skipped when its + * `skipIfPresent` tokens already appear in the source. + */ +export function transformEntryFile( + projectRoot: string, + options: TransformEntryOptions +): EntryTransformResult { + const entryFile = findEntryFile(projectRoot); + if (!entryFile) { + return { entryFile: null, patched: false, changes: blankChanges(), reason: 'no-entry-file' }; + } + + const filePath = path.join(projectRoot, entryFile); + const original = fs.readFileSync(filePath, 'utf8'); + + const result = applyTransforms(original, options); + + if (result.reason !== 'patched' || result.source === original) { + return { + entryFile, + patched: false, + changes: blankChanges(), + reason: result.reason === 'patched' ? 'already-wired' : result.reason, + }; + } + + fs.writeFileSync(filePath, result.source, 'utf8'); + return { entryFile, patched: true, changes: result.changes, reason: 'patched' }; +} + +interface ApplyResult { + source: string; + changes: EntryTransformChanges; + reason: EntryTransformReason; +} + +function applyTransforms(original: string, options: TransformEntryOptions): ApplyResult { + const changes = blankChanges(); + + const wantWrap = options.wrap && !hasAnyToken(original, options.wrap.skipIfPresent); + const wantInit = options.asyncInit && !hasAnyToken(original, options.asyncInit.skipIfPresent); + + if (!wantWrap && !wantInit) { + return { source: original, changes, reason: 'already-wired' }; + } + + const sourceFile = parseTsx(original); + const renderCall = findRenderCall(sourceFile); + if (!renderCall) { + return { source: original, changes, reason: 'no-render-call' }; + } + + const renderArg = unwrapParens(renderCall.arguments[0]); + const argStart = renderArg.getStart(sourceFile); + const argEnd = renderArg.getEnd(); + const baseIndent = lineIndentAt(original, argStart); + + const wrapArg = (text: string, indent: string): string => + wantWrap && options.wrap ? buildWrappedJsx(options.wrap.components, text, indent) : text; + + // Decide how the async init (if any) attaches: inject into an enclosing + // function, or create a fresh bootstrap wrapper around the render statement. + const initPlan = wantInit && options.asyncInit ? planAsyncInit(sourceFile, renderCall) : null; + if (wantInit && initPlan === null) { + return { source: original, changes: blankChanges(), reason: 'unsupported-shape' }; + } + + const edits: TextEdit[] = []; + const createsBootstrap = initPlan?.kind === 'create'; + + // In the "create" path the whole render statement is replaced, so the wrap is + // baked into that replacement instead of being a separate (overlapping) edit. + if (wantWrap && options.wrap && !createsBootstrap) { + edits.push({ + start: argStart, + end: argEnd, + replacement: wrapArg(original.slice(argStart, argEnd), baseIndent), + }); + changes.wrappedRenderTree = true; + } + + if (initPlan?.kind === 'inject') { + const injection = `\n${indentBlock(options.asyncInit!.initStatement, initPlan.bodyIndent)}\n`; + edits.push({ start: initPlan.bodyOpenPos, end: initPlan.bodyOpenPos, replacement: injection }); + changes.injectedInit = true; + if (initPlan.makeAsyncPos !== null) { + edits.push({ + start: initPlan.makeAsyncPos, + end: initPlan.makeAsyncPos, + replacement: 'async ', + }); + changes.madeFunctionAsync = true; + } + } else if (initPlan?.kind === 'create') { + const stmtText = original.slice(initPlan.stmtStart, initPlan.stmtEnd); + const wrappedStmt = wantWrap + ? replaceRange( + stmtText, + argStart - initPlan.stmtStart, + argEnd - initPlan.stmtStart, + wrapArg(original.slice(argStart, argEnd), initPlan.stmtIndent + ' ') + ) + : stmtText; + if (wantWrap) changes.wrappedRenderTree = true; + edits.push({ + start: initPlan.stmtStart, + end: initPlan.stmtEnd, + replacement: buildBootstrap(options.asyncInit!, wrappedStmt, initPlan.stmtIndent), + }); + changes.createdBootstrap = true; + } + + let updated = applyEdits(original, edits); + updated = insertImportsAfterApply(updated, options, wantWrap, wantInit, changes); + + return { source: updated, changes, reason: 'patched' }; +} + +type AsyncInitPlan = + | { + kind: 'inject'; + bodyOpenPos: number; + bodyIndent: string; + makeAsyncPos: number | null; + } + | { + kind: 'create'; + stmtStart: number; + stmtEnd: number; + stmtIndent: string; + }; + +/** + * Plans how `asyncInit` attaches to the render call: + * - inside a block-bodied function → inject at body top (make async if needed); + * - at module top level → wrap the render statement in a fresh async bootstrap; + * - inside an expression-bodied arrow → unsupported (returns null). + */ +function planAsyncInit( + sourceFile: ts.SourceFile, + renderCall: ts.CallExpression +): AsyncInitPlan | null { + const source = sourceFile.text; + const enclosing = findEnclosingFunction(renderCall); + + if (enclosing) { + if (!enclosing.body || !ts.isBlock(enclosing.body)) return null; + return { + kind: 'inject', + bodyOpenPos: enclosing.body.getStart(sourceFile) + 1, + bodyIndent: blockBodyIndent(source, enclosing.body, sourceFile), + makeAsyncPos: isAsyncFunction(enclosing) ? null : enclosing.getStart(sourceFile), + }; + } + + const statement = findTopLevelStatement(renderCall, sourceFile); + if (!statement) return null; + + const stmtStart = statement.getStart(sourceFile); + return { + kind: 'create', + stmtStart, + stmtEnd: statement.getEnd(), + stmtIndent: lineIndentAt(source, stmtStart), + }; +} + +function buildBootstrap(asyncInit: EntryAsyncInit, statementText: string, indent: string): string { + const innerIndent = `${indent} `; + return ( + `async function ${asyncInit.bootstrapName}() {\n` + + `${indentBlock(asyncInit.initStatement, innerIndent)}\n\n` + + `${indentBlock(statementText, innerIndent)}\n` + + `${indent}}\n\n` + + `${indent}void ${asyncInit.bootstrapName}();` + ); +} + +function replaceRange(text: string, start: number, end: number, replacement: string): string { + return text.slice(0, start) + replacement + text.slice(end); +} + +/** + * Inserts the wrap and/or init import lines after the last existing import. + * Done on the post-edit source so offsets from the AST pass stay valid; imports + * always sit at the top, well clear of the render-site edits. + */ +function insertImportsAfterApply( + source: string, + options: TransformEntryOptions, + wantWrap: boolean | undefined, + wantInit: boolean | undefined, + changes: EntryTransformChanges +): string { + let updated = source; + + if (wantWrap && options.wrap && changes.wrappedRenderTree) { + const before = updated; + updated = insertImport(updated, options.wrap.importLine); + changes.addedWrapImport = updated !== before; + } + + if (wantInit && options.asyncInit && (changes.injectedInit || changes.createdBootstrap)) { + const before = updated; + updated = insertImport(updated, options.asyncInit.importLine); + changes.addedInitImport = updated !== before; + } + + return updated; +} + +function blankChanges(): EntryTransformChanges { + return { + addedWrapImport: false, + wrappedRenderTree: false, + addedInitImport: false, + injectedInit: false, + createdBootstrap: false, + madeFunctionAsync: false, + }; +} + +function findEntryFile(projectRoot: string): string | null { + for (const candidate of ENTRY_FILE_CANDIDATES) { + if (fs.existsSync(path.join(projectRoot, candidate))) return candidate; + } + return null; +} + +function parseTsx(source: string): ts.SourceFile { + return ts.createSourceFile('entry.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +} + +function hasAnyToken(source: string, tokens: string[]): boolean { + return tokens.some((token) => source.includes(token)); +} + +function isJsxLike(node: ts.Node): boolean { + if (ts.isParenthesizedExpression(node)) return isJsxLike(node.expression); + return ts.isJsxElement(node) || ts.isJsxFragment(node) || ts.isJsxSelfClosingElement(node); +} + +function unwrapParens(node: ts.Expression): ts.Expression { + let current = node; + while (ts.isParenthesizedExpression(current)) current = current.expression; + return current; +} + +/** + * First `*.render(, ...)` call in source order. Matches + * `createRoot(el).render(...)`, `root.render(...)`, and legacy + * `ReactDOM.render(, el)` — the JSX argument is wrapped without touching + * any trailing container argument. + */ +function findRenderCall(sourceFile: ts.SourceFile): ts.CallExpression | null { + let found: ts.CallExpression | null = null; + + const visit = (node: ts.Node): void => { + if (found) return; + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'render' && + node.arguments.length > 0 && + isJsxLike(node.arguments[0]) + ) { + found = node; + return; + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return found; +} + +function findEnclosingFunction( + node: ts.Node +): ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | null { + let current: ts.Node | undefined = node.parent; + while (current && !ts.isSourceFile(current)) { + if ( + ts.isFunctionDeclaration(current) || + ts.isFunctionExpression(current) || + ts.isArrowFunction(current) + ) { + return current; + } + current = current.parent; + } + return null; +} + +function findTopLevelStatement(node: ts.Node, sourceFile: ts.SourceFile): ts.Statement | null { + let current: ts.Node = node; + while (current.parent && current.parent !== sourceFile) { + current = current.parent; + } + return current.parent === sourceFile && isStatement(current) ? (current as ts.Statement) : null; +} + +function isStatement(node: ts.Node): boolean { + return node.kind >= ts.SyntaxKind.FirstStatement && node.kind <= ts.SyntaxKind.LastStatement; +} + +function isAsyncFunction( + node: ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction +): boolean { + return Boolean(node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)); +} + +function insertImport(source: string, importLine: string): string { + if (source.includes(importLine)) return source; + + const lastImportEnd = findLastImportEnd(source); + if (lastImportEnd >= 0) { + return `${source.slice(0, lastImportEnd)}\n${importLine}${source.slice(lastImportEnd)}`; + } + return `${importLine}\n${source}`; +} + +function findLastImportEnd(source: string): number { + const sourceFile = parseTsx(source); + let lastEnd = -1; + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement)) lastEnd = statement.getEnd(); + } + return lastEnd; +} + +function applyEdits(source: string, edits: TextEdit[]): string { + const ordered = [...edits].sort((a, b) => b.start - a.start); + let result = source; + for (const edit of ordered) { + result = result.slice(0, edit.start) + edit.replacement + result.slice(edit.end); + } + return result; +} + +function lineIndentAt(source: string, pos: number): string { + let lineStart = pos; + while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--; + const match = source.slice(lineStart, pos).match(/^\s*/); + return match ? match[0] : ''; +} + +function blockBodyIndent(source: string, body: ts.Block, sourceFile: ts.SourceFile): string { + const firstStatement = body.statements[0]; + if (firstStatement) return lineIndentAt(source, firstStatement.getStart(sourceFile)); + return `${lineIndentAt(source, body.getStart(sourceFile))} `; +} + +/** + * Re-indents a (possibly multi-line) block to `indent`, preserving relative + * nesting by stripping the block's common leading whitespace first. + */ +function indentBlock(text: string, indent: string): string { + const lines = text.split('\n'); + if (lines.length === 1) return `${indent}${text.trim()}`; + + const commonIndent = lines + .filter((line) => line.trim().length > 0) + .reduce((min, line) => { + const leading = line.match(/^\s*/)?.[0].length ?? 0; + return Math.min(min, leading); + }, Number.POSITIVE_INFINITY); + + const strip = Number.isFinite(commonIndent) ? commonIndent : 0; + return lines + .map((line) => (line.trim().length === 0 ? '' : `${indent}${line.slice(strip)}`)) + .join('\n'); +} + +/** + * Wraps `innerText` in nested provider components. The first line carries no + * base indent because the cursor already sits at the (indented) argument + * position; subsequent lines are indented relative to `baseIndent`. + */ +function buildWrappedJsx(components: string[], innerText: string, baseIndent: string): string { + const openTags = components + .map((name, depth) => + depth === 0 ? `<${name}>` : `${baseIndent}${' '.repeat(depth)}<${name}>` + ) + .join('\n'); + + const closeTags = components + .map((name, depth) => `${baseIndent}${' '.repeat(depth)}`) + .reverse() + .join('\n'); + + const innerIndent = `${baseIndent}${' '.repeat(components.length)}`; + const inner = indentBlock(innerText, innerIndent); + + return `${openTags}\n${inner}\n${closeTags}`; +} diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts new file mode 100644 index 0000000..28055b4 --- /dev/null +++ b/packages/cli/src/commands/add.ts @@ -0,0 +1,14 @@ +import { Command } from 'commander'; + +import { registerAddWalletCommand } from './add/wallet'; + +/** + * Registers additive project-management commands. + */ +export function registerAddCommand(program: Command): void { + const add = program + .command('add') + .description('Add OpenZeppelin UI capabilities to an existing project.'); + + registerAddWalletCommand(add); +} diff --git a/packages/cli/src/commands/add/wallet.test.ts b/packages/cli/src/commands/add/wallet.test.ts new file mode 100644 index 0000000..2a52614 --- /dev/null +++ b/packages/cli/src/commands/add/wallet.test.ts @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { registerAddCommand } from '../add'; +import { CLI_PACKAGE_NAME, JSON_SCHEMA_VERSION } from '../migrate/json-results'; + +const temporaryDirectories: string[] = []; + +function createTempProject(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oz-ui-add-cmd-')); + temporaryDirectories.push(dir); + + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'sample-app', private: true, version: '0.1.0' }, null, 2) + ); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'main.tsx'), + `import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport App from './App';\n\ncreateRoot(document.getElementById('root')!).render(\n \n \n \n);\n` + ); + + return dir; +} + +async function captureStdout(fn: () => Promise | void): Promise { + const chunks: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); + return true; + }) as typeof process.stdout.write; + + try { + await fn(); + } finally { + process.stdout.write = originalWrite; + } + + return chunks.join(''); +} + +async function runAdd(args: string[]): Promise { + const root = new Command(); + registerAddCommand(root); + return captureStdout(async () => { + await root.parseAsync(['add', ...args], { from: 'user' }); + }); +} + +afterEach(() => { + process.exitCode = 0; + for (const dir of temporaryDirectories.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('add wallet command', () => { + it('emits a JSON envelope with the add-wallet action', async () => { + const dir = createTempProject(); + + const stdout = await runAdd([ + 'wallet', + '--project', + dir, + '--kit', + 'custom', + '--skip-install', + '--json', + ]); + const payload = JSON.parse(stdout); + + expect(payload.action).toBe('add-wallet'); + expect(payload.ok).toBe(true); + expect(payload.schemaVersion).toBe(JSON_SCHEMA_VERSION); + expect(payload.cli).toEqual({ name: CLI_PACKAGE_NAME, version: expect.any(String) }); + expect(payload.kit).toBe('custom'); + expect(payload.entryFilePatched).toBe('src/main.tsx'); + expect(payload.filesWritten).toEqual( + expect.arrayContaining(['src/oz/OzProviders.tsx', 'src/oz/runtime.ts']) + ); + }); + + it('prints a non-JSON success summary by default', async () => { + const dir = createTempProject(); + + const stdout = await runAdd([ + 'wallet', + '--project', + dir, + '--kit', + 'rainbowkit', + '--skip-install', + '--yes', + ]); + + expect(stdout).toContain('Wallet wiring added'); + expect(stdout).toContain('rainbowkit'); + expect(stdout).toContain('Files written:'); + expect(stdout).toContain('Next steps'); + }); +}); diff --git a/packages/cli/src/commands/add/wallet.ts b/packages/cli/src/commands/add/wallet.ts new file mode 100644 index 0000000..fb25b0d --- /dev/null +++ b/packages/cli/src/commands/add/wallet.ts @@ -0,0 +1,134 @@ +import path from 'node:path'; +import { createInterface } from 'node:readline/promises'; +import { Command } from 'commander'; +import pc from 'picocolors'; + +import { + addWalletToProject, + type AddWalletKit, + type AddWalletOptions, + type AddWalletResult, +} from '../../add/wallet/install'; +import { printError, printJson } from '../../utils/logger'; + +interface AddWalletCommandOptions { + project: string; + ecosystem?: 'evm'; + kit?: AddWalletKit; + packageManager?: AddWalletOptions['packageManager']; + skipInstall?: boolean; + force?: boolean; + yes?: boolean; + json?: boolean; +} + +const WALLET_KITS = ['custom', 'rainbowkit'] as const; + +function normalizeAnswer(answer: string): string { + return answer.trim().toLowerCase(); +} + +async function promptForWalletOptions(initial: AddWalletOptions): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = normalizeAnswer( + await rl.question(`Wallet kit (${WALLET_KITS.join('/')}) [${initial.kit}]: `) + ); + const kit = + answer === '' + ? initial.kit + : WALLET_KITS.includes(answer as AddWalletKit) + ? (answer as AddWalletKit) + : null; + + if (!kit) { + throw new Error( + `Unsupported wallet kit "${answer}". Expected one of: ${WALLET_KITS.join(', ')}` + ); + } + + return { ...initial, kit }; + } finally { + rl.close(); + } +} + +function renderSuccess(result: AddWalletResult): void { + process.stdout.write(pc.green(`Wallet wiring added to ${result.projectRoot}\n`)); + process.stdout.write(` Ecosystem: ${result.ecosystem}\n`); + process.stdout.write(` Kit: ${result.kit}\n`); + process.stdout.write(` Files written: ${result.filesWritten.length}\n`); + if (result.filesSkipped.length > 0) { + process.stdout.write(` Files skipped: ${result.filesSkipped.length}\n`); + } + if (result.entryFilePatched) { + process.stdout.write(` Entry file patched: ${pc.cyan(result.entryFilePatched)}\n`); + } else if (result.entryFilePatchReason !== 'already-wired') { + process.stdout.write( + ` ${pc.yellow(`Entry file not patched (${result.entryFilePatchReason}) — see next steps to wire it manually`)}\n` + ); + } + if (result.appConfigPatched) { + process.stdout.write(` App config updated: ${pc.cyan(result.appConfigPatched)}\n`); + } + + if (result.installCommand) { + const installState = result.installRan + ? 'Installed dependencies' + : 'Dependency install skipped'; + process.stdout.write(` ${installState}: ${pc.cyan(result.installCommand)}\n`); + } + + process.stdout.write(`\n${pc.bold('Next steps')}\n`); + for (const step of result.nextSteps) { + process.stdout.write(` ${pc.cyan(step)}\n`); + } +} + +/** + * Registers `oz-ui add wallet`, which applies wallet runtime wiring to an + * existing React app. + */ +export function registerAddWalletCommand(parent: Command): void { + parent + .command('wallet') + .description('Add OpenZeppelin UI wallet/runtime wiring to an existing project.') + .option('-p, --project ', 'Project root directory', process.cwd()) + .option('--ecosystem ', 'Target ecosystem (v1: evm)', 'evm') + .option('--kit ', 'Wallet kit: custom or rainbowkit') + .option('--package-manager ', 'npm, pnpm, or yarn') + .option('--skip-install', 'Write files without installing dependencies') + .option('--force', 'Overwrite generated wallet files that already exist') + .option('-y, --yes', 'Use defaults and skip interactive prompts') + .option('--json', 'Emit machine-readable JSON output') + .action(async (options: AddWalletCommandOptions) => { + const json = Boolean(options.json); + try { + const baseOptions: AddWalletOptions = { + projectRoot: path.resolve(options.project), + ecosystem: options.ecosystem ?? 'evm', + kit: options.kit ?? 'custom', + packageManager: options.packageManager, + skipInstall: Boolean(options.skipInstall), + force: Boolean(options.force), + }; + + const resolvedOptions = + options.yes || options.json ? baseOptions : await promptForWalletOptions(baseOptions); + const result = addWalletToProject(resolvedOptions); + + if (json) { + printJson(result); + return; + } + + renderSuccess(result); + } catch (error) { + printError(error, json); + } + }); +} diff --git a/packages/cli/src/create/package-json.ts b/packages/cli/src/create/package-json.ts index ff01cd9..693cd1a 100644 --- a/packages/cli/src/create/package-json.ts +++ b/packages/cli/src/create/package-json.ts @@ -1,16 +1,7 @@ -import { CLI_VERSION } from '../branding'; +import { UI_VERSIONS } from '../versions'; +import { EVM_WALLET_DEV_DEPENDENCIES, walletRuntimeDependenciesForKit } from '../wallet/scaffold'; import type { CreateAppSpec, ResolvedCreateOptions } from './types'; -const UI_VERSIONS = { - cli: CLI_VERSION === '0.0.0' ? 'latest' : `^${CLI_VERSION}`, - components: '^2.3.1', - react: '^2.0.1', - renderer: '^2.0.1', - styles: '^1.1.0', - types: '^2.0.0', - utils: '^2.0.0', -}; - /** * Renders the generated app package manifest. */ @@ -24,18 +15,7 @@ export function packageJson(options: ResolvedCreateOptions, spec: CreateAppSpec) }; if (spec.hasWallet) { - dependencies['@openzeppelin/adapter-evm'] = '^2.0.1'; - dependencies['@openzeppelin/ui-react'] = UI_VERSIONS.react; - dependencies['@openzeppelin/ui-types'] = UI_VERSIONS.types; - dependencies['@tanstack/react-query'] = '^5.84.1'; - dependencies['@wagmi/core'] = '^2.20.3'; - dependencies['react-hook-form'] = '^7.71.1'; - dependencies.viem = '^2.33.3'; - dependencies.wagmi = '^2.17.0'; - } - - if (options.wallet === 'rainbowkit') { - dependencies['@rainbow-me/rainbowkit'] = '^2.2.8'; + Object.assign(dependencies, walletRuntimeDependenciesForKit(options.wallet)); } if (spec.hasRouter) { @@ -57,7 +37,7 @@ export function packageJson(options: ResolvedCreateOptions, spec: CreateAppSpec) }; if (spec.hasWallet) { - devDependencies['@openzeppelin/adapters-vite'] = '^2.0.0'; + Object.assign(devDependencies, EVM_WALLET_DEV_DEPENDENCIES); } return `${JSON.stringify( diff --git a/packages/cli/src/create/support-files.ts b/packages/cli/src/create/support-files.ts index 97291ff..b431c10 100644 --- a/packages/cli/src/create/support-files.ts +++ b/packages/cli/src/create/support-files.ts @@ -1,9 +1,18 @@ -import type { ResolvedCreateOptions } from './types'; +import type { CreateWallet } from './types'; + +/** + * Minimum input both `oz-ui create` (full scaffold) and `oz-ui add wallet` + * (additive wiring on an existing project) need to render wallet-related files. + */ +export interface WalletSupportInput { + wallet: CreateWallet; + projectName: string; +} /** * Renders the generated runtime config JSON. */ -export function appConfig(options: ResolvedCreateOptions): string { +export function appConfig(options: WalletSupportInput): string { return `${JSON.stringify( { $comment: 'Base runtime config. Override with VITE_APP_CFG_* values in .env.local.', @@ -87,7 +96,7 @@ export async function initializeAppConfig(): Promise { /** * Renders the generated EVM runtime adapter wiring. */ -export function ozRuntime(options: ResolvedCreateOptions): string { +export function ozRuntime(options: WalletSupportInput): string { return `import { ecosystemDefinition } from '@openzeppelin/adapter-evm'; import { evmNetworks } from '@openzeppelin/adapter-evm/networks'; import type { EcosystemRuntime, NetworkConfig } from '@openzeppelin/ui-types'; @@ -160,7 +169,7 @@ export function OzProviders({ children }: { children: ReactNode }) { /** * Renders the generated RainbowKit native config module. */ -export function rainbowKitConfig(options: ResolvedCreateOptions): string { +export function rainbowKitConfig(options: WalletSupportInput): string { return `const rainbowKitConfig = { wagmiParams: { appName: '${options.projectName}', diff --git a/packages/cli/src/create/templates/index.ts b/packages/cli/src/create/templates/index.ts index 01dd10b..baf9446 100644 --- a/packages/cli/src/create/templates/index.ts +++ b/packages/cli/src/create/templates/index.ts @@ -1,14 +1,7 @@ +import { buildWalletSupportFiles } from '../../wallet/scaffold'; import { packageJson } from '../package-json'; import { resolveCreateAppSpec } from '../recipes'; -import { - appConfig, - indexCss, - ozConfig, - ozProviders, - ozRuntime, - rainbowKitConfig, - runtimeStatus, -} from '../support-files'; +import { indexCss, runtimeStatus } from '../support-files'; import type { CreateFile, ResolvedCreateOptions } from '../types'; import { viteConfig } from '../vite-template'; import { appTsx } from './app'; @@ -71,12 +64,7 @@ export function buildCreateFiles(options: ResolvedCreateOptions): CreateFile[] { ]; if (spec.hasWallet) { - files.push( - { path: 'public/app.config.json', content: appConfig(options) }, - { path: 'src/oz/config.ts', content: ozConfig() }, - { path: 'src/oz/runtime.ts', content: ozRuntime(options) }, - { path: 'src/oz/OzProviders.tsx', content: ozProviders() } - ); + files.push(...buildWalletSupportFiles(options)); } if (spec.requiresLogoAsset) { @@ -87,9 +75,5 @@ export function buildCreateFiles(options: ResolvedCreateOptions): CreateFile[] { files.push({ path: 'src/components/RuntimeStatus.tsx', content: runtimeStatus() }); } - if (options.wallet === 'rainbowkit') { - files.push({ path: 'src/oz/wallet/rainbowkit.config.ts', content: rainbowKitConfig(options) }); - } - return files; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index df88b64..518c9a8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander'; +import { registerAddCommand } from './commands/add'; import { registerCreateCommand } from './commands/create'; import { registerMigrateCommand } from './commands/migrate'; @@ -13,6 +14,7 @@ program .description('OpenZeppelin UI CLI — scaffold, migrate, and manage OZ UI applications.') .version(CLI_VERSION); +registerAddCommand(program); registerCreateCommand(program); registerMigrateCommand(program); diff --git a/packages/cli/src/templates/skills/migrate-to-oz-uikit/SKILL.md b/packages/cli/src/templates/skills/migrate-to-oz-uikit/SKILL.md index d416e2e..b73081f 100644 --- a/packages/cli/src/templates/skills/migrate-to-oz-uikit/SKILL.md +++ b/packages/cli/src/templates/skills/migrate-to-oz-uikit/SKILL.md @@ -1,37 +1,60 @@ -# migrate-to-oz-uikit +--- -Migrate an existing React application to the OpenZeppelin UI Kit. This skill orchestrates the full migration lifecycle using CLI commands and specialized subagents. +## name: migrate-to-oz-uikit +description: >- + Migrate an existing React application to the OpenZeppelin UI Kit using `oz-ui migrate` (init, analyze, + plan, execute, status, doctor). Use when adopting `@openzeppelin/ui-react`, replacing another UI stack, + or resuming from `migration-manifest.json`. Not for greenfield apps—use `scaffold-dapp` or `oz-ui create`. +when_to_use: >- + Existing React repo with package.json; migration manifest present or user wants to run migrate init; + user asks to move to OZ UI Kit, migrate from MUI/Chakra/shadcn, or continue a migration. Skip for + empty-folder new projects unless wiring a parent monorepo migration. +compatibility: >- + Node.js 20+ recommended; npm, pnpm, or yarn; `@openzeppelin/ui-cli` as a dev dependency after init; + network for installs. Subagent names (migration-analyzer, migration-executor, migration-verifier) assume + Claude Code–style agents where installed; otherwise run the equivalent CLI commands directly. -## Prerequisites +# Migrate to OpenZeppelin UI Kit + +Orchestrate the full migration lifecycle using CLI commands and, where available, specialized subagents (`migration-analyzer`, `migration-executor`, `migration-verifier`). + +## When to Use + +- The user has an **existing React app** and wants to adopt the OpenZeppelin UI Kit (`@openzeppelin/ui-react`, OZ providers, Tailwind alignment). +- `**migration-manifest.json` exists** or the user needs `**oz-ui migrate init`** before analyze/plan/execute. +- The user asks to **migrate off** MUI, Chakra, shadcn, another component library, or custom UI toward OZ components. + +**Do not use** as the primary path for **brand-new** scaffolding from an empty directory—use `**scaffold-dapp`** (`oz-ui create`) instead. + +## Instructions + +### Prerequisites - `@openzeppelin/ui-cli` must be installed as a dev dependency - The project must be a React application with a `package.json` -> `oz-ui` is a locally-installed binary. Always run it via the project's package -> manager: `npx oz-ui ...` or `pnpm exec oz-ui ...`. Bare `oz-ui` will fail -> with "command not found". +> `oz-ui` is a locally-installed binary. Always run it via the project's package manager: `npx oz-ui ...` or `pnpm exec oz-ui ...`. Bare `oz-ui` will fail with "command not found". -## Workflow +### Workflow -### Ordered pipeline +#### Ordered pipeline For a full migration, keep this sequence (each step builds on the last): -1. **`npx oz-ui migrate init`** — when there is no `migration-manifest.json` yet and OpenZeppelin UI packages are not wired (see Step 1). -2. **`npx oz-ui migrate analyze`** — scan the repo; produce `migration-analysis.json`. +1. `**npx oz-ui migrate init**` — when there is no `migration-manifest.json` yet and OpenZeppelin UI packages are not wired (see Step 1). +2. `**npx oz-ui migrate analyze**` — scan the repo; produce `migration-analysis.json`. 3. **Align** with the user on profile, scope, and ambiguous mappings (decisions inform the plan). -4. **`npx oz-ui migrate plan`** — produce `migration-manifest.json` with phased tasks. -5. **Execute** tasks from the manifest in phase order, using **`npx oz-ui migrate execute`** for deterministic work and manual edits only when the CLI returns a manual-review task. -6. **Complete** with **`npx oz-ui migrate status`** and a full **`npx oz-ui migrate doctor`** pass on the manifest. +4. `**npx oz-ui migrate plan`** — produce `migration-manifest.json` with phased tasks. +5. **Execute** tasks from the manifest in phase order, using `**npx oz-ui migrate execute`** for deterministic work and manual edits only when the CLI returns a manual-review task. +6. **Complete** with `**npx oz-ui migrate status`** and a full `**npx oz-ui migrate doctor**` pass on the manifest. Use `npx oz-ui migrate status --manifest migration-manifest.json --next` whenever you need the CLI to suggest the next command sequence. If a manifest already exists, **resume** with status/doctor instead of re-running init. -The **setup phase** must complete before analysis begins on a fresh project. Do not run -`npx oz-ui migrate analyze` until `npx oz-ui migrate init` has finished and the provider / asset scaffolding is in place. +The **setup phase** must complete before analysis begins on a fresh project. Do not run `npx oz-ui migrate analyze` until `npx oz-ui migrate init` has finished and the provider / asset scaffolding is in place. -### Step 1: Resume or Initialize +#### Step 1: Resume or Initialize Check if a migration is already in progress: @@ -40,16 +63,17 @@ ls migration-manifest.json 2>/dev/null ``` **If manifest exists**, resume: + 1. Run `npx oz-ui migrate status --manifest migration-manifest.json` to see progress 2. Run `npx oz-ui migrate doctor --manifest migration-manifest.json --json` to verify completed tasks 3. Report the current state to the user and ask whether to continue, fix failures, or restart **If no manifest exists**, check for OZ packages: + - If `@openzeppelin/ui-react` is in `package.json`, the project is partially set up — skip to analysis - Otherwise, run initialization: -If no manifest exists, run initialization before you skip to analysis. -Ask the user which assistant target(s) to install if they have not already specified an `--agent-profile` value. Valid values are `standard`, `claude`, `legacy-cursor`, `all`, and `none`; there is no default. +If no manifest exists, run initialization before you skip to analysis. Ask the user which assistant target(s) to install if they have not already specified an `--agent-profile` value. Valid values are `standard`, `claude`, `legacy-cursor`, `all`, and `none`; there is no default. ```bash npx oz-ui migrate init --project . --agent-profile @@ -59,7 +83,7 @@ This installs OZ packages, wires `RuntimeProvider` + `WalletStateProvider`, norm Tell the user to wrap their app root with `` from `src/oz/OzProviders.tsx`. -### Step 2: Analyze +#### Step 2: Analyze Delegate to the **migration-analyzer** subagent, or run directly: @@ -68,6 +92,7 @@ npx oz-ui migrate analyze --project . --json --output migration-analysis.json ``` Present a summary to the user: + - Framework, router, state/styling setup, and whether OZ packages are already present - How many components were found and how many map to OZ equivalents - Which UI library is in use (shadcn, MUI, Chakra, etc.) @@ -77,9 +102,10 @@ Present a summary to the user: - The estimated effort level (low / medium / high) - Recommended profile (viewer / transactor / operator) -### Step 3: User Alignment +#### Step 3: User Alignment Ask the user to confirm: + 1. **Profile selection**: "Based on your wallet usage, I recommend the `transactor` profile. Confirm or override?" 2. **Assistant target selection**: "Which agent profile should the manifest use (`standard`, `claude`, `legacy-cursor`, `all`, or `none`)?" 3. **Scope**: "Migrate the entire project or a specific directory?" @@ -87,7 +113,7 @@ Ask the user to confirm: Record all decisions — they will be passed to the plan command using `--decision key=value` flags and persisted in the manifest. -### Step 4: Generate Plan +#### Step 4: Generate Plan ```bash npx oz-ui migrate plan --report migration-analysis.json --json --profile [--scope ] [--decision ] @@ -95,7 +121,7 @@ npx oz-ui migrate plan --report migration-analysis.json --json --profile ', + ' ', + ' );', + '}', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Button', targetComponent: 'Button' }), + input, + { propMappings: { size: 'scale' } } satisfies RewriteContext + ); + + expect(out).toContain(';', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Button', targetComponent: 'Button' }), + input + ); + + const legacy = importLineFor(out, '@/components/ui/button'); + expect(legacy).toContain('Tooltip as Tip'); + expect(legacy).not.toMatch(/\bButton\b/); + expect(importLineFor(out, OZ)).toContain('Button'); + }); + + it('handles a multiline named import block', () => { + const input = [ + 'import {', + ' Button,', + ' Spinner,', + "} from '@/components/ui/button';", + '', + 'export const App = () => ;', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Button', targetComponent: 'Button' }), + input + ); + + expect(importLineFor(out, OZ)).toContain('Button'); + expect(out).toContain('Spinner'); + expect(out).not.toContain("Button,\n} from '@/components/ui/button'"); + }); + + it('remaps a prop that follows an attribute whose value contains ">"', () => { + const input = [ + "import { Button } from '@/components/ui/button';", + '', + 'export const App = () => (', + ' ', + ');', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Button', targetComponent: 'Button' }), + input, + { propMappings: { size: 'scale' } } + ); + + expect(out).toContain('scale="lg"'); + expect(out).not.toContain('size="lg"'); + expect(out).toContain('onClick={() => go(a > b)}'); + }); + + it('renames only the exact tag and leaves same-prefix siblings untouched', () => { + const input = [ + "import { Tabs, Tab } from '@mui/material';", + '', + 'export const App = () => (', + ' ', + ' ', + ' ', + ');', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Tab', targetComponent: 'TabsTrigger' }), + input + ); + + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).not.toContain(' { it('only remaps props on the target component tag', () => { const input = [ diff --git a/packages/cli/src/rewriter/rewriteFile.ts b/packages/cli/src/rewriter/rewriteFile.ts index cb53e0f..376dc9a 100644 --- a/packages/cli/src/rewriter/rewriteFile.ts +++ b/packages/cli/src/rewriter/rewriteFile.ts @@ -1,11 +1,20 @@ /** * Deterministic code rewriter for the migrate-to-oz-uikit system. * - * Handles the 80% case: import swaps and prop renames. - * Complex scenarios (layout restructuring, logic migration) are deferred - * to AI-assisted editing via the orchestration skill. + * Handles the 80% case: import swaps, JSX tag renames, prop renames, and the + * radix namespace-member transforms. Complex scenarios (layout restructuring, + * logic migration) are deferred to AI-assisted editing via the orchestration + * skill. + * + * Rewrites are AST-based (TypeScript compiler API): the source is tokenized and + * edited via offset splices instead of regex/brace-counting. This avoids the + * corruption the earlier string-based implementation produced on JSX containing + * parentheses, aliased/multiline imports, nested compound tags, and members + * whose names are prefixes of one another. */ +import ts from 'typescript'; + import { loadSourceLibraries } from '../catalog/index.js'; import type { MigrationTask } from '../manifest/schema.js'; @@ -19,26 +28,22 @@ export interface RewriteContext { const OZ_NS_UNWRAP = '__OZ_NS_UNWRAP__'; const OZ_NS_OMIT = '__OZ_NS_OMIT__'; const OZ_NS_CLOSE_AS_CHILD = '__OZ_NS_CLOSE_AS_CHILD__'; +const DEFAULT_TARGET_PACKAGE = '@openzeppelin/ui-components'; -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function extractUseStateSetter(content: string): string { - const m = content.match(/\[\s*\w+\s*,\s*(\w+)\s*\]\s*=\s*useState\s*\(/); - return m?.[1] ?? 'setOpen'; +interface TextEdit { + start: number; + end: number; + replacement: string; } -function specifierBaseName(spec: string): string { - return spec.includes(' as ') ? spec.split(' as ')[0].trim() : spec.trim(); -} +// --------------------------------------------------------------------------- +// Catalog lookups (unchanged data contract with the planner) +// --------------------------------------------------------------------------- -/** True when the catalog maps this export name to a different compound family root (e.g. TabsContent → Tabs). JSX tags keep the export name after migration. */ +/** True when the catalog maps this export to a different compound family root (e.g. TabsContent → Tabs); JSX tags keep the export name after migration. */ function isCompoundFamilyExport(componentName: string): boolean { - const libraries = loadSourceLibraries(); - for (const lib of Object.values(libraries)) { - const entry = lib.mappings[componentName] as { source?: string } | undefined; - const root = entry?.source; + for (const lib of Object.values(loadSourceLibraries())) { + const root = (lib.mappings[componentName] as { source?: string } | undefined)?.source; if (root && root !== componentName) return true; } return false; @@ -46,8 +51,7 @@ function isCompoundFamilyExport(componentName: string): boolean { /** Catalog `source` root for a component (e.g. CardHeader → Card). Used to group compound imports. */ function catalogSourceRootForComponent(componentName: string): string | null { - const libraries = loadSourceLibraries(); - for (const lib of Object.values(libraries)) { + for (const lib of Object.values(loadSourceLibraries())) { const entry = lib.mappings[componentName] as { source?: string } | undefined; if (entry?.source) return entry.source; } @@ -62,14 +66,11 @@ function importSpecifiersBelongToSourceFamily( if (!bases.includes(sourceComponent)) return false; const familyRoot = catalogSourceRootForComponent(sourceComponent); if (!familyRoot) return false; - const libraries = loadSourceLibraries(); - for (const lib of Object.values(libraries)) { - const pathMatch = lib.importPatterns.some((p) => importPath.includes(p)); - if (!pathMatch) continue; - const allOk = bases.every((base) => { - const entry = lib.mappings[base] as { source?: string } | undefined; - return entry?.source === familyRoot; - }); + for (const lib of Object.values(loadSourceLibraries())) { + if (!lib.importPatterns.some((p) => importPath.includes(p))) continue; + const allOk = bases.every( + (base) => (lib.mappings[base] as { source?: string } | undefined)?.source === familyRoot + ); if (allOk) return true; } return false; @@ -79,58 +80,113 @@ function findNamespaceMemberToTarget( sourceComponent: string, importPath: string ): Record | null { - const libraries = loadSourceLibraries(); - for (const lib of Object.values(libraries)) { - const matched = lib.importPatterns.some((p) => importPath.includes(p)); - if (!matched) continue; - const entry = lib.mappings[sourceComponent] as - | { namespaceMemberToTarget?: Record } - | undefined; - const map = entry?.namespaceMemberToTarget; + for (const lib of Object.values(loadSourceLibraries())) { + if (!lib.importPatterns.some((p) => importPath.includes(p))) continue; + const map = ( + lib.mappings[sourceComponent] as + | { namespaceMemberToTarget?: Record } + | undefined + )?.namespaceMemberToTarget; if (map && Object.keys(map).length > 0) return map; } return null; } function collectOzComponentNames(memberMap: Record): string[] { - const names: string[] = []; - for (const v of Object.values(memberMap)) { - if (v.startsWith('__OZ_NS_')) continue; - names.push(v); - } - return names; + return Object.values(memberMap).filter((v) => !v.startsWith('__OZ_NS_')); } function collectCatalogImportPathSubstrings(): string[] { - const substrings: string[] = []; - for (const lib of Object.values(loadSourceLibraries())) { - substrings.push(...lib.importPatterns); + return Object.values(loadSourceLibraries()).flatMap((lib) => lib.importPatterns); +} + +// --------------------------------------------------------------------------- +// AST utilities +// --------------------------------------------------------------------------- + +function parseTsx(source: string): ts.SourceFile { + return ts.createSourceFile( + 'rewrite.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); +} + +function visit(node: ts.Node, cb: (node: ts.Node) => void): void { + cb(node); + ts.forEachChild(node, (child) => visit(child, cb)); +} + +/** Applies non-overlapping edits right-to-left so earlier offsets stay valid. */ +function applyEdits(source: string, edits: TextEdit[]): string { + const ordered = [...edits].sort((a, b) => b.start - a.start); + let result = source; + for (const edit of ordered) { + result = result.slice(0, edit.start) + edit.replacement + result.slice(edit.end); } - return substrings; + return result; } -/** Start index of the first import line whose module path matches a catalog legacy pattern (not OZ). */ -function firstLegacyCatalogImportLineStart(content: string): number | null { - const patterns = collectCatalogImportPathSubstrings(); - let idx = 0; - for (const line of content.split('\n')) { - const importIdx = line.search(/^\s*import\b/); - if (importIdx >= 0) { - const fromMatch = line.match(/from\s*['"]([^'"]+)['"]/); - const modPath = fromMatch?.[1]; - if ( - modPath && - !modPath.includes('@openzeppelin') && - patterns.some((p) => modPath.includes(p)) - ) { - return idx + importIdx; - } - } - idx += line.length + 1; +function collapseBlankLines(source: string): string { + return source.replace(/^\s*\n{2,}/gm, '\n'); +} + +function lineStartOffset(source: string, pos: number): number { + let start = pos; + while (start > 0 && source[start - 1] !== '\n') start--; + return start; +} + +/** Removes a node, taking the whole physical line with it when the node is the only non-whitespace on that line. */ +function removeNodeEdit(source: string, start: number, end: number): TextEdit { + const lineStart = lineStartOffset(source, start); + const beforeBlank = source.slice(lineStart, start).trim() === ''; + let lineEnd = end; + while (lineEnd < source.length && source[lineEnd] !== '\n') lineEnd++; + const afterBlank = source.slice(end, lineEnd).trim() === ''; + if (beforeBlank && afterBlank) { + const consumeNewline = lineEnd < source.length ? lineEnd + 1 : lineEnd; + return { start: lineStart, end: consumeNewline, replacement: '' }; + } + return { start, end, replacement: '' }; +} + +function lastImportEnd(sourceFile: ts.SourceFile): number { + let end = -1; + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement)) end = statement.getEnd(); + } + return end; +} + +function importModulePath(node: ts.ImportDeclaration): string | null { + return ts.isStringLiteral(node.moduleSpecifier) ? node.moduleSpecifier.text : null; +} + +/** Imported (non-aliased) base name of a specifier: `Foo as Bar` → `Foo`. */ +function specifierBase(element: ts.ImportSpecifier): string { + return (element.propertyName ?? element.name).text; +} + +/** `Source.Member` tag → member name, or null when the tag is not a namespace member of `source`. */ +function namespaceMemberName(tagName: ts.JsxTagNameExpression, source: string): string | null { + if ( + ts.isPropertyAccessExpression(tagName) && + ts.isIdentifier(tagName.expression) && + tagName.expression.text === source && + ts.isIdentifier(tagName.name) + ) { + return tagName.name.text; } return null; } +// --------------------------------------------------------------------------- +// OZ named-import construction / merge +// --------------------------------------------------------------------------- + function formatOzNamedImportStatement( targetPackage: string, namesSorted: string[], @@ -143,6 +199,22 @@ function formatOzNamedImportStatement( return `import {\n${body}\n} from '${targetPackage}';`; } +function firstLegacyCatalogImport(sourceFile: ts.SourceFile): ts.ImportDeclaration | null { + const patterns = collectCatalogImportPathSubstrings(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const modPath = importModulePath(statement); + if ( + modPath && + !modPath.includes('@openzeppelin') && + patterns.some((p) => modPath.includes(p)) + ) { + return statement; + } + } + return null; +} + function mergeOzNamedImports( content: string, targetPackage: string, @@ -152,206 +224,378 @@ function mergeOzNamedImports( const unique = [...new Set(names)].sort((a, b) => a.localeCompare(b)); if (unique.length === 0) return content; - const multiline = preferMultiline && unique.length >= 5; + const sourceFile = parseTsx(content); - const ozImportRegex = new RegExp( - `import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${escapeRegex(targetPackage)}['"]` - ); - const ozMatch = content.match(ozImportRegex); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + if (importModulePath(statement) !== targetPackage) continue; + const named = statement.importClause?.namedBindings; + if (!named || !ts.isNamedImports(named)) continue; - if (ozMatch) { - const existing = ozMatch[1] - .split(',') - .map((s) => s.trim()) - .filter(Boolean); + const existing = named.elements.map((el) => el.getText(sourceFile)); const merged = [...new Set([...existing, ...unique])].sort((a, b) => a.localeCompare(b)); - const stmt = formatOzNamedImportStatement(targetPackage, merged, multiline); - return content.replace(ozMatch[0], stmt); + const multiline = preferMultiline && merged.length >= 5; + return applyEdits(content, [ + { + start: statement.getStart(sourceFile), + end: statement.getEnd(), + replacement: formatOzNamedImportStatement(targetPackage, merged, multiline), + }, + ]); } + const multiline = preferMultiline && unique.length >= 5; const newLine = `${formatOzNamedImportStatement(targetPackage, unique, multiline)}\n`; - const beforeLegacy = firstLegacyCatalogImportLineStart(content); - if (beforeLegacy !== null) { - return content.slice(0, beforeLegacy) + newLine + content.slice(beforeLegacy); + + const legacy = firstLegacyCatalogImport(sourceFile); + if (legacy) { + const insertAt = lineStartOffset(content, legacy.getStart(sourceFile)); + return content.slice(0, insertAt) + newLine + content.slice(insertAt); } - const lastImportIdx = content.lastIndexOf('import '); - if (lastImportIdx >= 0) { - const lineEnd = content.indexOf('\n', lastImportIdx); - const insertAt = lineEnd >= 0 ? lineEnd + 1 : content.length; + const lastEnd = lastImportEnd(sourceFile); + if (lastEnd >= 0) { + const insertAt = content[lastEnd] === '\n' ? lastEnd + 1 : lastEnd; return content.slice(0, insertAt) + newLine + content.slice(insertAt); } return newLine + content; } -function rewriteNamespaceImportBody( +// --------------------------------------------------------------------------- +// Named-import migration (+ JSX tag rename) +// --------------------------------------------------------------------------- + +interface NamedImportRewrite { + content: string; + found: boolean; +} + +/** + * Swaps legacy named imports of `source` to the OZ package. Whole import groups + * that belong to one compound family are collapsed into the OZ import; mixed + * imports keep their unrelated specifiers. Returns `found: false` (a no-op) when + * no legacy import references the source — so an absent component is never given + * a spurious OZ import. + */ +function rewriteNamedImports( content: string, source: string, - memberMap: Record, + target: string, targetPackage: string -): string { - let result = content; - const setter = extractUseStateSetter(content); - - for (const [member, target] of Object.entries(memberMap)) { - if (target !== OZ_NS_CLOSE_AS_CHILD) continue; - const closeAsChildRe = new RegExp( - `<${escapeRegex(source)}\\.${escapeRegex(member)}\\s+asChild>\\s*([\\s\\S]*?)\\s*`, - 'g' - ); - result = result.replace(closeAsChildRe, (_, inner: string) => { - const trimmed = inner.trim(); - return trimmed.replace( - /^[ \t]*<([A-Za-z][\w.]*)([^>]*?)(\/?>)/, - (full, tag: string, attrs: string, self: string) => { - if (attrs.includes('onClick')) return full; - if (self === '/>') return `<${tag}${attrs} onClick={() => ${setter}(false)} />`; - return `<${tag}${attrs} onClick={() => ${setter}(false)}>`; - } - ); - }); - } - - for (const [member, target] of Object.entries(memberMap)) { - if (target !== OZ_NS_OMIT) continue; - const omitRe = new RegExp( - `\\s*<${escapeRegex(source)}\\.${escapeRegex(member)}[^>]*/>\\s*`, - 'g' - ); - result = result.replace(omitRe, '\n'); - } +): NamedImportRewrite { + const sourceFile = parseTsx(content); + const edits: TextEdit[] = []; + const ozSymbols = new Set(); + let found = false; + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const importPath = importModulePath(statement); + if (!importPath || importPath.includes('@openzeppelin/')) continue; + const named = statement.importClause?.namedBindings; + if (!named || !ts.isNamedImports(named)) continue; + + const bases = named.elements.map(specifierBase); + if (!bases.includes(source)) continue; + found = true; + + if (importSpecifiersBelongToSourceFamily(importPath, bases, source)) { + edits.push(removeNodeEdit(content, statement.getStart(sourceFile), statement.getEnd())); + for (const base of bases) ozSymbols.add(base); + continue; + } - for (const [member, target] of Object.entries(memberMap)) { - if (target !== OZ_NS_UNWRAP) continue; - const unwrapRe = new RegExp( - `<${escapeRegex(source)}\\.${escapeRegex(member)}\\s*>\\s*([\\s\\S]*?)\\s*`, - 'g' - ); - result = result.replace(unwrapRe, '$1'); + const remaining = named.elements.filter((el) => specifierBase(el) !== source); + if (remaining.length === 0) { + edits.push(removeNodeEdit(content, statement.getStart(sourceFile), statement.getEnd())); + } else { + edits.push({ + start: named.getStart(sourceFile), + end: named.getEnd(), + replacement: `{ ${remaining.map((el) => el.getText(sourceFile)).join(', ')} }`, + }); + } + ozSymbols.add(target); } - const renameMembers = Object.entries(memberMap) - .filter(([, target]) => !target.startsWith('__OZ_NS_')) - .sort((a, b) => b[0].length - a[0].length); - - for (const [member, target] of renameMembers) { - const openRe = new RegExp(`<${escapeRegex(source)}\\.${escapeRegex(member)}(\\s|>)`, 'g'); - result = result.replace(openRe, `<${target}$1`); - const closeReTag = new RegExp(``, 'g'); - result = result.replace(closeReTag, ``); - } + if (!found) return { content, found: false }; - const nsImportLine = new RegExp( - `^import\\s+\\*\\s+as\\s+${escapeRegex(source)}\\s+from\\s+['"][^'"]+['"];?\\s*\\n?`, - 'm' + ozSymbols.add(target); + let updated = applyEdits(content, edits); + updated = mergeOzNamedImports( + updated, + targetPackage, + [...ozSymbols], + isCompoundFamilyExport(source) ); - result = result.replace(nsImportLine, ''); + return { content: collapseBlankLines(updated), found: true }; +} - result = mergeOzNamedImports(result, targetPackage, collectOzComponentNames(memberMap), false); - result = result.replace(/^\s*\n{2,}/gm, '\n'); +function rewriteJsxTags(content: string, source: string, target: string): string { + if (source === target) return content; + const sourceFile = parseTsx(content); + const edits: TextEdit[] = []; + + visit(sourceFile, (node) => { + let tagName: ts.JsxTagNameExpression | null = null; + if (ts.isJsxOpeningElement(node) || ts.isJsxClosingElement(node)) tagName = node.tagName; + else if (ts.isJsxSelfClosingElement(node)) tagName = node.tagName; + if (tagName && ts.isIdentifier(tagName) && tagName.text === source) { + edits.push({ + start: tagName.getStart(sourceFile), + end: tagName.getEnd(), + replacement: target, + }); + } + }); - return result; + return applyEdits(content, edits); } -function tryRewriteNamespaceImport( - task: MigrationTask, +function applyPropMappings( content: string, - targetPackage: string -): string | null { - const source = task.sourceComponent; - if (!source) return null; + targetComponent: string, + propMappings: Record +): string { + const sourceFile = parseTsx(content); + const edits: TextEdit[] = []; + + visit(sourceFile, (node) => { + const tagName = ts.isJsxOpeningElement(node) + ? node.tagName + : ts.isJsxSelfClosingElement(node) + ? node.tagName + : null; + const attributes = ts.isJsxOpeningElement(node) + ? node.attributes + : ts.isJsxSelfClosingElement(node) + ? node.attributes + : null; + if (!tagName || !attributes) return; + if (!ts.isIdentifier(tagName) || tagName.text !== targetComponent) return; + + for (const attr of attributes.properties) { + if (!ts.isJsxAttribute(attr) || !ts.isIdentifier(attr.name)) continue; + const mapped = propMappings[attr.name.text]; + if (mapped && mapped !== attr.name.text) { + edits.push({ + start: attr.name.getStart(sourceFile), + end: attr.name.getEnd(), + replacement: mapped, + }); + } + } + }); - const nsMatch = content.match( - new RegExp(`import\\s+\\*\\s+as\\s+${escapeRegex(source)}\\s+from\\s+['"]([^'"]+)['"]`) - ); - if (!nsMatch) return null; + return applyEdits(content, edits); +} - const importPath = nsMatch[1]; - const memberMap = findNamespaceMemberToTarget(source, importPath); - if (!memberMap) return null; +// --------------------------------------------------------------------------- +// Namespace-import migration (radix `import * as X`) +// --------------------------------------------------------------------------- - return rewriteNamespaceImportBody(content, source, memberMap, targetPackage); +function extractUseStateSetter(content: string): string { + return content.match(/\[\s*\w+\s*,\s*(\w+)\s*\]\s*=\s*useState\s*\(/)?.[1] ?? 'setOpen'; } -function rewriteImports( - content: string, - sourceComponent: string, - targetComponent: string, - targetPackage: string -): string { - let result = content; - const preferMultilineOzImport = isCompoundFamilyExport(sourceComponent); +function firstJsxChildElement( + node: ts.JsxElement +): ts.JsxElement | ts.JsxSelfClosingElement | null { + for (const child of node.children) { + if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) return child; + } + return null; +} - const importRegex = new RegExp( - `import\\s*\\{([^}]*\\b${escapeRegex(sourceComponent)}\\b[^}]*)\\}\\s*from\\s*['"]([^'"]+)['"]\\s*;?`, - 'g' +function hasOnClickAttribute(element: ts.JsxElement | ts.JsxSelfClosingElement): boolean { + const attributes = ts.isJsxElement(element) + ? element.openingElement.attributes + : element.attributes; + return attributes.properties.some( + (attr) => ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === 'onClick' ); +} + +/** Converts `{child}` into the child with an onClick that closes the dialog. */ +function passCloseAsChild(content: string, source: string, members: Set): string { + if (members.size === 0) return content; + const sourceFile = parseTsx(content); + const setter = extractUseStateSetter(content); + const edits: TextEdit[] = []; + + visit(sourceFile, (node) => { + if (!ts.isJsxElement(node)) return; + const member = namespaceMemberName(node.openingElement.tagName, source); + if (!member || !members.has(member)) return; + + const innerStart = node.openingElement.getEnd(); + const innerEnd = node.closingElement.getStart(sourceFile); + let inner = content.slice(innerStart, innerEnd); + + const child = firstJsxChildElement(node); + if (child && !hasOnClickAttribute(child)) { + const attributes = ts.isJsxElement(child) + ? child.openingElement.attributes + : child.attributes; + const insertPos = attributes.getEnd() - innerStart; + inner = + inner.slice(0, insertPos) + ` onClick={() => ${setter}(false)}` + inner.slice(insertPos); + } + + edits.push({ start: node.getStart(sourceFile), end: node.getEnd(), replacement: inner.trim() }); + }); - const matches = [...result.matchAll(importRegex)]; - const ozSymbols = new Set([targetComponent]); + return applyEdits(content, edits); +} - for (const match of matches) { - const fullImport = match[0]; - const importList = match[1]; - const importPath = match[2]; +function passOmit(content: string, source: string, members: Set): string { + if (members.size === 0) return content; + const sourceFile = parseTsx(content); + const edits: TextEdit[] = []; + + visit(sourceFile, (node) => { + const tagName = ts.isJsxSelfClosingElement(node) + ? node.tagName + : ts.isJsxElement(node) + ? node.openingElement.tagName + : null; + if (!tagName) return; + const member = namespaceMemberName(tagName, source); + if (member && members.has(member)) { + edits.push(removeNodeEdit(content, node.getStart(sourceFile), node.getEnd())); + } + }); - if (fullImport.includes('@openzeppelin/')) continue; + return collapseBlankLines(applyEdits(content, edits)); +} - const specifiers = importList - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - const bases = specifiers.map(specifierBaseName); +function passUnwrap(content: string, source: string, members: Set): string { + if (members.size === 0) return content; + const sourceFile = parseTsx(content); + const edits: TextEdit[] = []; + + visit(sourceFile, (node) => { + if (!ts.isJsxElement(node)) return; + const member = namespaceMemberName(node.openingElement.tagName, source); + if (!member || !members.has(member)) return; + const innerStart = node.openingElement.getEnd(); + const innerEnd = node.closingElement.getStart(sourceFile); + edits.push({ + start: node.getStart(sourceFile), + end: node.getEnd(), + replacement: content.slice(innerStart, innerEnd).trim(), + }); + }); - if (importSpecifiersBelongToSourceFamily(importPath, bases, sourceComponent)) { - result = result.replace(fullImport, ''); - for (const b of bases) ozSymbols.add(b); - continue; + return applyEdits(content, edits); +} + +function passRenameMembers( + content: string, + source: string, + renames: Record +): string { + if (Object.keys(renames).length === 0) return content; + const sourceFile = parseTsx(content); + const edits: TextEdit[] = []; + + const rename = (tagName: ts.JsxTagNameExpression): void => { + const member = namespaceMemberName(tagName, source); + if (member && renames[member]) { + edits.push({ + start: tagName.getStart(sourceFile), + end: tagName.getEnd(), + replacement: renames[member], + }); } + }; + + visit(sourceFile, (node) => { + if (ts.isJsxElement(node)) { + rename(node.openingElement.tagName); + rename(node.closingElement.tagName); + } else if (ts.isJsxSelfClosingElement(node)) { + rename(node.tagName); + } + }); - const remaining = specifiers.filter((s) => specifierBaseName(s) !== sourceComponent); + return applyEdits(content, edits); +} - if (remaining.length > 0) { - const newImport = fullImport.replace(importList, ` ${remaining.join(', ')} `); - result = result.replace(fullImport, newImport); - } else { - result = result.replace(fullImport, ''); +function removeNamespaceImport(content: string, source: string): string { + const sourceFile = parseTsx(content); + const edits: TextEdit[] = []; + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const named = statement.importClause?.namedBindings; + if (named && ts.isNamespaceImport(named) && named.name.text === source) { + edits.push(removeNodeEdit(content, statement.getStart(sourceFile), statement.getEnd())); } } - result = mergeOzNamedImports(result, targetPackage, [...ozSymbols], preferMultilineOzImport); - result = result.replace(/^\s*\n{2,}/gm, '\n'); + return collapseBlankLines(applyEdits(content, edits)); +} - return result; +function membersForTarget(memberMap: Record, sentinel: string): Set { + return new Set( + Object.entries(memberMap) + .filter(([, target]) => target === sentinel) + .map(([member]) => member) + ); } -function rewriteJsx(content: string, sourceComponent: string, targetComponent: string): string { - if (sourceComponent === targetComponent) return content; +function rewriteNamespaceImportBody( + content: string, + source: string, + memberMap: Record, + targetPackage: string +): string { + let result = passCloseAsChild(content, source, membersForTarget(memberMap, OZ_NS_CLOSE_AS_CHILD)); + result = passOmit(result, source, membersForTarget(memberMap, OZ_NS_OMIT)); + result = passUnwrap(result, source, membersForTarget(memberMap, OZ_NS_UNWRAP)); - const tagRegex = new RegExp(`(<\\/?)${escapeRegex(sourceComponent)}(\\s|>|\\/)`, 'g'); + const renames = Object.fromEntries( + Object.entries(memberMap).filter(([, target]) => !target.startsWith('__OZ_NS_')) + ); + result = passRenameMembers(result, source, renames); + result = removeNamespaceImport(result, source); + result = mergeOzNamedImports(result, targetPackage, collectOzComponentNames(memberMap), false); - return content.replace(tagRegex, `$1${targetComponent}$2`); + return collapseBlankLines(result); } -function applyPropMappings( +function tryRewriteNamespaceImport( + task: MigrationTask, content: string, - targetComponent: string, - propMappings: Record -): string { - let result = content; + targetPackage: string +): string | null { + const source = task.sourceComponent; + if (!source) return null; - for (const [oldProp, newProp] of Object.entries(propMappings)) { - const propRegex = new RegExp( - `(<${escapeRegex(targetComponent)}[^>]*?)\\b${escapeRegex(oldProp)}(\\s*=)`, - 'g' - ); - result = result.replace(propRegex, `$1${newProp}$2`); + const sourceFile = parseTsx(content); + let importPath: string | null = null; + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const named = statement.importClause?.namedBindings; + if (named && ts.isNamespaceImport(named) && named.name.text === source) { + importPath = importModulePath(statement); + break; + } } + if (importPath === null) return null; - return result; + const memberMap = findNamespaceMemberToTarget(source, importPath); + if (!memberMap) return null; + + return rewriteNamespaceImportBody(content, source, memberMap, targetPackage); } +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + /** @description Rewrites file content for a migration task: imports, JSX tags, and optional prop mappings. */ export function rewriteFile( task: MigrationTask, @@ -360,27 +604,34 @@ export function rewriteFile( ): string { const source = task.sourceComponent; const target = task.targetComponent; - if (!source || !target) return content; - const targetPackage = context.targetPackage ?? '@openzeppelin/ui-components'; + const targetPackage = context.targetPackage ?? DEFAULT_TARGET_PACKAGE; + const hasPropMappings = Boolean( + context.propMappings && Object.keys(context.propMappings).length > 0 + ); const namespaceResult = tryRewriteNamespaceImport(task, content, targetPackage); if (namespaceResult !== null) { - let result = namespaceResult; - if (context.propMappings && Object.keys(context.propMappings).length > 0) { - result = applyPropMappings(result, target, context.propMappings); - } - return result; + return hasPropMappings + ? applyPropMappings(namespaceResult, target, context.propMappings!) + : namespaceResult; } - let result = rewriteImports(content, source, target, targetPackage); + const { content: afterImports, found } = rewriteNamedImports( + content, + source, + target, + targetPackage + ); + if (!found) return content; + + let result = afterImports; if (!isCompoundFamilyExport(source)) { - result = rewriteJsx(result, source, target); + result = rewriteJsxTags(result, source, target); } - - if (context.propMappings && Object.keys(context.propMappings).length > 0) { - result = applyPropMappings(result, target, context.propMappings); + if (hasPropMappings) { + result = applyPropMappings(result, target, context.propMappings!); } return result; From 3854cda538f1dbd6983f502083ba9936d88f9d98 Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 29 May 2026 14:12:10 +0300 Subject: [PATCH 09/11] refactor(common): consolidate import detection onto shared AST extractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts a single AST-based import parser (`analysis/import-extract.ts`) used by both the component matcher and the pattern scanner. The pattern scanner no longer parses imports with regexes, so it stops false-matching `import` text inside comments or strings and drops the duplicate import-parsing implementation. `extractImportBindings` returns structured named/default/namespace bindings (for the matcher); `extractModuleImportRefs` returns every module reference with line/snippet context — static, side-effect, re-export, and dynamic `import()` — for the scanner. Component-matcher behavior is unchanged (its 24 tests still pass); scanner detection gains AST accuracy. Adds scanner tests for comment/string non-detection and dynamic/re-export parity. --- .changeset/wise-melons-import-scan.md | 5 + .../cli/src/analysis/component-matcher.ts | 82 ++-------- packages/cli/src/analysis/import-extract.ts | 151 ++++++++++++++++++ .../cli/src/analysis/pattern-scanner.test.ts | 38 +++++ packages/cli/src/analysis/pattern-scanner.ts | 58 +------ 5 files changed, 211 insertions(+), 123 deletions(-) create mode 100644 .changeset/wise-melons-import-scan.md create mode 100644 packages/cli/src/analysis/import-extract.ts diff --git a/.changeset/wise-melons-import-scan.md b/.changeset/wise-melons-import-scan.md new file mode 100644 index 0000000..1f9040b --- /dev/null +++ b/.changeset/wise-melons-import-scan.md @@ -0,0 +1,5 @@ +--- +"@openzeppelin/ui-cli": patch +--- + +Consolidate migration import detection onto a single AST-based extractor shared by the component matcher and the pattern scanner. The pattern scanner previously parsed imports with regexes, which could match `import` text inside comments or strings (false positives) and maintained a second import-parsing implementation. Both analyzers now use the TypeScript compiler, so `oz-ui migrate analyze` and `doctor` detect static, side-effect, re-export (`export … from`), and dynamic (`import()`) module references consistently and no longer flag commented-out or quoted import text. diff --git a/packages/cli/src/analysis/component-matcher.ts b/packages/cli/src/analysis/component-matcher.ts index 778293c..b8435c6 100644 --- a/packages/cli/src/analysis/component-matcher.ts +++ b/packages/cli/src/analysis/component-matcher.ts @@ -13,6 +13,12 @@ import { isLocalImport, type ImportSourceKind, } from './import-classifier'; +import { + createAnalysisSourceFile, + extractImportBindings, + type ImportBinding, + type ImportInfo, +} from './import-extract'; import { findWorkspacePackageForImport, isFileInDesignSystemPackage, @@ -92,17 +98,6 @@ export interface ComponentMatch { // Internal types // --------------------------------------------------------------------------- -interface ImportBinding { - importedName: string; - localName: string; - kind: 'named' | 'default' | 'namespace'; -} - -interface ImportInfo { - source: string; - bindings: ImportBinding[]; -} - interface ParsedFileFacts { imports: ImportInfo[]; componentUsages: Map; @@ -185,15 +180,6 @@ function localModuleTransitivelyImportsExternalLibrary( // AST helpers — parsing TypeScript / JSX into structured facts // --------------------------------------------------------------------------- -function getScriptKind(filePath: string): ts.ScriptKind { - if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX; - if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX; - if (filePath.endsWith('.mts')) return ts.ScriptKind.TS; - if (filePath.endsWith('.cts')) return ts.ScriptKind.TS; - if (filePath.endsWith('.js')) return ts.ScriptKind.JS; - return ts.ScriptKind.TS; -} - function incrementCount(map: Map, key: string): void { map.set(key, (map.get(key) ?? 0) + 1); } @@ -268,44 +254,6 @@ function collectJsxFacts( incrementCount(facts.inputTypeUsages, parseInputTypeFromAttributes(attributes)); } -function extractImports(sourceFile: ts.SourceFile): ImportInfo[] { - const imports: ImportInfo[] = []; - for (const statement of sourceFile.statements) { - if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) - continue; - const clause = statement.importClause; - if (!clause) continue; - - const bindings: ImportBinding[] = []; - if (clause.name) { - bindings.push({ - importedName: clause.name.text, - localName: clause.name.text, - kind: 'default', - }); - } - if (clause.namedBindings) { - if (ts.isNamedImports(clause.namedBindings)) { - for (const el of clause.namedBindings.elements) { - bindings.push({ - importedName: el.propertyName?.text ?? el.name.text, - localName: el.name.text, - kind: 'named', - }); - } - } else if (ts.isNamespaceImport(clause.namedBindings)) { - bindings.push({ - importedName: clause.namedBindings.name.text, - localName: clause.namedBindings.name.text, - kind: 'namespace', - }); - } - } - imports.push({ source: statement.moduleSpecifier.text, bindings }); - } - return imports; -} - function hasExportModifier(node: ts.Node): boolean { const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined; return mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false; @@ -316,13 +264,7 @@ function hasExportModifier(node: ts.Node): boolean { * modules (no third-party UI imports) whose exports are all catalog-mapped. */ function extractExportedFunctionComponentNames(file: ScannedFile): string[] { - const sourceFile = ts.createSourceFile( - file.relativePath, - file.content, - ts.ScriptTarget.Latest, - true, - getScriptKind(file.relativePath) - ); + const sourceFile = createAnalysisSourceFile(file.relativePath, file.content); const names: string[] = []; for (const stmt of sourceFile.statements) { @@ -403,16 +345,10 @@ function qualifiesForExportShapeInference(resolved: ScannedFile, exportedNames: } function parseFileFacts(file: ScannedFile): ParsedFileFacts { - const sourceFile = ts.createSourceFile( - file.relativePath, - file.content, - ts.ScriptTarget.Latest, - true, - getScriptKind(file.relativePath) - ); + const sourceFile = createAnalysisSourceFile(file.relativePath, file.content); const facts: ParsedFileFacts = { - imports: extractImports(sourceFile), + imports: extractImportBindings(sourceFile), componentUsages: new Map(), namespaceUsages: new Map(), htmlTagUsages: new Map(), diff --git a/packages/cli/src/analysis/import-extract.ts b/packages/cli/src/analysis/import-extract.ts new file mode 100644 index 0000000..2f3a983 --- /dev/null +++ b/packages/cli/src/analysis/import-extract.ts @@ -0,0 +1,151 @@ +import ts from 'typescript'; + +/** + * Shared AST-based import extraction for the analysis pipeline. + * + * `component-matcher` needs structured bindings (named/default/namespace) to map + * components, while `pattern-scanner` needs every module reference (including + * side-effect imports, `export … from`, and dynamic `import()`) with line/snippet + * context. Both parse the same way here so there is a single, parser-backed + * notion of "what does this file import" — no regex that false-positives on + * `import` mentioned in comments or strings. + */ + +export interface ImportBinding { + importedName: string; + localName: string; + kind: 'named' | 'default' | 'namespace'; +} + +export interface ImportInfo { + source: string; + bindings: ImportBinding[]; +} + +/** A module specifier reference with source-location context, for evidence reporting. */ +export interface ModuleImportRef { + source: string; + /** 1-based line where the reference begins. */ + line: number; + /** Trimmed text of the physical line where the reference begins. */ + statement: string; +} + +/** + * + */ +export function getScriptKind(filePath: string): ts.ScriptKind { + if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX; + if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX; + if (filePath.endsWith('.mts')) return ts.ScriptKind.TS; + if (filePath.endsWith('.cts')) return ts.ScriptKind.TS; + if (filePath.endsWith('.js')) return ts.ScriptKind.JS; + return ts.ScriptKind.TS; +} + +/** + * + */ +export function createAnalysisSourceFile(filePath: string, content: string): ts.SourceFile { + return ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + true, + getScriptKind(filePath) + ); +} + +/** + * Structured bindings for every static `import` declaration that has an import + * clause. Side-effect imports, `export … from`, and dynamic imports are omitted + * (they bind no local names); use {@link extractModuleImportRefs} for those. + */ +export function extractImportBindings(sourceFile: ts.SourceFile): ImportInfo[] { + const imports: ImportInfo[] = []; + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + const clause = statement.importClause; + if (!clause) continue; + + const bindings: ImportBinding[] = []; + if (clause.name) { + bindings.push({ + importedName: clause.name.text, + localName: clause.name.text, + kind: 'default', + }); + } + if (clause.namedBindings) { + if (ts.isNamedImports(clause.namedBindings)) { + for (const el of clause.namedBindings.elements) { + bindings.push({ + importedName: el.propertyName?.text ?? el.name.text, + localName: el.name.text, + kind: 'named', + }); + } + } else if (ts.isNamespaceImport(clause.namedBindings)) { + bindings.push({ + importedName: clause.namedBindings.name.text, + localName: clause.namedBindings.name.text, + kind: 'namespace', + }); + } + } + imports.push({ source: statement.moduleSpecifier.text, bindings }); + } + return imports; +} + +function lineTextAt(sourceFile: ts.SourceFile, pos: number): string { + const { text } = sourceFile; + const lineStart = text.lastIndexOf('\n', pos - 1) + 1; + const newlineIdx = text.indexOf('\n', pos); + const lineEnd = newlineIdx === -1 ? text.length : newlineIdx; + return text.slice(lineStart, lineEnd).trim(); +} + +/** + * Every module specifier referenced by the file: static imports (including + * side-effect `import './x'`), re-exports (`export … from`), and dynamic + * `import('x')`. Deduplicated by source + line. + */ +export function extractModuleImportRefs(sourceFile: ts.SourceFile): ModuleImportRef[] { + const refs: ModuleImportRef[] = []; + const seen = new Set(); + + const push = (source: string, pos: number): void => { + if (!source) return; + const line = sourceFile.getLineAndCharacterOfPosition(pos).line + 1; + const key = `${source}\0${line}`; + if (seen.has(key)) return; + seen.add(key); + refs.push({ source, line, statement: lineTextAt(sourceFile, pos) }); + }; + + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + push(node.moduleSpecifier.text, node.getStart(sourceFile)); + } else if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + push(node.moduleSpecifier.text, node.getStart(sourceFile)); + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length > 0 && + ts.isStringLiteral(node.arguments[0]) + ) { + push(node.arguments[0].text, node.getStart(sourceFile)); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return refs; +} diff --git a/packages/cli/src/analysis/pattern-scanner.test.ts b/packages/cli/src/analysis/pattern-scanner.test.ts index 2b25d98..81e6069 100644 --- a/packages/cli/src/analysis/pattern-scanner.test.ts +++ b/packages/cli/src/analysis/pattern-scanner.test.ts @@ -90,6 +90,44 @@ describe('scanPatterns', () => { expect(patterns).toHaveLength(0); }); + it('does not match import-like text inside comments or strings (AST, not regex)', () => { + const files: ScannedFile[] = [ + { + absolutePath: '/project/src/notes.ts', + relativePath: 'src/notes.ts', + content: [ + "// import { useAccount } from 'wagmi';", + 'const docs = "import { x } from \'wagmi\'";', + 'export const noop = () => docs;', + ].join('\n'), + }, + ]; + + const patterns = scanPatterns(files); + expect(patterns.find((p) => p.pattern === 'wagmi')).toBeUndefined(); + }); + + it('detects dynamic imports, re-exports, and side-effect imports', () => { + const files: ScannedFile[] = [ + { + absolutePath: '/project/src/lazy.ts', + relativePath: 'src/lazy.ts', + content: "export const load = () => import('wagmi');\n", + }, + { + absolutePath: '/project/src/reexport.ts', + relativePath: 'src/reexport.ts', + content: "export { useAccount } from 'wagmi';\n", + }, + ]; + + const patterns = scanPatterns(files); + const wagmi = patterns.find((p) => p.pattern === 'wagmi'); + + expect(wagmi).toBeDefined(); + expect(wagmi!.files).toEqual(['src/lazy.ts', 'src/reexport.ts']); + }); + it('returns rich observations with evidence', () => { const files: ScannedFile[] = [ { diff --git a/packages/cli/src/analysis/pattern-scanner.ts b/packages/cli/src/analysis/pattern-scanner.ts index 57e8211..b0db0f5 100644 --- a/packages/cli/src/analysis/pattern-scanner.ts +++ b/packages/cli/src/analysis/pattern-scanner.ts @@ -7,6 +7,11 @@ import { type PatternRuleConfidence, type PatternRuleKind, } from '../catalog'; +import { + createAnalysisSourceFile, + extractModuleImportRefs, + type ModuleImportRef, +} from './import-extract'; import type { ScannedFile } from './scanner'; export interface PatternEvidence { @@ -50,26 +55,11 @@ export interface CanonicalPatternMatch extends Omit { pattern: string; } -interface ExtractedImport { - source: string; - statement: string; - line: number; -} - interface FilePatternFacts { file: ScannedFile; - imports: ExtractedImport[]; + imports: ModuleImportRef[]; } -const IMPORT_PATTERNS = [ - /\bimport\s+[\s\S]*?\sfrom\s+['"]([^'"]+)['"]/g, - /\bexport\s+[\s\S]*?\sfrom\s+['"]([^'"]+)['"]/g, - /\bimport\s+['"]([^'"]+)['"]/g, -]; - -/** Dynamic `import('specifier')` — structural; wallet/OZ packages are often loaded lazily. */ -const DYNAMIC_IMPORT_PATTERN = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g; - function countLineNumber(content: string, index: number): number { return content.slice(0, index).split('\n').length; } @@ -80,38 +70,6 @@ function readLineAt(content: string, index: number): string { return content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim(); } -function extractImports(file: ScannedFile): ExtractedImport[] { - const imports: ExtractedImport[] = []; - const seen = new Set(); - - const pushMatch = (source: string, index: number) => { - if (!source) return; - const line = countLineNumber(file.content, index); - const key = `${source}\0${line}`; - if (seen.has(key)) return; - seen.add(key); - imports.push({ - source, - statement: readLineAt(file.content, index), - line, - }); - }; - - for (const pattern of IMPORT_PATTERNS) { - for (const match of file.content.matchAll(pattern)) { - if (match[1] === undefined || match.index === undefined) continue; - pushMatch(match[1], match.index); - } - } - - for (const match of file.content.matchAll(DYNAMIC_IMPORT_PATTERN)) { - if (match[1] === undefined || match.index === undefined) continue; - pushMatch(match[1], match.index); - } - - return imports; -} - function isImportMatcher(matcher: PatternRule['matcher']): matcher is PatternImportMatcher { return 'packages' in matcher; } @@ -150,7 +108,7 @@ function mergeConfidence( function createImportObservation( file: ScannedFile, rule: PatternRule, - matches: ExtractedImport[] + matches: ModuleImportRef[] ): PatternObservation { return { ruleId: rule.id, @@ -202,7 +160,7 @@ function createContentObservation( function createFilePatternFacts(files: ScannedFile[]): FilePatternFacts[] { return files.map((file) => ({ file, - imports: extractImports(file), + imports: extractModuleImportRefs(createAnalysisSourceFile(file.relativePath, file.content)), })); } From 82a2272870fe3a8fb5e4a90f46109951b8132e3c Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 29 May 2026 15:43:24 +0300 Subject: [PATCH 10/11] fix(common): scope raw-HTML verifier check to unadopted OZ components The component-replacement verifier flagged any intrinsic `', + ' ', + ' ', + ' );', + '}', + ].join('\n') + ); + + const result = checkTask(buttonTask(), dir); + expect(result.passed).toBe(true); + expect(result.diagnostics.join('\n')).not.toMatch(/Raw HTML/); + }); + + it('fails when only a raw ;', + '}', + ].join('\n') + ); + + const result = checkTask(buttonTask(), dir); + expect(result.passed).toBe(false); + expect(result.diagnostics.join('\n')).toMatch(/Raw HTML ;', + '}', + ].join('\n') + ); + + const result = checkTask(buttonTask(), dir); + expect(result.passed).toBe(true); + expect(result.diagnostics.join('\n')).not.toMatch(/Raw HTML/); + }); + }); }); diff --git a/packages/cli/src/verification/checker.ts b/packages/cli/src/verification/checker.ts index 525325d..4633cc0 100644 --- a/packages/cli/src/verification/checker.ts +++ b/packages/cli/src/verification/checker.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import ts from 'typescript'; import { doctorTailwindProject } from '@openzeppelin/ui-tailwind-utils'; @@ -9,6 +10,7 @@ import { MIGRATE_SKILL_ID, type AgentAssetProfile, } from '../agent-assets'; +import { createAnalysisSourceFile } from '../analysis/import-extract'; import { CLI_BRANDING, CLI_FAMILIES } from '../branding'; import { loadCatalog, loadHtmlElementMappings } from '../catalog'; import type { MigrationTask } from '../manifest'; @@ -292,15 +294,52 @@ function isOpenZeppelinModule(moduleSpecifier: string): boolean { return moduleSpecifier.startsWith(OZ_SCOPE); } +interface JsxTagUsage { + /** Number of intrinsic (lowercase) JSX elements matching the html source tag (e.g. `