diff --git a/.changeset/bright-lions-create.md b/.changeset/bright-lions-create.md new file mode 100644 index 00000000..c0cbe8bd --- /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/.changeset/keen-foxes-scaffold-skill.md b/.changeset/keen-foxes-scaffold-skill.md new file mode 100644 index 00000000..eb99c982 --- /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/.changeset/large-pugs-rewrite.md b/.changeset/large-pugs-rewrite.md new file mode 100644 index 00000000..7db898f0 --- /dev/null +++ b/.changeset/large-pugs-rewrite.md @@ -0,0 +1,5 @@ +--- +"@openzeppelin/ui-cli": patch +--- + +Migrate the deterministic component rewriter (`oz-ui migrate execute`) from regex/brace-counting to an AST-based implementation built on the TypeScript compiler. Import swaps, JSX tag renames, prop renames, and the radix namespace-member transforms (unwrap / omit / close-as-child / rename) now operate on the parsed syntax tree and edit via offset splices, so they no longer corrupt files with aliased or multiline imports, JSX attribute values containing `>` or parentheses, or tags whose names are prefixes of one another. The rewriter is also now a no-op when no legacy import references the source component, instead of injecting an unused OpenZeppelin import. diff --git a/.changeset/quiet-foxes-add-wallet.md b/.changeset/quiet-foxes-add-wallet.md new file mode 100644 index 00000000..e26bc2ce --- /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/.changeset/quiet-jaguars-envelope.md b/.changeset/quiet-jaguars-envelope.md new file mode 100644 index 00000000..ffd7344c --- /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/.changeset/sharp-otters-rewire.md b/.changeset/sharp-otters-rewire.md new file mode 100644 index 00000000..23d24068 --- /dev/null +++ b/.changeset/sharp-otters-rewire.md @@ -0,0 +1,5 @@ +--- +"@openzeppelin/ui-cli": patch +--- + +Harden React entry-file patching with an AST-based transformer shared by `oz-ui add wallet` and `oz-ui migrate init`. The previous regex/brace-counting approach could corrupt entry files containing parentheses inside JSX strings, legacy `ReactDOM.render`, sync/arrow `bootstrap` functions, or `createRoot` stored in a variable. The new transformer parses the source with the TypeScript compiler, wraps only the JSX render argument (preserving any container argument), injects config initialization into an existing bootstrap (or creates one) without duplicating declarations, and bails safely without writing on unsupported shapes. `oz-ui add wallet` now reports an `entryFilePatchReason` and emits manual-wiring next steps when the entry file cannot be patched automatically. diff --git a/.changeset/wise-melons-import-scan.md b/.changeset/wise-melons-import-scan.md new file mode 100644 index 00000000..1f9040b6 --- /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/.changeset/witty-pandas-button-check.md b/.changeset/witty-pandas-button-check.md new file mode 100644 index 00000000..b849ec2a --- /dev/null +++ b/.changeset/witty-pandas-button-check.md @@ -0,0 +1,10 @@ +--- +'@openzeppelin/ui-cli': patch +--- + +Fix a false-positive in the component-replacement verifier where a legitimately +remaining intrinsic tag (e.g. a ` + + + ${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 00000000..98346275 --- /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 00000000..34de905c --- /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 00000000..fdc7b463 --- /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 00000000..14a38683 --- /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/create/types.ts b/packages/cli/src/create/types.ts new file mode 100644 index 00000000..06306813 --- /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/create/vite-template.ts b/packages/cli/src/create/vite-template.ts new file mode 100644 index 00000000..810ecae6 --- /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; +`; +} diff --git a/packages/cli/src/execution/task-executor.ts b/packages/cli/src/execution/task-executor.ts index 4f9aafaa..4c2f6ff1 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/index.ts b/packages/cli/src/index.ts index 16f285f5..518c9a8c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import { Command } from 'commander'; +import { registerAddCommand } from './commands/add'; +import { registerCreateCommand } from './commands/create'; import { registerMigrateCommand } from './commands/migrate'; import { CLI_VERSION } from './branding'; @@ -12,6 +14,8 @@ program .description('OpenZeppelin UI CLI — scaffold, migrate, and manage OZ UI applications.') .version(CLI_VERSION); +registerAddCommand(program); +registerCreateCommand(program); registerMigrateCommand(program); program.parse(); diff --git a/packages/cli/src/init/setup.ts b/packages/cli/src/init/setup.ts index 1c84879e..d3b8a3e1 100644 --- a/packages/cli/src/init/setup.ts +++ b/packages/cli/src/init/setup.ts @@ -15,10 +15,12 @@ import { import { agentDirectoriesForProfiles, + MIGRATE_SKILL_ID, skillDirectoriesForProfiles, writeAgentProfileSelection, } from '../agent-assets'; import { CLI_BRANDING, CLI_FAMILIES, OZ_CORE_PACKAGES } from '../branding'; +import { transformEntryFile } from '../codemod/entry-transform'; import type { AgentAssetProfile } from '../manifest/schema'; import { copyTemplateDirectory, writeTemplate } from '../templates'; import { buildInstallCommand, detectPackageManager } from '../utils/framework'; @@ -91,13 +93,6 @@ export function writeProviderTemplates(projectRoot: string): string[] { return written; } -const ENTRY_FILE_CANDIDATES = [ - 'src/main.tsx', - 'src/main.jsx', - 'src/index.tsx', - 'src/index.jsx', -] as const; - const PROVIDER_IMPORT_LINE = "import { RuntimeProvider, WalletStateProvider } from './oz/runtime-providers';"; @@ -108,97 +103,15 @@ const PROVIDER_IMPORT_LINE = * is installed. */ export function patchEntryFileWithProviders(projectRoot: string): string | null { - for (const candidate of ENTRY_FILE_CANDIDATES) { - const filePath = path.join(projectRoot, candidate); - if (!fs.existsSync(filePath)) continue; - - const content = fs.readFileSync(filePath, 'utf8'); - if (content.includes('RuntimeProvider') || content.includes('OzProviders')) return null; - - const patched = injectProviderWiring(content); - if (patched !== content) { - fs.writeFileSync(filePath, patched, 'utf8'); - return candidate; - } - } - - return null; -} - -function injectProviderWiring(source: string): string { - if (source.includes('RuntimeProvider') || source.includes('OzProviders')) return source; - - let result = source; - - const lastImportIdx = findLastImportIndex(result); - if (lastImportIdx >= 0) { - const insertPos = result.indexOf('\n', lastImportIdx); - if (insertPos >= 0) { - result = - result.slice(0, insertPos + 1) + PROVIDER_IMPORT_LINE + '\n' + result.slice(insertPos + 1); - } - } else { - result = PROVIDER_IMPORT_LINE + '\n' + result; - } - - result = wrapRenderTree(result); - - return result; -} - -function findLastImportIndex(source: string): number { - let lastIdx = -1; - const importRegex = /^import\s/gm; - let match; - while ((match = importRegex.exec(source)) !== null) { - lastIdx = match.index; - } - return lastIdx; -} - -/** - * Wraps the JSX tree inside ReactDOM.createRoot(...).render() with - * RuntimeProvider + WalletStateProvider. Targets the common pattern: - * .render(...) or .render() or .render(...) - */ -function wrapRenderTree(source: string): string { - const renderMatch = source.match(/\.render\(\s*\n?(\s*)/); - if (!renderMatch) return source; - - const renderIdx = source.indexOf(renderMatch[0]); - const afterRender = renderIdx + renderMatch[0].length; - const indent = renderMatch[1] || ' '; - - const closingMatch = findMatchingCloseParen(source, renderIdx); - if (closingMatch < 0) return source; - - const innerJsx = source.slice(afterRender, closingMatch).trim(); - - const wrapped = - `.render(\n` + - `${indent}\n` + - `${indent} \n` + - `${indent} ${innerJsx}\n` + - `${indent} \n` + - `${indent}\n` + - `${indent.slice(2) || ''}`; - - return source.slice(0, renderIdx) + wrapped + source.slice(closingMatch); -} - -function findMatchingCloseParen(source: string, startIdx: number): number { - const openIdx = source.indexOf('(', startIdx); - if (openIdx < 0) return -1; - - let depth = 1; - for (let i = openIdx + 1; i < source.length; i++) { - if (source[i] === '(') depth++; - else if (source[i] === ')') { - depth--; - if (depth === 0) return i; - } - } - return -1; + const result = transformEntryFile(projectRoot, { + wrap: { + importLine: PROVIDER_IMPORT_LINE, + components: ['RuntimeProvider', 'WalletStateProvider'], + skipIfPresent: ['RuntimeProvider', 'OzProviders'], + }, + }); + + return result.patched ? result.entryFile : null; } /** @@ -341,102 +254,31 @@ export function writeAppConfigFiles(projectRoot: string): AppConfigResult { const APP_CONFIG_IMPORT_LINE = "import { appConfigService } from '@openzeppelin/ui-utils';"; +const APP_CONFIG_INIT_STATEMENT = [ + 'await appConfigService.initialize([', + " { type: 'viteEnv', env: import.meta.env },", + " { type: 'json', path: '/app.config.json' },", + ']);', +].join('\n'); + /** * Patches the entry file to call `appConfigService.initialize()` before - * the React render. Wraps the existing synchronous `createRoot().render()` - * call in an async IIFE that loads config from Vite env + JSON. + * the React render. Wraps the synchronous `createRoot().render()` call in an + * async bootstrap that loads config from Vite env + JSON. * * Idempotent: skips if the file already references `appConfigService`. */ export function patchEntryFileWithConfigService(projectRoot: string): string | null { - for (const candidate of ENTRY_FILE_CANDIDATES) { - const filePath = path.join(projectRoot, candidate); - if (!fs.existsSync(filePath)) continue; - - const content = fs.readFileSync(filePath, 'utf8'); - if (content.includes('appConfigService')) return null; - - const patched = injectConfigServiceWiring(content); - if (patched !== content) { - fs.writeFileSync(filePath, patched, 'utf8'); - return candidate; - } - } - - return null; -} - -function injectConfigServiceWiring(source: string): string { - if (source.includes('appConfigService')) return source; - - // Bail early if there's no createRoot().render() pattern to wrap - const createRootMatch = source.match(/(?:ReactDOM\.)?createRoot\s*\(/); - if (!createRootMatch) return source; - - const createRootIdx = source.indexOf(createRootMatch[0]); - const renderDotIdx = source.indexOf('.render(', createRootIdx); - if (renderDotIdx < 0) return source; - - const renderCloseParen = findMatchingCloseParen(source, renderDotIdx); - if (renderCloseParen < 0) return source; - - // Add the import - let result = source; - const lastImportIdx = findLastImportIndex(result); - if (lastImportIdx >= 0) { - const insertPos = result.indexOf('\n', lastImportIdx); - if (insertPos >= 0) { - result = - result.slice(0, insertPos + 1) + - APP_CONFIG_IMPORT_LINE + - '\n' + - result.slice(insertPos + 1); - } - } else { - result = APP_CONFIG_IMPORT_LINE + '\n' + result; - } - - // Re-locate createRoot after the import insertion shifted offsets - const updatedCreateRootMatch = result.match(/(?:ReactDOM\.)?createRoot\s*\(/); - if (!updatedCreateRootMatch) return result; - - const updatedCreateRootIdx = result.indexOf(updatedCreateRootMatch[0]); - - let stmtStart = updatedCreateRootIdx; - while (stmtStart > 0 && result[stmtStart - 1] !== '\n') stmtStart--; - - const updatedRenderDotIdx = result.indexOf('.render(', updatedCreateRootIdx); - if (updatedRenderDotIdx < 0) return result; - - const updatedRenderCloseParen = findMatchingCloseParen(result, updatedRenderDotIdx); - if (updatedRenderCloseParen < 0) return result; - - let stmtEnd = updatedRenderCloseParen + 1; - while (stmtEnd < result.length && /[\s;]/.test(result[stmtEnd])) { - const ch = result[stmtEnd]; - stmtEnd++; - if (ch === ';') break; - } - - const renderStatement = result.slice(stmtStart, stmtEnd).trim(); - - const asyncWrapper = - 'async function startApp() {\n' + - ' await appConfigService.initialize([\n' + - " { type: 'viteEnv', env: import.meta.env },\n" + - " { type: 'json', path: '/app.config.json' },\n" + - ' ]);\n' + - '\n' + - ' ' + - renderStatement + - '\n' + - '}\n' + - '\n' + - 'void startApp();\n'; - - result = result.slice(0, stmtStart) + asyncWrapper + result.slice(stmtEnd); - - return result; + const result = transformEntryFile(projectRoot, { + asyncInit: { + importLine: APP_CONFIG_IMPORT_LINE, + initStatement: APP_CONFIG_INIT_STATEMENT, + bootstrapName: 'startApp', + skipIfPresent: ['appConfigService'], + }, + }); + + return result.patched ? result.entryFile : null; } /** @@ -458,7 +300,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 +313,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/rewriter/rewriteFile.test.ts b/packages/cli/src/rewriter/rewriteFile.test.ts new file mode 100644 index 00000000..4732e602 --- /dev/null +++ b/packages/cli/src/rewriter/rewriteFile.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from 'vitest'; + +import type { MigrationTask } from '../manifest/schema'; +import { rewriteFile, type RewriteContext } from './rewriteFile'; + +/** + * Characterization / contract tests for the deterministic component rewriter. + * + * These assert the *semantic guarantees* the rewriter must preserve (imports + * swapped/merged, JSX tags renamed, props remapped, namespace members handled) + * rather than exact whitespace, so they hold across an implementation change + * from regex to AST. They are driven by the real on-disk catalog. + */ + +function replacementTask(overrides: Partial = {}): MigrationTask { + return { + id: 'component-replacement-test', + phase: 'component-migration', + type: 'component-replacement', + status: 'pending', + description: 'test', + file: 'src/App.tsx', + ...overrides, + }; +} + +const OZ = '@openzeppelin/ui-components'; + +function importLineFor(content: string, pkg: string): string | null { + return content.split('\n').find((line) => line.includes(`from '${pkg}'`)) ?? null; +} + +describe('rewriteFile — named import swaps', () => { + it('swaps a single named import to the OZ package and remaps props on the target tag', () => { + const input = [ + "import { Button } from '@/components/ui/button';", + '', + 'export function App() {', + ' return ;', + '}', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Button', targetComponent: 'Button' }), + input, + { propMappings: { size: 'scale' } } + ); + + expect(out).toContain(`from '${OZ}'`); + expect(out).not.toContain("from '@/components/ui/button'"); + expect(importLineFor(out, OZ)).toContain('Button'); + expect(out).toContain('scale="lg"'); + expect(out).not.toContain('size="lg"'); + expect(out).toContain('variant="default"'); + }); + + it('renames the JSX tag when source and target component names differ', () => { + const input = [ + "import { TextField } from '@mui/material';", + '', + 'export function App() {', + ' return ;', + '}', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'TextField', targetComponent: 'Input' }), + input + ); + + expect(out).toContain(' { + const input = [ + "import { Button, Spinner } from '@/components/ui/button';", + '', + 'export function App() {', + ' return ;', + '}', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Button', targetComponent: 'Button' }), + input + ); + + const legacy = importLineFor(out, '@/components/ui/button'); + expect(legacy).not.toBeNull(); + expect(legacy).toContain('Spinner'); + expect(legacy).not.toMatch(/\bButton\b/); + expect(importLineFor(out, OZ)).toContain('Button'); + }); + + it('merges into an existing OZ import instead of adding a duplicate', () => { + const input = [ + "import { Card } from '@openzeppelin/ui-components';", + "import { Button } from '@/components/ui/button';", + '', + 'export function App() {', + ' return (', + ' ', + ' ', + ' ', + ' );', + '}', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Button', targetComponent: 'Button' }), + input + ); + + const ozLines = out.split('\n').filter((line) => line.includes(`from '${OZ}'`)); + expect(ozLines).toHaveLength(1); + expect(ozLines[0]).toContain('Button'); + expect(ozLines[0]).toContain('Card'); + }); + + it('is a no-op when no legacy import references the source component', () => { + // The AST rewriter only swaps imports it actually finds, so an absent + // source component is never given a spurious OZ import. + const input = ["import { Something } from 'some-lib';", 'export const x = 1;', ''].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Button', targetComponent: 'Button' }), + input + ); + + expect(out).toBe(input); + }); + + it('returns content unchanged when the task lacks source/target', () => { + const input = "import { Button } from '@/components/ui/button';\n"; + expect(rewriteFile(replacementTask({}), input)).toBe(input); + }); +}); + +describe('rewriteFile — compound families', () => { + it('collapses a Card-family import group into one OZ import and leaves JSX tags intact', () => { + const input = [ + "import { Card, CardHeader, CardContent } from '@/components/ui/card';", + '', + 'export function App() {', + ' return (', + ' ', + ' Title', + ' Body', + ' ', + ' );', + '}', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Card', targetComponent: 'Card' }), + input + ); + + expect(out).not.toContain("from '@/components/ui/card'"); + const ozLine = importLineFor(out, OZ); + expect(ozLine).toContain('Card'); + expect(ozLine).toContain('CardHeader'); + expect(ozLine).toContain('CardContent'); + // Compound family JSX tags are preserved (only imports move). + expect(out).toContain(''); + expect(out).toContain(''); + }); +}); + +describe('rewriteFile — namespace imports (radix)', () => { + const input = [ + "import * as Dialog from '@radix-ui/react-dialog';", + '', + 'export function Modal() {', + ' const [open, setOpen] = useState(false);', + ' return (', + ' ', + ' Open', + ' ', + ' ', + ' ', + ' Title', + ' Desc', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' );', + '}', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Dialog', targetComponent: 'Dialog' }), + input + ); + + it('removes the namespace import and adds an OZ named import', () => { + expect(out).not.toContain('import * as Dialog'); + expect(importLineFor(out, OZ)).not.toBeNull(); + }); + + it('renames members to their OZ targets', () => { + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).toContain(''); + expect(out).not.toContain('Dialog.Root'); + expect(out).not.toContain('Dialog.Trigger'); + }); + + it('unwraps Portal, omits Overlay, and converts Close asChild to an onClick handler', () => { + expect(out).not.toContain('Dialog.Portal'); + expect(out).not.toContain('Dialog.Overlay'); + expect(out).not.toContain('Dialog.Close'); + expect(out).toContain('onClick={() => setOpen(false)}'); + }); + + it('does not duplicate onClick when the child already has one', () => { + const withHandler = input.replace( + '', + '' + ); + const result = rewriteFile( + replacementTask({ sourceComponent: 'Dialog', targetComponent: 'Dialog' }), + withHandler + ); + const matches = result.match(/onClick=/g) ?? []; + expect(matches).toHaveLength(1); + expect(result).toContain('onClick={foo}'); + }); +}); + +describe('rewriteFile — AST robustness (cases the regex implementation mishandled)', () => { + it('preserves an aliased sibling specifier when removing the migrated import', () => { + const input = [ + "import { Button, Tooltip as Tip } from '@/components/ui/button';", + '', + 'export const App = () => ;', + '', + ].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 = [ + "import { Button } from '@/components/ui/button';", + '', + 'export function App() {', + ' return (', + '
', + ' ', + ' ', + '
', + ' );', + '}', + '', + ].join('\n'); + + const out = rewriteFile( + replacementTask({ sourceComponent: 'Button', targetComponent: 'Button' }), + input, + { propMappings: { size: 'scale' } } satisfies RewriteContext + ); + + expect(out).toContain('