diff --git a/.github/workflows/upgrade-template-deps.yml b/.github/workflows/upgrade-template-deps.yml new file mode 100644 index 000000000..c85f03db4 --- /dev/null +++ b/.github/workflows/upgrade-template-deps.yml @@ -0,0 +1,54 @@ +name: Upgrade template dependencies + +on: + schedule: + - cron: '0 0 * * 1' # Every Monday at midnight UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + upgrade-template-deps: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Setup + uses: ./.github/actions/setup + + - name: Run upgrade script + run: node scripts/upgrade-template-deps.mts + + - name: Create or update pull request + run: | + if [ -z "$(git status --porcelain)" ]; then + echo "No changes to commit." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + branch="upgrade-template-deps" + + git checkout -B "$branch" + git add -A + git commit -m "chore: upgrade template dependencies" + git push -u origin "$branch" --force + + existing_pr=$(gh pr list --head "$branch" --json number --jq '.[0].number') + + if [ -n "$existing_pr" ]; then + echo "Updated existing PR #$existing_pr" + else + gh pr create \ + --title "chore: upgrade template dependencies" \ + --body "Automated upgrade of template dependencies via \`scripts/upgrade-template-deps.mts\`." \ + --label "dependencies" + fi + env: + GH_TOKEN: ${{ github.token }} diff --git a/eslint.config.mjs b/eslint.config.mjs index b7098f478..bb8f9f617 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,6 @@ import { defineConfig, globalIgnores } from 'eslint/config'; import { recommended, vitest, typechecked } from 'eslint-config-satya164'; +import globals from 'globals'; export default defineConfig( recommended, @@ -24,6 +25,13 @@ export default defineConfig( }, }, + { + files: ['scripts/**'], + languageOptions: { + globals: globals.node, + }, + }, + globalIgnores([ '**/.next/', '**/.expo/', diff --git a/packages/create-react-native-library/package.json b/packages/create-react-native-library/package.json index c503b34fc..5398df9e3 100644 --- a/packages/create-react-native-library/package.json +++ b/packages/create-react-native-library/package.json @@ -48,7 +48,7 @@ "github-username": "^6.0.0", "kleur": "^4.1.4", "ora": "^5.4.1", - "pigment": "^0.3.11", + "pigment": "^0.4.0", "validate-npm-package-name": "^4.0.0" }, "devDependencies": { diff --git a/packages/create-react-native-library/src/constants.ts b/packages/create-react-native-library/src/constants.ts index 073d779a4..04bf26570 100644 --- a/packages/create-react-native-library/src/constants.ts +++ b/packages/create-react-native-library/src/constants.ts @@ -1,3 +1,4 @@ export const FALLBACK_BOB_VERSION = '0.40.13'; export const FALLBACK_NITRO_MODULES_VERSION = '0.29.8'; +export const SUPPORTED_MONOREPO_CONFIG_VERSION = '0.3.3'; export const SUPPORTED_REACT_NATIVE_VERSION = '0.83.0'; diff --git a/packages/create-react-native-library/src/exampleApp/generateExampleApp.ts b/packages/create-react-native-library/src/exampleApp/generateExampleApp.ts index 4cc78d8bb..15590b719 100644 --- a/packages/create-react-native-library/src/exampleApp/generateExampleApp.ts +++ b/packages/create-react-native-library/src/exampleApp/generateExampleApp.ts @@ -1,11 +1,12 @@ +import dedent from 'dedent'; import fs from 'fs-extra'; import getLatestVersion from 'get-latest-version'; import https from 'https'; import path from 'path'; +import { SUPPORTED_MONOREPO_CONFIG_VERSION } from '../constants'; import type { TemplateConfiguration } from '../template'; import sortObjectKeys from '../utils/sortObjectKeys'; import { spawn } from '../utils/spawn'; -import dedent from 'dedent'; const FILES_TO_DELETE = [ '__tests__', @@ -216,7 +217,7 @@ export default async function generateExampleApp({ const PACKAGES_TO_ADD_DEV = { 'react-native-builder-bob': `^${config.versions.bob}`, - 'react-native-monorepo-config': `^0.3.3`, + 'react-native-monorepo-config': `^${SUPPORTED_MONOREPO_CONFIG_VERSION}`, }; if ( diff --git a/packages/create-react-native-library/src/template.ts b/packages/create-react-native-library/src/template.ts index a7e9c45e2..cf065ce2a 100644 --- a/packages/create-react-native-library/src/template.ts +++ b/packages/create-react-native-library/src/template.ts @@ -260,6 +260,10 @@ export async function applyTemplate( const files = await fs.readdir(source); for (const f of files) { + if (f.startsWith('~')) { + continue; + } + let name; try { diff --git a/packages/create-react-native-library/src/utils/configureTools.ts b/packages/create-react-native-library/src/utils/configureTools.ts index 5039036c9..4e54e832c 100644 --- a/packages/create-react-native-library/src/utils/configureTools.ts +++ b/packages/create-react-native-library/src/utils/configureTools.ts @@ -2,12 +2,10 @@ import fs from 'fs-extra'; import path from 'node:path'; import { applyTemplate, type TemplateConfiguration } from '../template'; import sortObjectKeys from './sortObjectKeys'; -import { SUPPORTED_REACT_NATIVE_VERSION } from '../constants'; type Tool = { name: string; description: string; - package: Record; condition?: (config: TemplateConfiguration) => boolean; }; @@ -21,109 +19,26 @@ type Options = { const ESLINT = { name: 'ESLint with Prettier', description: 'Lint and format code', - package: { - scripts: { - lint: 'eslint "**/*.{js,ts,tsx}"', - }, - prettier: { - quoteProps: 'consistent', - singleQuote: true, - tabWidth: 2, - trailingComma: 'es5', - useTabs: false, - }, - devDependencies: { - '@eslint/compat': '^1.3.2', - '@eslint/eslintrc': '^3.3.1', - '@eslint/js': '^9.35.0', - '@react-native/eslint-config': SUPPORTED_REACT_NATIVE_VERSION, - 'eslint-config-prettier': '^10.1.8', - 'eslint-plugin-prettier': '^5.5.4', - 'eslint': '^9.35.0', - 'prettier': '^2.8.8', - }, - }, }; const LEFTHOOK = { name: 'Lefthook with Commitlint', description: 'Manage Git hooks and lint commit messages', - package: { - commitlint: { - extends: ['@commitlint/config-conventional'], - }, - devDependencies: { - '@commitlint/config-conventional': '^19.8.1', - 'commitlint': '^19.8.1', - 'lefthook': '^2.0.3', - }, - }, }; const RELEASE_IT = { name: 'Release It', description: 'Automate versioning and package publishing tasks', - package: { - 'scripts': { - release: 'release-it --only-version', - }, - 'release-it': { - git: { - // eslint-disable-next-line no-template-curly-in-string - commitMessage: 'chore: release ${version}', - // eslint-disable-next-line no-template-curly-in-string - tagName: 'v${version}', - }, - npm: { - publish: true, - }, - github: { - release: true, - }, - plugins: { - '@release-it/conventional-changelog': { - preset: { - name: 'angular', - }, - }, - }, - }, - 'devDependencies': { - 'release-it': '^19.0.4', - '@release-it/conventional-changelog': '^10.0.1', - }, - }, }; const JEST = { name: 'Jest', description: 'Test JavaScript and TypeScript code', - package: { - scripts: { - test: 'jest', - }, - jest: { - preset: 'react-native', - modulePathIgnorePatterns: [ - '/example/node_modules', - '/lib/', - ], - }, - devDependencies: { - '@types/jest': '^29.5.14', - 'jest': '^29.7.0', - }, - }, }; const TURBOREPO = { name: 'Turborepo', description: 'Cache build outputs on CI', - package: { - devDependencies: { - turbo: '^2.5.6', - }, - }, }; export const AVAILABLE_TOOLS = { @@ -168,39 +83,49 @@ export async function configureTools({ continue; } - const files = path.resolve(__dirname, `../../templates/tools/${key}`); + const toolDir = path.resolve(__dirname, `../../templates/tools/${key}`); - if (fs.existsSync(files)) { - await applyTemplate(config, files, root); + if (fs.existsSync(toolDir)) { + await applyTemplate(config, toolDir, root); } - for (const [key, value] of Object.entries(tool.package)) { - if ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) - ) { - if (typeof packageJson[key] === 'object' || packageJson[key] == null) { - packageJson[key] = { - ...packageJson[key], - ...value, - }; + const pkgPath = path.join(toolDir, '~package.json'); + if (fs.existsSync(pkgPath)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const toolPkg = (await fs.readJson(pkgPath)) as Record; + + for (const [field, value] of Object.entries(toolPkg)) { + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { if ( - key === 'dependencies' || - key === 'devDependencies' || - key === 'peerDependencies' + typeof packageJson[field] === 'object' || + packageJson[field] == null ) { - // @ts-expect-error: We know they are objects here - packageJson[key] = sortObjectKeys(packageJson[key]); + packageJson[field] = { + ...packageJson[field], + ...value, + }; + + if ( + field === 'dependencies' || + field === 'devDependencies' || + field === 'peerDependencies' + ) { + // @ts-expect-error: We know they are objects here + packageJson[field] = sortObjectKeys(packageJson[field]); + } + } else { + throw new Error( + `Cannot merge '${field}' field because it is not an object (got '${String(packageJson[field])}').` + ); } } else { - throw new Error( - `Cannot merge '${key}' field because it is not an object (got '${String(packageJson[key])}').` - ); + packageJson[field] = value; } - } else { - packageJson[key] = value; } } } diff --git a/packages/create-react-native-library/templates/tools/eslint/~package.json b/packages/create-react-native-library/templates/tools/eslint/~package.json new file mode 100644 index 000000000..b9cb1ae31 --- /dev/null +++ b/packages/create-react-native-library/templates/tools/eslint/~package.json @@ -0,0 +1,22 @@ +{ + "scripts": { + "lint": "eslint \"**/*.{js,ts,tsx}\"" + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "devDependencies": { + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/eslint-config": "0.83.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "prettier": "^2.8.8" + } +} diff --git a/packages/create-react-native-library/templates/tools/jest/~package.json b/packages/create-react-native-library/templates/tools/jest/~package.json new file mode 100644 index 000000000..6acc4e4a7 --- /dev/null +++ b/packages/create-react-native-library/templates/tools/jest/~package.json @@ -0,0 +1,16 @@ +{ + "scripts": { + "test": "jest" + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/example/node_modules", + "/lib/" + ] + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "jest": "^29.7.0" + } +} diff --git a/packages/create-react-native-library/templates/tools/lefthook/~package.json b/packages/create-react-native-library/templates/tools/lefthook/~package.json new file mode 100644 index 000000000..0d671888d --- /dev/null +++ b/packages/create-react-native-library/templates/tools/lefthook/~package.json @@ -0,0 +1,10 @@ +{ + "commitlint": { + "extends": ["@commitlint/config-conventional"] + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "commitlint": "^19.8.1", + "lefthook": "^2.0.3" + } +} diff --git a/packages/create-react-native-library/templates/tools/release-it/~package.json b/packages/create-react-native-library/templates/tools/release-it/~package.json new file mode 100644 index 000000000..020d1ae2d --- /dev/null +++ b/packages/create-react-native-library/templates/tools/release-it/~package.json @@ -0,0 +1,28 @@ +{ + "scripts": { + "release": "release-it --only-version" + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "devDependencies": { + "@release-it/conventional-changelog": "^10.0.1", + "release-it": "^19.0.4" + } +} diff --git a/packages/create-react-native-library/templates/tools/turborepo/~package.json b/packages/create-react-native-library/templates/tools/turborepo/~package.json new file mode 100644 index 000000000..8498ad69f --- /dev/null +++ b/packages/create-react-native-library/templates/tools/turborepo/~package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "turbo": "^2.5.6" + } +} diff --git a/scripts/upgrade-template-deps.mts b/scripts/upgrade-template-deps.mts new file mode 100755 index 000000000..a4228be86 --- /dev/null +++ b/scripts/upgrade-template-deps.mts @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +import { execFileSync, execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +type Change = { from: string; to: string }; +type PackageJson = { devDependencies?: Record }; + +const ROOT_DIR = path.resolve(import.meta.dirname, '..'); +const CRNL_DIR = path.join(ROOT_DIR, 'packages/create-react-native-library'); +const TEMPLATES_DIR = path.join(CRNL_DIR, 'templates'); +const BIN_PATH = path.join(CRNL_DIR, 'bin/create-react-native-library'); + +// These are tied to the React Native release and can't be upgraded independently +const IGNORED_PACKAGES = [ + 'react', + '@types/react', + 'react-native', + /^@react-native\//, +]; + +function readDevDependencies(filePath: string) { + const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8')) as PackageJson; + + return pkg.devDependencies ?? {}; +} + +function isIgnoredPackage(name: string) { + return IGNORED_PACKAGES.some((pkg) => + typeof pkg === 'string' ? pkg === name : pkg.test(name) + ); +} + +function getDependencyChanges( + generatedDeps: Record, + ncuDeps: Record +) { + const changes: Record = {}; + + for (const [name, ncuVersion] of Object.entries(ncuDeps)) { + if (isIgnoredPackage(name)) { + // Sync the CLI-generated version back to the template, not the ncu upgrade. + // updateDeps will skip if the template already has this version. + changes[name] = { from: '', to: generatedDeps[name] ?? ncuVersion }; + } else if (generatedDeps[name] !== ncuVersion) { + changes[name] = { from: generatedDeps[name] ?? '', to: ncuVersion }; + } + } + + return changes; +} + +function getTemplatePackageFiles() { + return [ + path.join(TEMPLATES_DIR, 'common', '$package.json'), + ...fs + .readdirSync(path.join(TEMPLATES_DIR, 'tools')) + .map((dir) => path.join(TEMPLATES_DIR, 'tools', dir, '~package.json')) + .filter((filePath) => fs.existsSync(filePath)), + ]; +} + +function updateDeps(filePath: string, changes: Record) { + const replacements = Object.entries(changes).map(([name, { to }]) => { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + return { + regex: new RegExp(`("${escaped}":\\s*)"([^"]*)"`), + to, + }; + }); + + const content = fs.readFileSync(filePath, 'utf8'); + + const updated = content + .split('\n') + .map((line) => { + // Skip lines with EJS expressions (dynamically resolved versions) + if (line.includes('<%')) { + return line; + } + + for (const { regex, to } of replacements) { + line = line.replace(regex, `$1"${to}"`); + } + + return line; + }) + .join('\n'); + + if (updated !== content) { + fs.writeFileSync(filePath, updated); + console.log(`Updated: ${path.relative(CRNL_DIR, filePath)}`); + } +} + +function main() { + console.log('Building create-react-native-library...'); + + execSync('yarn workspace create-react-native-library prepare', { + cwd: ROOT_DIR, + stdio: 'inherit', + }); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'crnl-upgrade-')); + const libDir = path.join(tmpDir, 'bob-upgrade-test'); + + console.log(`\nGenerating library in ${tmpDir}...`); + + execFileSync( + process.execPath, + [ + BIN_PATH, + 'bob-upgrade-test', + '--description', + 'test', + '--type', + 'turbo-module', + '--languages', + 'kotlin-objc', + '--example', + 'vanilla', + '--yes', + ], + { + cwd: tmpDir, + stdio: 'inherit', + } + ); + + const pkgPath = path.join(libDir, 'package.json'); + const generatedDeps = readDevDependencies(pkgPath); + + console.log('\nChecking for dependency upgrades...'); + + execSync('npx npm-check-updates -u', { + cwd: libDir, + stdio: 'inherit', + }); + + const ncuDeps = readDevDependencies(pkgPath); + const changes = getDependencyChanges(generatedDeps, ncuDeps); + + if (Object.keys(changes).length === 0) { + console.log('\nAll dependencies are up to date.'); + return; + } + + const upgrades = Object.entries(changes).filter(([, { from }]) => from); + + if (upgrades.length > 0) { + console.log('\nDependencies to upgrade:'); + + for (const [name, { from, to }] of upgrades) { + console.log(` ${name}: ${from} -> ${to}`); + } + } + + const filesToUpdate = getTemplatePackageFiles(); + + for (const file of filesToUpdate) { + updateDeps(file, changes); + } + + console.log('\nDone!'); +} + +main(); diff --git a/yarn.lock b/yarn.lock index 4df0d2ac2..1f315b82b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6103,7 +6103,7 @@ __metadata: github-username: "npm:^6.0.0" kleur: "npm:^4.1.4" ora: "npm:^5.4.1" - pigment: "npm:^0.3.11" + pigment: "npm:^0.4.0" validate-npm-package-name: "npm:^4.0.0" bin: create-react-native-library: bin/create-react-native-library @@ -11749,13 +11749,13 @@ __metadata: languageName: node linkType: hard -"pigment@npm:^0.3.11": - version: 0.3.11 - resolution: "pigment@npm:0.3.11" +"pigment@npm:^0.4.0": + version: 0.4.0 + resolution: "pigment@npm:0.4.0" dependencies: ansi-escapes: "npm:^7.2.0" wrap-ansi: "npm:^9.0.2" - checksum: 10c0/6692f1078249fd92c5f7c1dd661e1c86b58cf91c9468f49ad69bbca01cbe51a8aef81bcc5b0f5bb87f94ef9d62fcfbb5b48ff732b59fd4ca50a2beac3c5fbfe4 + checksum: 10c0/55b641d714d263865c07c2d8958a37edea45cefa1fa3a09fa24ad9254d563754357742f41fbf84df2f4b97010d69d4a32e96d161ae26029aabe4f4d35898d651 languageName: node linkType: hard