diff --git a/README.md b/README.md index ed754f6..f81966a 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ no native file is left. | `--write` | Update the native files instead of only comparing them | | `--version-name ` | Use this version name instead of the `package.json` version. Requires `--version-code` unless it is semver. | | `--version-code ` | Use this version code instead of the calculated one | -| `--reserve-builds ` | Multiply the calculated version code by `n` so each version owns `n` consecutive codes, e.g. for CI builds that bump the code per build (`100` turns `10203` into `1020300`). Ignored with `--version-code`. | +| `--reserve-builds ` | Multiply the calculated version code by `n` so each version owns `n` consecutive codes, e.g. for CI builds that bump the code per build (`100` turns `10203` into `1020300`). Ignored with `--version-code`. Best set once as `reserveBuilds` in `package.json`, see [Configuration](#configuration). | | `--skip-android`, `--skip-ios` | Ignore that platform | | `--project-dir ` | Project root (default: current directory) | | `--gradle-path ` | Android `build.gradle` to use, instead of `android/app/build.gradle` | @@ -105,6 +105,32 @@ Relative `--gradle-path` and `--pbxproj-path` values are resolved against the project directory. Version codes must be positive integers up to `2147483647`, the 32-bit limit both platforms enforce. +### Configuration + +Settings that are permanent for a project go under the `rn-version-sync` key +of `package.json`, so the check, `--write` and the `version` script all use +the same values without repeating them on every call: + +```json +{ + "rn-version-sync": { + "reserveBuilds": 100 + } +} +``` + +| Setting | Option | +| ------- | ------ | +| `reserveBuilds` (integer) | `--reserve-builds` | +| `gradlePath` (string) | `--gradle-path` | +| `pbxprojPath` (string) | `--pbxproj-path` | +| `configuration` (string) | `--configuration` | +| `skipAndroid`, `skipIos` (boolean) | `--skip-android`, `--skip-ios` | + +A command line option takes precedence over the configured value. Relative +paths are resolved against the project directory, and an unknown setting or a +value of the wrong type is an error. + ## Reading values from native files The `--print*` flags read what is actually written in the native files instead @@ -205,7 +231,7 @@ steps: The same functionality is available as a module, with type definitions: ```js -const { checkVersions, syncVersions, readNativeValues } = require('rn-version-sync'); +const { checkVersions, syncVersions, readNativeValues, loadConfig } = require('rn-version-sync'); checkVersions(process.cwd()); // { @@ -225,10 +251,15 @@ syncVersions(process.cwd(), { reserveBuilds: 100 }); readNativeValues(process.cwd(), 'ios', { configuration: 'Staging' }); // { appId: 'com.example.app.staging', versionName: '1.2.3', versionCode: '1020300' } + +loadConfig(process.cwd()); +// { reserveBuilds: 100 } ``` `resolveVersions`, `formatTemplate` and `formatEnv` back the target row, -`--format` and `--print-env` in the same way. +`--format` and `--print-env` in the same way. Like the CLI, all functions fill +in options that are not passed explicitly from the `rn-version-sync` +configuration in `package.json`. ## Requirements diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 457f92a..e2240e3 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -171,6 +171,79 @@ describe('cli', () => { ); }); + it('applies the configuration in package.json', () => { + project = new TestProject({ + version: '1.2.3', + config: { reserveBuilds: 100, skipIos: true }, + android: { versionName: '1.2.3', versionCode: 1020300 }, + ios: false, + }); + + expect(run(project.root)).toEqual({ + status: 0, + stdout: [ + 'PLATFORM APP ID VERSION STATUS', + JS_ROW, + 'android com.testapp 1.2.3 (1020300) ok', + '', + ].join('\n'), + stderr: '', + }); + }); + + it('prefers command line options over the configuration', () => { + project = new TestProject({ + version: '1.2.3', + config: { reserveBuilds: 100, skipIos: true }, + ios: false, + }); + + const result = run(project.root, '--write', '--reserve-builds', '10'); + expect(result.stdout).toContain( + 'android com.testapp 1.2.3 (102030) updated\n', + ); + }); + + it('labels the iOS row with the configuration from package.json', () => { + project = new TestProject({ + version: '1.2.3', + config: { configuration: 'Debug' }, + android: false, + ios: [ + { + name: 'Debug', + version: '1.2.3', + buildNumber: '10203', + bundleId: 'com.testapp', + }, + { + name: 'Release', + version: '1.0.0', + buildNumber: '1', + bundleId: 'com.testapp', + }, + ], + }); + + expect(run(project.root).stdout).toContain( + 'ios (Debug) com.testapp 1.2.3 (10203) ok\n', + ); + }); + + it('rejects an invalid configuration', () => { + project = new TestProject({ + version: '1.2.3', + config: { reserveBuild: 100 }, + }); + + expect(run(project.root)).toEqual({ + status: 1, + stdout: '', + stderr: + 'Error: Invalid "rn-version-sync" configuration in package.json: unknown setting "reserveBuild" (available: reserveBuilds, gradlePath, pbxprojPath, configuration, skipAndroid, skipIos)\n', + }); + }); + it('uses --project-dir instead of the working directory', () => { project = new TestProject({ version: '1.2.3' }); const other = new TestProject({ version: '9.9.9' }); @@ -462,6 +535,9 @@ describe('cli', () => { expect(result.stdout).toContain('Usage: rn-version-sync [options]'); expect(result.stdout).toContain('--write'); expect(result.stdout).toContain('--reserve-builds '); + expect(result.stdout).toContain( + '"rn-version-sync": { "reserveBuilds": 100 }', + ); }); }); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 0000000..8d320b4 --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,95 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadConfig } from '../config'; +import { TestProject } from './helpers'; + +const INVALID = 'Invalid "rn-version-sync" configuration in package.json: '; + +describe('loadConfig', () => { + let project: TestProject; + + afterEach(() => { + project?.cleanup(); + }); + + it('returns an empty configuration without the package.json key', () => { + project = new TestProject({ android: false, ios: false }); + + expect(loadConfig(project.root)).toEqual({}); + }); + + it('returns an empty configuration when package.json is missing', () => { + project = new TestProject({ android: false, ios: false }); + fs.rmSync(path.join(project.root, 'package.json')); + + expect(loadConfig(project.root)).toEqual({}); + }); + + it('reads the settings and resolves paths against the project directory', () => { + project = new TestProject({ + android: false, + ios: false, + config: { + reserveBuilds: 100, + gradlePath: 'app/build.gradle', + pbxprojPath: '/elsewhere/App.xcodeproj/project.pbxproj', + configuration: 'Staging', + skipAndroid: false, + skipIos: true, + }, + }); + + expect(loadConfig(project.root)).toEqual({ + reserveBuilds: 100, + gradlePath: path.join(project.root, 'app', 'build.gradle'), + pbxprojPath: '/elsewhere/App.xcodeproj/project.pbxproj', + configuration: 'Staging', + skipAndroid: false, + skipIos: true, + }); + }); + + it('rejects a configuration that is not an object', () => { + for (const config of [null, 100, 'reserveBuilds', [100]]) { + project = new TestProject({ android: false, ios: false, config }); + + expect(() => loadConfig(project.root)).toThrow( + `${INVALID}must be an object`, + ); + project.cleanup(); + } + }); + + it('rejects an unknown setting', () => { + project = new TestProject({ + android: false, + ios: false, + config: { reserveBuild: 100 }, + }); + + expect(() => loadConfig(project.root)).toThrow( + `${INVALID}unknown setting "reserveBuild" (available: reserveBuilds, gradlePath, pbxprojPath, configuration, skipAndroid, skipIos)`, + ); + }); + + it('rejects values of the wrong type', () => { + const cases: [unknown, string][] = [ + [{ reserveBuilds: '100' }, 'reserveBuilds must be a positive integer'], + [{ reserveBuilds: 0 }, 'reserveBuilds must be a positive integer'], + [{ reserveBuilds: 1.5 }, 'reserveBuilds must be a positive integer'], + [{ gradlePath: 5 }, 'gradlePath must be a string'], + [{ pbxprojPath: null }, 'pbxprojPath must be a string'], + [{ configuration: true }, 'configuration must be a string'], + [{ skipAndroid: 'yes' }, 'skipAndroid must be a boolean'], + [{ skipIos: 1 }, 'skipIos must be a boolean'], + ]; + + for (const [config, message] of cases) { + project = new TestProject({ android: false, ios: false, config }); + + expect(() => loadConfig(project.root)).toThrow(`${INVALID}${message}`); + project.cleanup(); + } + }); +}); diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index 5dbdee1..833c9bd 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -37,6 +37,8 @@ export interface ProjectOptions { iosTargets?: PbxprojTarget[]; /** Project-level build configurations; default to the targets' names without version settings */ iosProjectConfigs?: PbxprojBuildConfig[]; + /** Written as the "rn-version-sync" key of package.json, even when invalid */ + config?: unknown; } export const APPLICATION_PRODUCT_TYPE = 'com.apple.product-type.application'; @@ -70,7 +72,13 @@ export class TestProject { fs.writeFileSync( path.join(this.root, 'package.json'), JSON.stringify( - { name: 'test-app', version: options.version ?? defaults.version }, + { + name: 'test-app', + version: options.version ?? defaults.version, + ...(options.config !== undefined && { + 'rn-version-sync': options.config, + }), + }, null, 2, ), diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index c324f53..de3180f 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,3 +1,5 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { checkVersions, @@ -7,7 +9,7 @@ import { resolveVersions, syncVersions, } from '../index'; -import { TestProject } from './helpers'; +import { TestProject, buildGradle } from './helpers'; describe('syncVersions', () => { let project: TestProject; @@ -39,6 +41,20 @@ describe('syncVersions', () => { expect(gradle).toContain('versionCode 999'); }); + it('writes the version code reserved in package.json', () => { + project = new TestProject({ + version: '1.2.3', + config: { reserveBuilds: 100 }, + }); + + syncVersions(project.root); + + expect(project.readGradle()).toContain('versionCode 1020300'); + expect(project.readPbxproj()).toContain( + 'CURRENT_PROJECT_VERSION = 1020300;', + ); + }); + it('throws on versionCode exceeding 32-bit int max', () => { project = new TestProject({ version: '1.0.0', android: false, ios: false }); @@ -278,6 +294,38 @@ describe('checkVersions', () => { }); }); + it('applies the configuration in package.json', () => { + project = new TestProject({ + version: '1.2.3', + config: { reserveBuilds: 100, skipIos: true }, + android: { versionName: '1.2.3', versionCode: 1020300 }, + ios: false, + }); + + const status = checkVersions(project.root); + + expect(status.target.versionCode).toBe(1020300); + expect(status.platforms.map((p) => p.platform)).toEqual(['android']); + expect(status.platforms[0].inSync).toBe(true); + }); + + it('prefers explicit options over the configuration', () => { + project = new TestProject({ + version: '1.2.3', + config: { reserveBuilds: 100, skipIos: true }, + android: false, + }); + + const status = checkVersions(project.root, { + reserveBuilds: 10, + skipAndroid: true, + skipIos: false, + }); + + expect(status.target.versionCode).toBe(102030); + expect(status.platforms.map((p) => p.platform)).toEqual(['ios']); + }); + it('reads the iOS values of the requested configuration', () => { project = new TestProject({ version: '1.2.3', @@ -365,6 +413,24 @@ describe('resolveVersions', () => { expect(result).toEqual({ versionName: '1.2.3', versionCode: 1020300 }); }); + it('applies reserveBuilds from package.json unless given', () => { + project = new TestProject({ + version: '1.2.3', + config: { reserveBuilds: 100 }, + android: false, + ios: false, + }); + + expect(resolveVersions(project.root)).toEqual({ + versionName: '1.2.3', + versionCode: 1020300, + }); + expect(resolveVersions(project.root, { reserveBuilds: 10 })).toEqual({ + versionName: '1.2.3', + versionCode: 102030, + }); + }); + it('ignores reserveBuilds when versionCode is manually set', () => { project = new TestProject({ version: '1.0.0', android: false, ios: false }); @@ -496,6 +562,26 @@ describe('readNativeValues', () => { expect(android.appId).toBe('com.testapp'); expect(ios.appId).toBe('com.testapp'); }); + + it('uses the paths and configuration set in package.json', () => { + project = new TestProject({ + config: { gradlePath: 'other/build.gradle', configuration: 'Debug' }, + ios: [ + { name: 'Debug', bundleId: 'com.testapp.debug' }, + { name: 'Release', bundleId: 'com.testapp' }, + ], + }); + fs.mkdirSync(path.join(project.root, 'other')); + fs.writeFileSync( + path.join(project.root, 'other', 'build.gradle'), + buildGradle({ applicationId: 'com.other' }), + ); + + expect(readNativeValues(project.root, 'android').appId).toBe('com.other'); + expect(readNativeValues(project.root, 'ios').appId).toBe( + 'com.testapp.debug', + ); + }); }); describe('formatEnv', () => { @@ -574,6 +660,21 @@ describe('formatTemplate', () => { expect(debug).toBe('com.testapp.debug'); }); + it('applies the configuration in package.json', () => { + project = new TestProject({ + android: false, + config: { configuration: 'Debug' }, + ios: [ + { name: 'Debug', bundleId: 'com.testapp.debug' }, + { name: 'Release', bundleId: 'com.testapp' }, + ], + }); + + expect(formatTemplate('{appId}', project.root, 'ios')).toBe( + 'com.testapp.debug', + ); + }); + it('reads only the referenced values', () => { project = new TestProject({ android: false, ios: [{ name: 'Release' }] }); diff --git a/src/cli.ts b/src/cli.ts index 8f1f724..fe554ae 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { checkVersions, formatEnv, formatTemplate, + loadConfig, readNativeValues, syncVersions, } from '.'; @@ -105,6 +106,15 @@ program 'Compare the package.json version with the native projects, or sync them with --write', ) .version(packageJson.version) + .addHelpText( + 'after', + ` +Project-wide defaults for --reserve-builds, --skip-android, --skip-ios, +--gradle-path, --pbxproj-path and --configuration can be set in package.json, +where a command line option takes precedence: + + "rn-version-sync": { "reserveBuilds": 100 }`, + ) .option('--write', 'Update the native files instead of only comparing them') .option( '--version-name ', @@ -229,12 +239,16 @@ program return; } + // Label the iOS row with the configuration the values are read from + const configuration = + options.configuration ?? loadConfig(projectDir).configuration; + if (options.write) { const result = syncVersions(projectDir, syncOptions); const labels = result.platforms.map((p) => p.updated ? 'updated' : 'unchanged', ); - console.log(renderStatus(result, labels, options.configuration)); + console.log(renderStatus(result, labels, configuration)); return; } @@ -242,7 +256,7 @@ program const labels = status.platforms.map((p) => p.inSync ? 'ok' : 'outdated', ); - console.log(renderStatus(status, labels, options.configuration)); + console.log(renderStatus(status, labels, configuration)); if (status.platforms.some((p) => !p.inSync)) { console.error('Run with --write to update the native files.'); process.exit(1); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..dcaa4a7 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,97 @@ +import * as path from 'node:path'; +import { readPackageJson } from './utils'; + +/** Key of package.json that holds the project configuration */ +const CONFIG_KEY = 'rn-version-sync'; + +/** + * Project-wide defaults, stored under the "rn-version-sync" key of + * package.json. Each setting has a CLI option of the same name, and an + * explicit option takes precedence over the configured value. + */ +export interface ProjectConfig { + /** Version codes each version owns; multiplies the calculated code */ + reserveBuilds?: number; + /** Android build.gradle, relative to the project directory */ + gradlePath?: string; + /** iOS project.pbxproj, relative to the project directory */ + pbxprojPath?: string; + /** Xcode build configuration to read the iOS values from (default: Release) */ + configuration?: string; + /** Ignore the Android project */ + skipAndroid?: boolean; + /** Ignore the iOS project */ + skipIos?: boolean; +} + +interface Setting { + /** Description of the accepted values, for error messages */ + expected: string; + isValid: (value: unknown) => boolean; +} + +const STRING: Setting = { + expected: 'a string', + isValid: (value) => typeof value === 'string', +}; + +const BOOLEAN: Setting = { + expected: 'a boolean', + isValid: (value) => typeof value === 'boolean', +}; + +const SETTINGS: Record = { + reserveBuilds: { + expected: 'a positive integer', + isValid: (value) => Number.isInteger(value) && (value as number) >= 1, + }, + gradlePath: STRING, + pbxprojPath: STRING, + configuration: STRING, + skipAndroid: BOOLEAN, + skipIos: BOOLEAN, +}; + +function isSetting(key: string): key is keyof ProjectConfig { + return (Object.keys(SETTINGS) as string[]).includes(key); +} + +function invalid(problem: string): Error { + return new Error( + `Invalid "${CONFIG_KEY}" configuration in package.json: ${problem}`, + ); +} + +/** + * Read the project configuration from package.json. A missing key yields an + * empty configuration. Unknown settings and values of the wrong type are + * errors, and relative paths are resolved against the project directory. + */ +export function loadConfig(projectRoot: string): ProjectConfig { + const raw = readPackageJson(projectRoot)[CONFIG_KEY]; + if (raw === undefined) { + return {}; + } + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw invalid('must be an object'); + } + + for (const [key, value] of Object.entries(raw)) { + if (!isSetting(key)) { + const available = Object.keys(SETTINGS).join(', '); + throw invalid(`unknown setting "${key}" (available: ${available})`); + } + if (!SETTINGS[key].isValid(value)) { + throw invalid(`${key} must be ${SETTINGS[key].expected}`); + } + } + + const config: ProjectConfig = { ...raw }; + if (config.gradlePath !== undefined) { + config.gradlePath = path.resolve(projectRoot, config.gradlePath); + } + if (config.pbxprojPath !== undefined) { + config.pbxprojPath = path.resolve(projectRoot, config.pbxprojPath); + } + return config; +} diff --git a/src/index.ts b/src/index.ts index f8d7b38..bd3acd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { locateBuildGradle, updateAndroidVersion, } from './android'; +import { type ProjectConfig, loadConfig } from './config'; import { getIOSAppId, getIOSVersions, @@ -19,19 +20,31 @@ import { export type Platform = 'android' | 'ios'; -export interface ReadOptions { - gradlePath?: string; - pbxprojPath?: string; - /** Xcode build configuration to read the iOS values from (default: Release) */ - configuration?: string; -} +export type ReadOptions = Pick< + ProjectConfig, + 'gradlePath' | 'pbxprojPath' | 'configuration' +>; -export interface SyncOptions extends ReadOptions { +/** + * Project configuration plus the per-invocation overrides. Options left + * undefined are filled in from the "rn-version-sync" key of package.json. + */ +export interface SyncOptions extends ProjectConfig { versionName?: string; versionCode?: number; - reserveBuilds?: number; - skipAndroid?: boolean; - skipIos?: boolean; +} + +/** + * Fill options left undefined with the project configuration from + * package.json, so an explicit option takes precedence over the configured + * value. + */ +function withConfig(projectRoot: string, options: T): T { + const merged: Record = { ...options }; + for (const [key, value] of Object.entries(loadConfig(projectRoot))) { + merged[key] ??= value; + } + return merged as T; } export interface ResolvedVersions { @@ -45,6 +58,13 @@ export interface ResolvedVersions { export function resolveVersions( projectRoot: string, options: SyncOptions = {}, +): ResolvedVersions { + return resolveTarget(projectRoot, withConfig(projectRoot, options)); +} + +function resolveTarget( + projectRoot: string, + options: SyncOptions, ): ResolvedVersions { const manualVersionCode = options.versionCode; if ( @@ -100,6 +120,17 @@ function readVersions( : getIOSVersions(projectRoot, options.pbxprojPath, options.configuration); } +function readValues( + projectRoot: string, + platform: Platform, + options: ReadOptions, +): NativeValues { + return { + appId: readAppId(projectRoot, platform, options), + ...readVersions(projectRoot, platform, options), + }; +} + /** * Read app id, version name and version code of one platform as written in * its native build file. @@ -109,10 +140,7 @@ export function readNativeValues( platform: Platform, options: ReadOptions = {}, ): NativeValues { - return { - appId: readAppId(projectRoot, platform, options), - ...readVersions(projectRoot, platform, options), - }; + return readValues(projectRoot, platform, withConfig(projectRoot, options)); } export interface PlatformStatus extends NativeValues { @@ -195,7 +223,7 @@ function readPlatformStatus( target: ResolvedVersions, configuration?: string, ): PlatformStatus { - const values = readNativeValues( + const values = readValues( projectRoot, file.platform, file.platform === 'android' @@ -221,18 +249,19 @@ export function checkVersions( projectRoot: string, options: SyncOptions = {}, ): VersionStatus { + const opts = withConfig(projectRoot, options); const { name, version } = getPackageInfo(projectRoot); - const target = resolveVersions(projectRoot, options); - const { files, missing } = locateNativeFiles(projectRoot, options); + const target = resolveTarget(projectRoot, opts); + const { files, missing } = locateNativeFiles(projectRoot, opts); return { packageName: name, packageVersion: version, target, overridden: - options.versionName !== undefined || options.versionCode !== undefined, + opts.versionName !== undefined || opts.versionCode !== undefined, platforms: files.map((file) => - readPlatformStatus(projectRoot, file, target, options.configuration), + readPlatformStatus(projectRoot, file, target, opts.configuration), ), missing, }; @@ -288,6 +317,7 @@ export function formatTemplate( platform: Platform, options: ReadOptions = {}, ): string { + const opts = withConfig(projectRoot, options); let appId: string | undefined; let versions: Omit | undefined; @@ -299,10 +329,10 @@ export function formatTemplate( ); } if (name === 'appId') { - appId ??= readAppId(projectRoot, platform, options); + appId ??= readAppId(projectRoot, platform, opts); return appId; } - versions ??= readVersions(projectRoot, platform, options); + versions ??= readVersions(projectRoot, platform, opts); return versions[name]; }); } @@ -339,5 +369,6 @@ export { getAndroidVersions, updateAndroidVersion, } from './android'; +export { type ProjectConfig, loadConfig } from './config'; export { getIOSAppId, getIOSVersions, updateIOSVersion } from './ios'; export { getPackageVersion } from './utils'; diff --git a/src/utils.ts b/src/utils.ts index 988fd05..98f330a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,6 +10,8 @@ export interface SemverComponents { export interface PackageJson { name?: string; version?: string; + /** Project configuration, validated by loadConfig */ + 'rn-version-sync'?: unknown; } function parsePackageJson(packagePath: string): PackageJson { @@ -23,16 +25,20 @@ function parsePackageJson(packagePath: string): PackageJson { } } +/** + * Read package.json as written; empty when the file is missing + */ +export function readPackageJson(projectRoot: string): PackageJson { + const packagePath = path.join(projectRoot, 'package.json'); + return fs.existsSync(packagePath) ? parsePackageJson(packagePath) : {}; +} + /** * Read the "name" and "version" fields of package.json as written; a field * is undefined when it or the file is missing. */ export function getPackageInfo(projectRoot: string): PackageJson { - const packagePath = path.join(projectRoot, 'package.json'); - if (!fs.existsSync(packagePath)) { - return {}; - } - const { name, version } = parsePackageJson(packagePath); + const { name, version } = readPackageJson(projectRoot); return { name, version }; }