From 7bca6eb8d560774b844e48a725a23c336d4fed8f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 06:17:04 +0000 Subject: [PATCH] feat(cli): detect Lynx (rspeedy) projects in rozenite init `rozenite init` now recognizes an rspeedy/Rsbuild project (via lynx.config.ts/js, or @lynx-js/rspeedy in package.json), installs @rozenite/lynx, and adds rozeniteLynxPlugin() to the plugins array in lynx.config.ts, mirroring Metro/Re.Pack support. It also prints a reminder to turn on Lynx DevTool, and the project-detection gate no longer rejects Lynx projects for lacking a react-native dependency. Closes https://github.com/callstackincubator/rozenite/issues/493 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018XyA3ckZz2ZFvmH659mBAT --- .changeset/rozenite-init-detects-lynx.md | 11 ++ .../cli/src/__tests__/config-wrapper.test.ts | 127 +++++++++++++++++- packages/cli/src/__tests__/is-project.test.ts | 51 +++++++ packages/cli/src/commands/init-command.ts | 32 ++++- packages/cli/src/utils/config-wrapper.ts | 101 +++++++++++++- packages/cli/src/utils/packages.ts | 14 +- .../tools/src/__tests__/project-type.test.ts | 80 +++++++++++ packages/tools/src/project-type.ts | 30 ++++- website/src/docs/getting-started.mdx | 4 +- 9 files changed, 436 insertions(+), 14 deletions(-) create mode 100644 .changeset/rozenite-init-detects-lynx.md create mode 100644 packages/cli/src/__tests__/is-project.test.ts create mode 100644 packages/tools/src/__tests__/project-type.test.ts diff --git a/.changeset/rozenite-init-detects-lynx.md b/.changeset/rozenite-init-detects-lynx.md new file mode 100644 index 00000000..969840bb --- /dev/null +++ b/.changeset/rozenite-init-detects-lynx.md @@ -0,0 +1,11 @@ +--- +'rozenite': minor +'@rozenite/tools': minor +--- + +`rozenite init` now detects rspeedy/Rsbuild (Lynx) projects — via +`lynx.config.ts`/`lynx.config.js`, or `@lynx-js/rspeedy` in `package.json` — +installs `@rozenite/lynx` as a dev dependency, and adds `rozeniteLynxPlugin()` +to the `plugins` array in `lynx.config.ts`, mirroring what it already does for +Metro and Re.Pack. It also reminds you to turn on Lynx DevTool, since that +switch is off by default and Rozenite finds nothing to connect to without it. diff --git a/packages/cli/src/__tests__/config-wrapper.test.ts b/packages/cli/src/__tests__/config-wrapper.test.ts index 32c04627..6dce6c7c 100644 --- a/packages/cli/src/__tests__/config-wrapper.test.ts +++ b/packages/cli/src/__tests__/config-wrapper.test.ts @@ -179,7 +179,12 @@ describe('wrapConfigFile', () => { // Helper function to create a config file with given content const createConfigFile = async (bundlerType: BundlerType, content: string, extension = '.js') => { - const baseName = bundlerType === 'metro' ? 'metro.config' : 'rspack.config'; + const baseName = + bundlerType === 'metro' + ? 'metro.config' + : bundlerType === 'repack' + ? 'rspack.config' + : 'lynx.config'; const filename = baseName + extension; const configPath = path.join(tempDir, filename); await fs.writeFile(configPath, content, 'utf8'); @@ -416,6 +421,126 @@ describe('wrapConfigFile', () => { }); }); + describe('lynx', () => { + it('should add rozeniteLynxPlugin to a basic lynx.config.ts', async () => { + const basicConfig = `import { defineConfig } from '@lynx-js/rspeedy'; +import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; + +export default defineConfig({ + plugins: [pluginReactLynx()], +});`; + + const configPath = await createConfigFile('lynx', basicConfig, '.ts'); + + await wrapConfigFile(tempDir, 'lynx'); + + const wrappedContent = await fs.readFile(configPath, 'utf8'); + + expect(wrappedContent).toContain( + "import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy';", + ); + expect(wrappedContent).toContain('pluginReactLynx()'); + expect(wrappedContent).toContain('rozeniteLynxPlugin(),'); + // The plugin call must land inside the plugins array. + expect(wrappedContent).toMatch(/plugins:\s*\[[\s\S]*rozeniteLynxPlugin\(\)[\s\S]*\]/); + }); + + it('should add rozeniteLynxPlugin to a real-world create-rspeedy config', async () => { + // Mirrors what `create-rspeedy`'s react template generates. + const realWorldConfig = `import { defineConfig } from '@lynx-js/rspeedy' + +import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin' +import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin' +import { pluginTypeCheck } from '@rsbuild/plugin-type-check' + +export default defineConfig({ + plugins: [ + pluginQRCode({ + schema(url) { + // We use \`?fullscreen=true\` to open the page in LynxExplorer in full screen mode + return \`\${url}?fullscreen=true\` + }, + }), + pluginReactLynx(), + pluginTypeCheck(), + ], +})`; + + const configPath = await createConfigFile('lynx', realWorldConfig, '.ts'); + + await wrapConfigFile(tempDir, 'lynx'); + + const wrappedContent = await fs.readFile(configPath, 'utf8'); + + expect(wrappedContent).toContain( + "import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy';", + ); + expect(wrappedContent).toContain('rozeniteLynxPlugin(),'); + // All the original plugins must be preserved. + expect(wrappedContent).toContain('pluginQRCode({'); + expect(wrappedContent).toContain('pluginReactLynx()'); + expect(wrappedContent).toContain('pluginTypeCheck()'); + // The generated file is otherwise structurally valid JS/TS. + const openBraces = (wrappedContent.match(/\{/g) || []).length; + const closeBraces = (wrappedContent.match(/\}/g) || []).length; + expect(openBraces).toBe(closeBraces); + const openParens = (wrappedContent.match(/\(/g) || []).length; + const closeParens = (wrappedContent.match(/\)/g) || []).length; + expect(openParens).toBe(closeParens); + }); + + it('should add rozeniteLynxPlugin to an empty plugins array', async () => { + const emptyPluginsConfig = `import { defineConfig } from '@lynx-js/rspeedy'; + +export default defineConfig({ + plugins: [], +});`; + + const configPath = await createConfigFile('lynx', emptyPluginsConfig, '.ts'); + + await wrapConfigFile(tempDir, 'lynx'); + + const wrappedContent = await fs.readFile(configPath, 'utf8'); + + expect(wrappedContent).toContain('plugins: [rozeniteLynxPlugin()]'); + }); + + it('should not modify an already configured lynx.config.ts', async () => { + const alreadyWrapped = `import { defineConfig } from '@lynx-js/rspeedy'; +import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy'; + +export default defineConfig({ + plugins: [rozeniteLynxPlugin()], +});`; + + const configPath = await createConfigFile('lynx', alreadyWrapped, '.ts'); + + await wrapConfigFile(tempDir, 'lynx'); + + const wrappedContent = await fs.readFile(configPath, 'utf8'); + + expect(wrappedContent).toBe(alreadyWrapped); + }); + + it('should throw when lynx.config.ts does not exist', async () => { + await expect(wrapConfigFile(tempDir, 'lynx')).rejects.toThrow( + 'Configuration file lynx.config.{.js,.mjs,.cjs,.ts,.cts,.mts} not found', + ); + }); + + it('should throw when the config has no plugins array', async () => { + const noPluginsConfig = `import { defineConfig } from '@lynx-js/rspeedy'; + +export default defineConfig({});`; + + await createConfigFile('lynx', noPluginsConfig, '.ts'); + + await expect(wrapConfigFile(tempDir, 'lynx')).rejects.toThrow( + 'Could not find a "plugins" array', + ); + }); + }); + describe('import style detection', () => { it('should use CommonJS style for CommonJS configs', async () => { const configPath = await createConfigFile('metro', FIXTURES.metro.commonjs.basic); diff --git a/packages/cli/src/__tests__/is-project.test.ts b/packages/cli/src/__tests__/is-project.test.ts new file mode 100644 index 00000000..8d327a73 --- /dev/null +++ b/packages/cli/src/__tests__/is-project.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { isProject } from '../utils/packages.js'; + +describe('isProject', () => { + let testDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rozenite-is-project-test-')); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('returns false when there is no package.json', () => { + expect(isProject(testDir)).toBe(false); + }); + + it('returns true for a React Native project', () => { + fs.writeFileSync( + path.join(testDir, 'package.json'), + JSON.stringify({ dependencies: { 'react-native': '^0.76.0' } }), + ); + + expect(isProject(testDir)).toBe(true); + }); + + it('returns true for a Lynx (rspeedy) project, which has no react-native dependency', () => { + fs.writeFileSync( + path.join(testDir, 'package.json'), + JSON.stringify({ + dependencies: { '@lynx-js/react': '^0.124.0' }, + devDependencies: { '@lynx-js/rspeedy': '^0.16.0' }, + }), + ); + + expect(isProject(testDir)).toBe(true); + }); + + it('returns false for an unrelated project', () => { + fs.writeFileSync( + path.join(testDir, 'package.json'), + JSON.stringify({ dependencies: { react: '^19.0.0' } }), + ); + + expect(isProject(testDir)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/init-command.ts b/packages/cli/src/commands/init-command.ts index 3528fffd..e49b3975 100644 --- a/packages/cli/src/commands/init-command.ts +++ b/packages/cli/src/commands/init-command.ts @@ -9,14 +9,32 @@ import { spawn } from '../utils/spawn.js'; import { step } from '../utils/steps.js'; const formatBundlerType = (bundlerType: BundlerType): string => { - return bundlerType === 'metro' ? 'Metro' : 'Re.Pack'; + switch (bundlerType) { + case 'metro': + return 'Metro'; + case 'repack': + return 'Re.Pack'; + case 'lynx': + return 'Lynx'; + } +}; + +const getPackageName = (bundlerType: BundlerType): string => { + switch (bundlerType) { + case 'metro': + return '@rozenite/metro'; + case 'repack': + return '@rozenite/repack'; + case 'lynx': + return '@rozenite/lynx'; + } }; export const initCommand = async (projectRoot: string) => { intro('Rozenite'); if (!isProject(projectRoot)) { - logger.error("I couldn't find a React Native project in this directory."); + logger.error("I couldn't find a React Native or Lynx project in this directory."); return; } @@ -54,13 +72,13 @@ export const initCommand = async (projectRoot: string) => { if (!bundlerTypes.length) { throw new Error( - 'Could not determine bundler type. Please ensure you have a metro.config.js or rspack.config.js file.', + 'Could not determine bundler type. Please ensure you have a metro.config.js, rspack.config.js, or lynx.config.ts file.', ); } for (const bundlerType of bundlerTypes) { // Install the appropriate Rozenite package - const packageName = bundlerType === 'metro' ? '@rozenite/metro' : '@rozenite/repack'; + const packageName = getPackageName(bundlerType); await step( { @@ -87,5 +105,11 @@ export const initCommand = async (projectRoot: string) => { ); } + if (bundlerTypes.includes('lynx')) { + logger.info( + 'Lynx DevTool is off by default. Turn it on in LynxExplorer (Settings → Lynx DevTool Switches) or in your app, then relaunch — otherwise Rozenite will find no target to connect to. See https://rozenite.dev/docs/rozenite-for-lynx for details.', + ); + } + outro('You are now ready to use Rozenite!'); }; diff --git a/packages/cli/src/utils/config-wrapper.ts b/packages/cli/src/utils/config-wrapper.ts index cb308074..5876adf1 100644 --- a/packages/cli/src/utils/config-wrapper.ts +++ b/packages/cli/src/utils/config-wrapper.ts @@ -1,11 +1,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -export type BundlerType = 'metro' | 'repack'; +export type BundlerType = 'metro' | 'repack' | 'lynx'; const CONFIG_BASE_NAMES = { metro: 'metro.config', repack: 'rspack.config', + lynx: 'lynx.config', } as const; const MODULE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.cts', '.mts'] as const; @@ -19,6 +20,10 @@ const WRAPPER_IMPORTS = { packageName: '@rozenite/repack', importName: 'withRozenite', }, + lynx: { + packageName: '@rozenite/lynx/rspeedy', + importName: 'rozeniteLynxPlugin', + }, } as const; /** @@ -140,6 +145,96 @@ const findFirstImportLine = (lines: string[]): number => { return -1; // No imports found }; +/** + * Finds the index of the `]` matching the `[` at `openIndex`, skipping over + * brackets that appear inside string/template literals or comments. + */ +const findMatchingBracket = (source: string, openIndex: number): number => { + let depth = 0; + + for (let i = openIndex; i < source.length; i++) { + const char = source[i]; + + if (char === '"' || char === "'" || char === '`') { + const quote = char; + i++; + while (i < source.length && source[i] !== quote) { + if (source[i] === '\\') { + i++; + } + i++; + } + continue; + } + + if (char === '/' && source[i + 1] === '/') { + while (i < source.length && source[i] !== '\n') { + i++; + } + continue; + } + + if (char === '/' && source[i + 1] === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { + i++; + } + i++; + continue; + } + + if (char === '[') { + depth++; + } else if (char === ']') { + depth--; + if (depth === 0) { + return i; + } + } + } + + return -1; +}; + +/** + * Adds `rozeniteLynxPlugin()` as the last entry of the `plugins` array in a + * `lynx.config.ts` (rspeedy/Rsbuild) file, rather than wrapping the whole + * config export the way Metro/Re.Pack are wrapped — rspeedy plugins are + * declared as a list, not composed around the exported config object. + */ +const addPluginToLynxConfig = (sourceCode: string, importName: string): string => { + const pluginsMatch = /plugins\s*:\s*(\[)/.exec(sourceCode); + + if (!pluginsMatch) { + throw new Error('Could not find a "plugins" array in the Lynx configuration file'); + } + + const openIndex = pluginsMatch.index + pluginsMatch[0].length - 1; + const closeIndex = findMatchingBracket(sourceCode, openIndex); + + if (closeIndex === -1) { + throw new Error('Could not find the end of the "plugins" array in the Lynx configuration file'); + } + + const inner = sourceCode.slice(openIndex + 1, closeIndex); + const trailingWhitespaceMatch = /\s*$/.exec(inner); + const trailingWhitespace = trailingWhitespaceMatch ? trailingWhitespaceMatch[0] : ''; + const content = inner.slice(0, inner.length - trailingWhitespace.length); + const needsComma = content.trim().length > 0 && !content.trimEnd().endsWith(','); + + const newInner = + content.trim().length === 0 + ? `${importName}()` + : `${content}${needsComma ? ',' : ''}\n ${importName}(),`; + + return ( + sourceCode.slice(0, openIndex + 1) + + newInner + + trailingWhitespace + + sourceCode.slice(closeIndex) + ); +}; + /** * Wraps a bundler configuration file export with withRozenite using smart string manipulation * This preserves original formatting while making precise changes @@ -214,7 +309,9 @@ export const wrapConfigFile = async ( } // Wrap the export if not already wrapped - if (!hasWrapper) { + if (!hasWrapper && bundlerType === 'lynx') { + sourceCode = addPluginToLynxConfig(sourceCode, importName); + } else if (!hasWrapper) { // Handle different export patterns using regex with minimal changes // Pattern 1: export default { ... } diff --git a/packages/cli/src/utils/packages.ts b/packages/cli/src/utils/packages.ts index 87607369..36a07c0d 100644 --- a/packages/cli/src/utils/packages.ts +++ b/packages/cli/src/utils/packages.ts @@ -91,8 +91,14 @@ export const isPackageInstalled = async ( export const isProject = (projectRoot: string): boolean => { const packageJsonPath = path.join(projectRoot, 'package.json'); - return ( - fs.existsSync(packageJsonPath) && - fs.readFileSync(packageJsonPath, 'utf8').includes('react-native') - ); + + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8'); + // 'react-native' covers React Native CLI, Expo and Re.Pack projects. + // 'lynx' covers rspeedy/Rsbuild-based Lynx projects, which depend on + // '@lynx-js/rspeedy' rather than on react-native itself. + return packageJsonContent.includes('react-native') || packageJsonContent.includes('@lynx-js/'); }; diff --git a/packages/tools/src/__tests__/project-type.test.ts b/packages/tools/src/__tests__/project-type.test.ts new file mode 100644 index 00000000..e9185a81 --- /dev/null +++ b/packages/tools/src/__tests__/project-type.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { getAvailableBundlerTypes } from '../project-type.js'; + +describe('getAvailableBundlerTypes', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'project-type-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('returns an empty list when no bundler config or dependency is present', async () => { + expect(getAvailableBundlerTypes(tempDir)).toEqual([]); + }); + + it('detects Lynx from a lynx.config.ts file', async () => { + await fs.writeFile(path.join(tempDir, 'lynx.config.ts'), 'export default {};'); + + expect(getAvailableBundlerTypes(tempDir)).toEqual(['lynx']); + }); + + it('detects Lynx from a lynx.config.js file', async () => { + await fs.writeFile(path.join(tempDir, 'lynx.config.js'), 'module.exports = {};'); + + expect(getAvailableBundlerTypes(tempDir)).toEqual(['lynx']); + }); + + it('detects Lynx from @lynx-js/rspeedy in devDependencies, even without a config file', async () => { + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'my-lynx-app', + devDependencies: { '@lynx-js/rspeedy': '^0.16.0' }, + }), + ); + + expect(getAvailableBundlerTypes(tempDir)).toEqual(['lynx']); + }); + + it('detects Lynx from @lynx-js/rspeedy in dependencies', async () => { + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'my-lynx-app', + dependencies: { '@lynx-js/rspeedy': '^0.16.0' }, + }), + ); + + expect(getAvailableBundlerTypes(tempDir)).toEqual(['lynx']); + }); + + it('does not detect Lynx from an unrelated package.json', async () => { + await fs.writeFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'my-app', dependencies: { react: '^19.0.0' } }), + ); + + expect(getAvailableBundlerTypes(tempDir)).toEqual([]); + }); + + it('does not throw on a malformed package.json', async () => { + await fs.writeFile(path.join(tempDir, 'package.json'), '{ not valid json'); + + expect(getAvailableBundlerTypes(tempDir)).toEqual([]); + }); + + it('detects Metro, Re.Pack, and Lynx together in a mixed workspace', async () => { + await fs.writeFile(path.join(tempDir, 'metro.config.js'), 'module.exports = {};'); + await fs.writeFile(path.join(tempDir, 'rspack.config.js'), 'module.exports = {};'); + await fs.writeFile(path.join(tempDir, 'lynx.config.ts'), 'export default {};'); + + expect(getAvailableBundlerTypes(tempDir)).toEqual(['metro', 'repack', 'lynx']); + }); +}); diff --git a/packages/tools/src/project-type.ts b/packages/tools/src/project-type.ts index e514a690..04643865 100644 --- a/packages/tools/src/project-type.ts +++ b/packages/tools/src/project-type.ts @@ -4,9 +4,11 @@ import path from 'node:path'; const MODULE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.cts', '.mts']; const METRO_CONFIG_FILE = 'metro.config.js'; const REPACK_CONFIG_FILE = 'rspack.config.js'; +const LYNX_CONFIG_FILE = 'lynx.config.js'; +const LYNX_RSPEEDY_PACKAGE = '@lynx-js/rspeedy'; export type ProjectType = 'react-native-cli' | 'expo'; -export type BundlerType = 'metro' | 'repack'; +export type BundlerType = 'metro' | 'repack' | 'lynx'; const isExpoProject = (projectRoot: string): boolean => { const appJsonPath = path.join(projectRoot, 'app.json'); @@ -25,6 +27,25 @@ const isExpoProject = (projectRoot: string): boolean => { } }; +const hasDependency = (projectRoot: string, packageName: string): boolean => { + const packageJsonPath = path.join(projectRoot, 'package.json'); + + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + try { + const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8'); + const packageJson = JSON.parse(packageJsonContent); + return Boolean( + packageJson?.dependencies?.[packageName] || packageJson?.devDependencies?.[packageName], + ); + } catch { + // If we can't parse the JSON, we can't tell. + return false; + } +}; + const isSourceFilePresent = (projectRoot: string, fileName: string): boolean => { const name = fileName.split('.').slice(0, -1).join('.'); @@ -69,5 +90,12 @@ export const getAvailableBundlerTypes = (projectRoot: string): BundlerType[] => bundlers.push('repack'); } + if ( + isSourceFilePresent(projectRoot, LYNX_CONFIG_FILE) || + hasDependency(projectRoot, LYNX_RSPEEDY_PACKAGE) + ) { + bundlers.push('lynx'); + } + return bundlers; }; diff --git a/website/src/docs/getting-started.mdx b/website/src/docs/getting-started.mdx index b2e212b7..19a1febb 100644 --- a/website/src/docs/getting-started.mdx +++ b/website/src/docs/getting-started.mdx @@ -6,11 +6,11 @@ import { PackageManagerTabs } from '@rspress/core/theme'; Rozenite assumes you're comfortable with a React Native project. If you're new to React Native, start with the [React Native documentation](https://reactnative.dev/) first. -On Lynx, follow [Rozenite for Lynx](/docs/targets/rozenite-for-lynx) instead — `rozenite init` doesn't detect rspeedy projects yet ([#493](https://github.com/callstackincubator/rozenite/issues/493)). +On Lynx, `rozenite init` also works, but see [Rozenite for Lynx](/docs/targets/rozenite-for-lynx) for the extra step of turning on Lynx DevTool in your app — nothing is discoverable until it is on. ## Install -Run the `rozenite init` command in your project. It detects your bundler, installs the right package, and updates your config for you. +Run the `rozenite init` command in your project. It detects your bundler (Metro, Re.Pack, or Lynx's rspeedy), installs the right package, and updates your config for you.