diff --git a/.changeset/olive-parrots-invite.md b/.changeset/olive-parrots-invite.md new file mode 100644 index 00000000..a95a6bbd --- /dev/null +++ b/.changeset/olive-parrots-invite.md @@ -0,0 +1,5 @@ +--- +'@callstack/brownfield-cli': patch +--- + +respect `--destination` in `package:ios` when merging XCFrameworks diff --git a/packages/cli/package.json b/packages/cli/package.json index ada436d7..16108769 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -88,11 +88,11 @@ "@expo/config": "^12.0.13", "@react-native-community/cli-config": "^20.0.0", "@react-native-community/cli-config-android": "^20.0.0", - "@rock-js/platform-android": "^0.14.0", - "@rock-js/platform-apple-helpers": "^0.14.0", - "@rock-js/plugin-brownfield-android": "^0.14.0", - "@rock-js/plugin-brownfield-ios": "^0.14.0", - "@rock-js/tools": "^0.14.0", + "@rock-js/platform-android": "^0.15.4", + "@rock-js/platform-apple-helpers": "^0.15.4", + "@rock-js/plugin-brownfield-android": "^0.15.4", + "@rock-js/plugin-brownfield-ios": "^0.15.4", + "@rock-js/tools": "^0.15.4", "ajv": "^8.20.0", "commander": "^14.0.3", "quicktype-core": "^23.2.6", diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts index cafbf49c..0fe63c48 100644 --- a/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.action.test.ts @@ -234,6 +234,7 @@ describe('package:ios action --add-spm-package', () => { productsPath: '/repo/ios/.brownfield/build/Build/Products', configuration: 'Release', frameworkName: 'BrownfieldLib', + sdks: ['iphoneos', 'iphonesimulator'], }); expect(mockCreateLocalSpmPackage).toHaveBeenCalledWith({ packageDir: '/repo/ios/.brownfield/package/build', @@ -257,11 +258,14 @@ describe('package:ios action --add-spm-package', () => { 'Release', ]); - expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith({ - projectRoot: '/repo', - packageDir: '/repo/ios/.brownfield/package/build', - usePrebuiltExpo: false, - }); + expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith( + expect.objectContaining({ + projectRoot: '/repo', + packageDir: '/repo/ios/.brownfield/package/build', + usePrebuiltExpo: false, + usePrebuiltExpoExplicit: true, + }) + ); expect(mockCreateLocalSpmPackage).toHaveBeenCalledWith({ packageDir: '/repo/ios/.brownfield/package/build', frameworkName: 'BrownfieldLib', @@ -311,6 +315,37 @@ describe('package:ios action --add-spm-package', () => { expect(processExitMock).toHaveBeenCalledWith(1); }); + test('marks an explicit --use-prebuilt-expo true as explicit', async () => { + await invokePackageIosAction([ + '--use-prebuilt-expo', + 'true', + '--configuration', + 'Release', + ]); + + expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith( + expect.objectContaining({ + usePrebuiltExpo: true, + usePrebuiltExpoExplicit: true, + }) + ); + }); + + test('passes SPM generation the degraded value when Expo prebuilts are missing', async () => { + mockEmitExpoSupportXcframeworks.mockImplementation( + ({ onDegradeToSource }: { onDegradeToSource?: () => void }) => { + onDegradeToSource?.(); + return true; + } + ); + + await invokePackageIosAction(['--add-spm-package', '--configuration', 'Release']); + + expect(mockCreateLocalSpmPackage).toHaveBeenCalledWith( + expect.objectContaining({ usePrebuiltExpo: false }) + ); + }); + test('runs Expo framework emission before local SPM package creation for Expo SDK 56+', async () => { mockEmitExpoSupportXcframeworks.mockReturnValue(true); @@ -320,11 +355,15 @@ describe('package:ios action --add-spm-package', () => { 'Release', ]); - expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith({ - projectRoot: '/repo', - packageDir: '/repo/ios/.brownfield/package/build', - usePrebuiltExpo: true, - }); + // omitted flag => value is version-inferred, not explicit + expect(mockEmitExpoSupportXcframeworks).toHaveBeenCalledWith( + expect.objectContaining({ + projectRoot: '/repo', + packageDir: '/repo/ios/.brownfield/package/build', + usePrebuiltExpo: true, + usePrebuiltExpoExplicit: false, + }) + ); expect( mockEmitExpoSupportXcframeworks.mock.invocationCallOrder[0] ).toBeLessThan(mockCreateLocalSpmPackage.mock.invocationCallOrder[0] ?? 0); diff --git a/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts b/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts index 243fe98a..760db465 100644 --- a/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts +++ b/packages/cli/src/brownfield/commands/__tests__/packageIos.test.ts @@ -3,6 +3,7 @@ import * as rockTools from '@rock-js/tools'; import { describe, expect, test } from 'vitest'; import { + isUsePrebuiltExpoExplicit, packageIosCommand, parseUsePrebuiltExpoArgument, parseUsePrebuiltRnCoreArgument, @@ -120,3 +121,14 @@ describe('--add-spm-package (Commander)', () => { ); }); }); + +describe('isUsePrebuiltExpoExplicit', () => { + test('is false when the value is omitted (version-inferred later)', () => { + expect(isUsePrebuiltExpoExplicit(undefined)).toBe(false); + }); + + test('is true for explicit true and false (CLI or brownfield.config.json)', () => { + expect(isUsePrebuiltExpoExplicit(true)).toBe(true); + expect(isUsePrebuiltExpoExplicit(false)).toBe(true); + }); +}); diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts index 0de55e96..00af02cc 100644 --- a/packages/cli/src/brownfield/commands/packageIos.ts +++ b/packages/cli/src/brownfield/commands/packageIos.ts @@ -32,6 +32,10 @@ import { import { runBrownieCodegenIfApplicable } from '../../brownie/helpers/runBrownieCodegenIfApplicable.js'; import { runNavigationCodegenIfApplicable } from '../../navigation/helpers/runNavigationCodegenIfApplicable.js'; import { copyDebugBundleToSimulatorSlice } from '../utils/copyDebugBundleToSimulatorSlice.js'; +import { + collectFrameworkPaths, + resolveDestinationSdks, +} from '../utils/destinationSdks.js'; import { resolvePackagedFrameworkName } from '../utils/resolvePackagedFrameworkName.js'; import { stripFrameworkBinary } from '../utils/stripFrameworkBinary.js'; import type { PackageIosOptions } from '../../types.js'; @@ -126,6 +130,11 @@ export const packageIosCommand = curryOptions( .action( actionRunner(async (cliOptions: PackageIosOptions) => { const options = mergeBrownfieldConfigWithOptions(cliOptions, 'ios'); + // captured before the version-aware default below, so that a value from + // brownfield.config.json counts as explicit + const usePrebuiltExpoExplicit = isUsePrebuiltExpoExplicit( + options.usePrebuiltExpo + ); const projectRoot = findProjectRoot(); await runExpoPrebuildIfNeeded({ projectRoot, platform: 'ios' }); @@ -281,6 +290,9 @@ export const packageIosCommand = curryOptions( // Reference: https://github.com/facebook/react-native/blob/490c5e8dcc6cdb19c334cc39e93a39a48ba71e96/packages/react-native/scripts/cocoapods/new_architecture.rb#L171 const packageDir = path.join(dotBrownfieldDir, 'package', 'build'); const configuration = options.configuration ?? 'Debug'; + // `--destination` narrows which slices Xcode emits, so every path below has to + // look at those slices only instead of assuming a device + simulator pair + const sdks = resolveDestinationSdks(options.destination); const { hasBrownie } = await runBrownieCodegenIfApplicable( projectRoot, @@ -309,6 +321,11 @@ export const packageIosCommand = curryOptions( projectRoot, packageDir, usePrebuiltExpo: options.usePrebuiltExpo, + usePrebuiltExpoExplicit, + // keep the generated SPM manifest consistent with the emitted XCFrameworks + onDegradeToSource: () => { + options.usePrebuiltExpo = false; + }, }); const productsPath = path.join(options.buildFolder, 'Build', 'Products'); @@ -317,6 +334,7 @@ export const packageIosCommand = curryOptions( explicitScheme: options.scheme, productsPath, configuration, + sdks, }); if (!frameworkName && options.addSpmPackage) { @@ -335,24 +353,19 @@ export const packageIosCommand = curryOptions( productsPath, configuration, frameworkName, + sdks, }); if (configuration.includes('Debug')) { // Re-merge only Debug frameworks so the simulator slice includes main.jsbundle. await mergeFrameworks({ sourceDir: userConfig.project.ios.sourceDir, - frameworkPaths: [ - path.join( - productsPath, - `${configuration}-iphoneos`, - `${frameworkName}.framework` - ), - path.join( - productsPath, - `${configuration}-iphonesimulator`, - `${frameworkName}.framework` - ), - ], + frameworkPaths: collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName, + }), outputPath: path.join(packageDir, `${frameworkName}.xcframework`), }); } @@ -384,20 +397,13 @@ export const packageIosCommand = curryOptions( await mergeFrameworks({ sourceDir: userConfig.project.ios.sourceDir, - frameworkPaths: [ - path.join( - productsPath, - `${configuration}-iphoneos`, - 'Brownie', - 'Brownie.framework' - ), - path.join( - productsPath, - `${configuration}-iphonesimulator`, - 'Brownie', - 'Brownie.framework' - ), - ], + frameworkPaths: collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName: 'Brownie', + productSubDir: 'Brownie', + }), outputPath: brownieOutputPath, }); @@ -419,20 +425,13 @@ export const packageIosCommand = curryOptions( await mergeFrameworks({ sourceDir: userConfig.project.ios.sourceDir, - frameworkPaths: [ - path.join( - productsPath, - `${configuration}-iphoneos`, - 'BrownfieldNavigation', - 'BrownfieldNavigation.framework' - ), - path.join( - productsPath, - `${configuration}-iphonesimulator`, - 'BrownfieldNavigation', - 'BrownfieldNavigation.framework' - ), - ], + frameworkPaths: collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName: 'BrownfieldNavigation', + productSubDir: 'BrownfieldNavigation', + }), outputPath: brownfieldNavigationOutputPath, }); @@ -462,9 +461,20 @@ export const packageIosCommand = curryOptions( 'In Xcode, choose File > Add Package Dependencies..., click Add Local..., and select that folder.' ); } + + // The dependency prints its own "Success" before the post-build steps above, + // so log a final line marking the true end of the run. + logger.success( + `package:ios finished. Artifacts are in ${colorLink(relativeToCwd(packageDir))}` + ); }) ); +/** Whether `--use-prebuilt-expo` was chosen (CLI or brownfield.config.json) rather than inferred. */ +export function isUsePrebuiltExpoExplicit(value: boolean | undefined) { + return value !== undefined; +} + export const packageIosExample = new ExampleUsage( 'package:ios --scheme BrownfieldLib --configuration Release', "Build iOS XCFramework for 'BrownfieldLib' scheme in Release configuration" diff --git a/packages/cli/src/brownfield/utils/__tests__/destination-sdks.test.ts b/packages/cli/src/brownfield/utils/__tests__/destination-sdks.test.ts new file mode 100644 index 00000000..67730440 --- /dev/null +++ b/packages/cli/src/brownfield/utils/__tests__/destination-sdks.test.ts @@ -0,0 +1,145 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + collectFrameworkPaths, + hasFrameworkBuildProduct, + resolveDestinationSdks, +} from '../destinationSdks.js'; + +describe('resolveDestinationSdks', () => { + it('builds both slices when no destination is passed', () => { + expect(resolveDestinationSdks(undefined)).toEqual([ + 'iphoneos', + 'iphonesimulator', + ]); + expect(resolveDestinationSdks([])).toEqual([ + 'iphoneos', + 'iphonesimulator', + ]); + }); + + it('maps the device and simulator aliases', () => { + expect(resolveDestinationSdks(['simulator'])).toEqual(['iphonesimulator']); + expect(resolveDestinationSdks(['device'])).toEqual(['iphoneos']); + }); + + it('maps raw xcodebuild destinations', () => { + expect(resolveDestinationSdks(['generic/platform=iOS Simulator'])).toEqual([ + 'iphonesimulator', + ]); + expect(resolveDestinationSdks(['generic/platform=iOS'])).toEqual([ + 'iphoneos', + ]); + }); + + it('widens to both slices for destinations it cannot classify', () => { + expect(resolveDestinationSdks(['id=00008030-ABCDEF'])).toEqual([ + 'iphoneos', + 'iphonesimulator', + ]); + }); + + it('dedupes and keeps a stable order', () => { + expect(resolveDestinationSdks(['simulator', 'device', 'simulator'])).toEqual( + ['iphoneos', 'iphonesimulator'] + ); + }); +}); + +describe('collectFrameworkPaths', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'destination-sdks-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function createFramework(sdk: string, subDir?: string, name = 'BrownfieldLib') { + const frameworkPath = path.join( + tempDir, + `Release-${sdk}`, + ...(subDir ? [subDir] : []), + `${name}.framework` + ); + fs.mkdirSync(frameworkPath, { recursive: true }); + return frameworkPath; + } + + it('returns only the slices that were built', () => { + const simulatorFramework = createFramework('iphonesimulator'); + + expect( + collectFrameworkPaths({ + productsPath: tempDir, + configuration: 'Release', + sdks: ['iphonesimulator'], + frameworkName: 'BrownfieldLib', + }) + ).toEqual([simulatorFramework]); + }); + + it('drops slices with no build product on disk', () => { + const simulatorFramework = createFramework('iphonesimulator'); + + expect( + collectFrameworkPaths({ + productsPath: tempDir, + configuration: 'Release', + sdks: ['iphoneos', 'iphonesimulator'], + frameworkName: 'BrownfieldLib', + }) + ).toEqual([simulatorFramework]); + }); + + it('accepts a static library as a build product', () => { + const productsDir = path.join(tempDir, 'Release-iphonesimulator'); + fs.mkdirSync(productsDir, { recursive: true }); + fs.writeFileSync(path.join(productsDir, 'libBrownfieldLib.a'), 'archive'); + + expect(hasFrameworkBuildProduct(productsDir, 'BrownfieldLib')).toBe(true); + expect( + collectFrameworkPaths({ + productsPath: tempDir, + configuration: 'Release', + sdks: ['iphonesimulator'], + frameworkName: 'BrownfieldLib', + }) + ).toEqual([path.join(productsDir, 'BrownfieldLib.framework')]); + }); + + it('throws a descriptive error when no slice has a build product', () => { + fs.mkdirSync(path.join(tempDir, 'Debug-iphonesimulator'), { + recursive: true, + }); + + expect(() => + collectFrameworkPaths({ + productsPath: tempDir, + configuration: 'Debug', + sdks: ['iphonesimulator'], + frameworkName: 'mastodonreactnative', + }) + ).toThrowError(/Could not find a build product for mastodonreactnative/); + }); + + it('resolves nested pod products', () => { + const brownie = createFramework('iphonesimulator', 'Brownie', 'Brownie'); + + expect( + collectFrameworkPaths({ + productsPath: tempDir, + configuration: 'Release', + sdks: ['iphoneos', 'iphonesimulator'], + frameworkName: 'Brownie', + productSubDir: 'Brownie', + }) + ).toEqual([brownie]); + }); +}); diff --git a/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts b/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts index 7dc87732..66e504bc 100644 --- a/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/emitExpoSupportXcframeworks.test.ts @@ -20,6 +20,7 @@ vi.mock('../project.js', async (importOriginal) => { return { ...actual, getExpoSdkMajor: vi.fn(), + getExpoIosUsePrecompiledModules: vi.fn(), isExpoProject: vi.fn(), }; }); @@ -78,6 +79,9 @@ describe('emitExpoSupportXcframeworks', () => { fs.mkdirSync(projectRoot, { recursive: true }); fs.mkdirSync(packageDir, { recursive: true }); vi.clearAllMocks(); + vi.mocked(projectUtils.getExpoIosUsePrecompiledModules).mockReturnValue( + undefined + ); }); afterEach(() => { @@ -294,4 +298,72 @@ describe('emitExpoSupportXcframeworks', () => { expect(fs.readdirSync(packageDir)).toEqual([]); expect(childProcess.execFileSync).not.toHaveBeenCalled(); }); + + it('names both remedies and the usePrecompiledModules cause when prebuilts are missing', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + vi.mocked(projectUtils.getExpoIosUsePrecompiledModules).mockReturnValue( + false + ); + + createAllRequiredExpoSupportXcframeworks(projectRoot); + fs.rmSync(path.join(projectRoot, 'ios', 'Pods', 'ExpoFont'), { + recursive: true, + force: true, + }); + + const run = () => + emitExpoSupportXcframeworks({ + projectRoot, + packageDir, + usePrebuiltExpo: true, + usePrebuiltExpoExplicit: true, + }); + expect(run).toThrow(/--use-prebuilt-expo false/); + expect(run).toThrow(/pod install/); + expect(run).toThrow(/ios\.usePrecompiledModules to false/); + }); + + it('degrades to source builds instead of throwing when usePrebuiltExpo was inferred', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + + createSignedMockXcframework( + path.join(projectRoot, 'node_modules', 'expo-modules-jsi', 'apple', 'Products'), + 'ExpoModulesJSI' + ); + const onDegradeToSource = vi.fn(); + + expect( + emitExpoSupportXcframeworks({ + projectRoot, + packageDir, + usePrebuiltExpo: true, + usePrebuiltExpoExplicit: false, + onDegradeToSource, + }) + ).toBe(true); + + expect(onDegradeToSource).toHaveBeenCalledTimes(1); + expect(fs.readdirSync(packageDir)).toEqual(['ExpoModulesJSI.xcframework']); + }); + + it('still throws when usePrebuiltExpo was explicit and prebuilts are missing', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + + createSignedMockXcframework( + path.join(projectRoot, 'node_modules', 'expo-modules-jsi', 'apple', 'Products'), + 'ExpoModulesJSI' + ); + + expect(() => + emitExpoSupportXcframeworks({ + projectRoot, + packageDir, + usePrebuiltExpo: true, + usePrebuiltExpoExplicit: true, + }) + ).toThrow(/Expected Expo SDK 56\+ XCFramework not found/); + }); }); diff --git a/packages/cli/src/brownfield/utils/__tests__/project.test.ts b/packages/cli/src/brownfield/utils/__tests__/project.test.ts index e806c614..15905e6e 100644 --- a/packages/cli/src/brownfield/utils/__tests__/project.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/project.test.ts @@ -4,7 +4,10 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { getExpoConfigIfIsExpo } from '../project.js'; +import { + getExpoConfigIfIsExpo, + getExpoIosUsePrecompiledModules, +} from '../project.js'; function createTempExpoProject(): string { const tempDir = fs.mkdtempSync( @@ -93,3 +96,74 @@ describe('getExpoConfigIfIsExpo', () => { ); }); }); + +function createDynamicConfigExpoProject(usePrecompiledModules: boolean): string { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'brownfield-expo-dynamic-') + ); + + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify( + { name: 'temp-dynamic-expo', version: '1.0.0', dependencies: { expo: '58.0.0' } }, + null, + 2 + ) + ); + + // Dynamic config only: no app.json, so `rootConfig` carries no plugins array. + fs.writeFileSync( + path.join(tempDir, 'app.config.js'), + `module.exports = { + name: 'TempDynamicExpo', + slug: 'temp-dynamic-expo', + plugins: [['expo-build-properties', { ios: { usePrecompiledModules: ${usePrecompiledModules} } }]], +}; +` + ); + + // Resolvable no-op plugin so plugin evaluation succeeds. + const pluginDir = path.join(tempDir, 'node_modules', 'expo-build-properties'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify( + { name: 'expo-build-properties', version: '1.0.0', main: 'index.js' }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(pluginDir, 'index.js'), + 'module.exports = (config) => config;\n' + ); + + return tempDir; +} + +describe('getExpoIosUsePrecompiledModules', () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('reads the value from a dynamic app.config.js', () => { + tempDir = createDynamicConfigExpoProject(false); + + expect(getExpoIosUsePrecompiledModules(tempDir as string)).toBe(false); + }); + + it('returns undefined rather than throwing when plugins cannot be evaluated', () => { + // createTempExpoProject lists a plugin that fails to load, which is the + // reason `skipPlugins` is used; the retry must degrade, not crash. + tempDir = createTempExpoProject(); + + expect(() => + getExpoIosUsePrecompiledModules(tempDir as string) + ).not.toThrow(); + }); +}); diff --git a/packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts b/packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts index a9baea3f..a58f0c01 100644 --- a/packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/resolve-packaged-framework-name.test.ts @@ -40,6 +40,22 @@ describe('resolvePackagedFrameworkName', () => { }); }); + it('resolves the packaged framework from a simulator-only build', () => { + const simulatorProductsPath = path.join(tempDir, 'Release-iphonesimulator'); + createFramework(simulatorProductsPath, 'BrownfieldLib', true); + + expect( + resolvePackagedFrameworkName({ + productsPath: tempDir, + configuration: 'Release', + sdks: ['iphonesimulator'], + }) + ).toEqual({ + frameworkName: 'BrownfieldLib', + resolution: 'detected', + }); + }); + it('resolves the packaged framework automatically from the device build output', () => { const deviceProductsPath = path.join(tempDir, 'Debug-iphoneos'); createFramework(deviceProductsPath, 'BrownfieldLib', true); diff --git a/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts b/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts index 693349fd..18ee076a 100644 --- a/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts +++ b/packages/cli/src/brownfield/utils/__tests__/supportsPrebuiltExpo.test.ts @@ -15,6 +15,7 @@ vi.mock('../project.js', async (importOriginal) => { return { ...actual, getExpoSdkMajor: vi.fn(), + getExpoIosUsePrecompiledModules: vi.fn(), isExpoProject: vi.fn(), }; }); @@ -23,6 +24,7 @@ describe('supportsPrebuiltExpo', () => { beforeEach(() => { vi.mocked(projectUtils.isExpoProject).mockReset(); vi.mocked(projectUtils.getExpoSdkMajor).mockReset(); + vi.mocked(projectUtils.getExpoIosUsePrecompiledModules).mockReset(); }); test('returns supported with opt-in default for non-Expo projects', () => { @@ -165,4 +167,32 @@ describe('isPrecompiledExpoDisabledByPodfile', () => { expect(isPrecompiledExpoDisabledByPodfile(projectRoot)).toBe(false); }); + + test('returns unsupported when ios.usePrecompiledModules is false', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + vi.mocked(projectUtils.getExpoIosUsePrecompiledModules).mockReturnValue( + false + ); + + expect(supportsPrebuiltExpo({ projectRoot: '/project' })).toEqual({ + supported: false, + reason: expect.stringMatching(/ios\.usePrecompiledModules/), + }); + }); + + test('stays supported when ios.usePrecompiledModules is true or unset', () => { + vi.mocked(projectUtils.isExpoProject).mockReturnValue(true); + vi.mocked(projectUtils.getExpoSdkMajor).mockReturnValue(56); + + for (const value of [true, undefined]) { + vi.mocked(projectUtils.getExpoIosUsePrecompiledModules).mockReturnValue( + value + ); + expect(supportsPrebuiltExpo({ projectRoot: '/project' })).toEqual({ + supported: true, + enabledByDefault: true, + }); + } + }); }); diff --git a/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts b/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts index bf9c719d..b097c503 100644 --- a/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts +++ b/packages/cli/src/brownfield/utils/copyDebugBundleToSimulatorSlice.ts @@ -3,21 +3,31 @@ import path from 'node:path'; import { colorLink, logger, relativeToCwd } from '@rock-js/tools'; +import { ALL_APPLE_SDKS, type AppleSdk } from './destinationSdks.js'; + interface CopyDebugBundleToSimulatorSliceOptions { productsPath: string; configuration: string; frameworkName: string; + /** Slices the build produced; defaults to both. */ + sdks?: AppleSdk[]; } export function copyDebugBundleToSimulatorSlice({ productsPath, configuration, frameworkName, + sdks = ALL_APPLE_SDKS, }: CopyDebugBundleToSimulatorSliceOptions) { if (!configuration.includes('Debug')) { return; } + // nothing to copy across when only one slice was built + if (!sdks.includes('iphoneos') || !sdks.includes('iphonesimulator')) { + return; + } + const deviceBundlePath = path.join( productsPath, `${configuration}-iphoneos`, diff --git a/packages/cli/src/brownfield/utils/destinationSdks.ts b/packages/cli/src/brownfield/utils/destinationSdks.ts new file mode 100644 index 00000000..f6b5f17c --- /dev/null +++ b/packages/cli/src/brownfield/utils/destinationSdks.ts @@ -0,0 +1,122 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { RockError } from '@rock-js/tools'; + +/** Xcode SDK a build product directory belongs to (`Release-iphoneos`, `Release-iphonesimulator`). */ +export type AppleSdk = 'iphoneos' | 'iphonesimulator'; + +/** Both slices, in the order Rock's `packageIosAction` builds them. */ +export const ALL_APPLE_SDKS: AppleSdk[] = ['iphoneos', 'iphonesimulator']; + +/** + * Map a single `--destination` value onto the SDK(s) its build products land in. + * + * Mirrors `resolveDestination` in `@rock-js/platform-apple-helpers`: the aliases + * `device`/`simulator` plus any raw `xcodebuild -destination` string. A value we + * cannot classify (`id=`, `name=iPhone 17`) may resolve to either SDK, so it + * widens to both rather than silently dropping a slice. + */ +function resolveSdksForDestination(destination: string): AppleSdk[] { + const normalized = destination.trim().toLowerCase(); + + if (normalized === 'device') { + return ['iphoneos']; + } + + if (normalized === 'simulator' || normalized.includes('simulator')) { + return ['iphonesimulator']; + } + + if (normalized.includes('platform=ios')) { + return ['iphoneos']; + } + + return ALL_APPLE_SDKS; +} + +/** + * SDK slices a `package:ios` run produces for the given `--destination` values. + * + * Omitting `--destination` builds both slices, matching Rock's default. + */ +export function resolveDestinationSdks( + destination?: string[] | null +): AppleSdk[] { + if (!destination || destination.length === 0) { + return ALL_APPLE_SDKS; + } + + const sdks = new Set(destination.flatMap(resolveSdksForDestination)); + + // keep a stable order regardless of the order the flags were passed in + return ALL_APPLE_SDKS.filter((sdk) => sdks.has(sdk)); +} + +/** + * Whether `directoryPath` holds a build product `mergeFrameworks` can consume: + * either a real `.framework` bundle or the static library it synthesizes one from. + */ +export function hasFrameworkBuildProduct( + directoryPath: string, + frameworkName: string +) { + return ( + fs.existsSync(path.join(directoryPath, `${frameworkName}.framework`)) || + fs.existsSync(path.join(directoryPath, `lib${frameworkName}.a`)) + ); +} + +interface CollectFrameworkPathsOptions { + productsPath: string; + configuration: string; + sdks: AppleSdk[]; + frameworkName: string; + /** Sub-directory of the configuration products dir, e.g. `Brownie` for a Pod target. */ + productSubDir?: string; +} + +/** + * `.framework` paths to merge into an XCFramework, limited to the slices that were + * actually built. Slices with no build product on disk are dropped, so a + * `--destination simulator` run merges the simulator slice alone instead of failing + * on a missing `-iphoneos` directory. + * + * Throws when no slice has a build product: passing an empty list to + * `mergeFrameworks` would surface as xcodebuild's opaque + * "at least one framework or library must be specified". + */ +export function collectFrameworkPaths({ + productsPath, + configuration, + sdks, + frameworkName, + productSubDir, +}: CollectFrameworkPathsOptions): string[] { + const searchedDirectories = sdks.map((sdk) => + path.join( + productsPath, + `${configuration}-${sdk}`, + ...(productSubDir ? [productSubDir] : []) + ) + ); + + const frameworkPaths = searchedDirectories + .filter((directoryPath) => + hasFrameworkBuildProduct(directoryPath, frameworkName) + ) + .map((directoryPath) => + path.join(directoryPath, `${frameworkName}.framework`) + ); + + if (frameworkPaths.length === 0) { + throw new RockError( + `Could not find a build product for ${frameworkName} in the ${configuration} configuration. ` + + `Looked for ${frameworkName}.framework or lib${frameworkName}.a in:\n` + + searchedDirectories.map((dir) => ` - ${dir}`).join('\n') + + `\nIf the build produced an .app instead of a framework, the wrong scheme was built; pass --scheme with your brownfield framework scheme.` + ); + } + + return frameworkPaths; +} diff --git a/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts b/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts index ea605f29..6a3503fe 100644 --- a/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts +++ b/packages/cli/src/brownfield/utils/emitExpoSupportXcframeworks.ts @@ -1,10 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; -import { RockError } from '@rock-js/tools'; +import { logger, RockError } from '@rock-js/tools'; import { normalizeCopiedXcframework } from './normalizeCopiedXcframework.js'; -import { getExpoSdkMajor, isExpoProject } from './project.js'; +import { + getExpoIosUsePrecompiledModules, + getExpoSdkMajor, + isExpoProject, +} from './project.js'; import { MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO } from './supportsPrebuiltExpo.js'; @@ -87,14 +91,32 @@ function resolveExpoFrameworkSourcePath( throw new RockError(`Unsupported Expo XCFramework: ${frameworkName}`); } +function getPrebuiltExpoRemedy(projectRoot: string) { + const cause = + getExpoIosUsePrecompiledModules(projectRoot) === false + ? 'expo-build-properties sets ios.usePrecompiledModules to false, so prebuilt Expo XCFrameworks are never produced. ' + : ''; + return `${cause}Either re-run with --use-prebuilt-expo false to build Expo modules from source, or run \`pod install\` (with ios.usePrecompiledModules enabled) to materialize the prebuilts.`; +} + export function emitExpoSupportXcframeworks({ projectRoot, packageDir, usePrebuiltExpo = true, + usePrebuiltExpoExplicit = true, + onDegradeToSource, }: { projectRoot: string; packageDir: string; usePrebuiltExpo?: boolean; + /** + * Whether `usePrebuiltExpo` was chosen by the user (CLI flag or brownfield.config.json) + * rather than inferred from the Expo SDK version. When inferred, missing prebuilts + * degrade to building Expo modules from source instead of throwing. + */ + usePrebuiltExpoExplicit?: boolean; + /** Called when an inferred `usePrebuiltExpo` was degraded to `false` because prebuilts are absent */ + onDegradeToSource?: () => void; }) { if (!isExpoProject(projectRoot)) { return false; @@ -108,6 +130,22 @@ export function emitExpoSupportXcframeworks({ return false; } + if (usePrebuiltExpo && !usePrebuiltExpoExplicit) { + const missingPrebuilts = ALL_EXPO_SUPPORT_XCFRAMEWORK_NAMES.filter( + (frameworkName) => + !fs.existsSync(resolveExpoFrameworkSourcePath(projectRoot, frameworkName)) + ); + // ExpoModulesJSI ships in node_modules and is not a Pods prebuilt; only degrade + // when Pods-provided prebuilts are absent. + if (missingPrebuilts.some((name) => name !== 'ExpoModulesJSI')) { + logger.warn( + `Prebuilt Expo XCFrameworks were not found (${missingPrebuilts.join(', ')}). ${getPrebuiltExpoRemedy(projectRoot)} Continuing without prebuilt Expo (--use-prebuilt-expo false); pass --use-prebuilt-expo true to make this an error.` + ); + usePrebuiltExpo = false; + onDegradeToSource?.(); + } + } + for (const frameworkName of getExpoSupportXcframeworkNames(usePrebuiltExpo)) { const sourcePath = resolveExpoFrameworkSourcePath( projectRoot, @@ -115,7 +153,7 @@ export function emitExpoSupportXcframeworks({ ); if (!fs.existsSync(sourcePath)) { throw new RockError( - `Expected Expo SDK ${MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO}+ XCFramework not found: ${frameworkName}.xcframework at ${path.relative(projectRoot, sourcePath)}` + `Expected Expo SDK ${MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO}+ XCFramework not found: ${frameworkName}.xcframework at ${path.relative(projectRoot, sourcePath)}. ${getPrebuiltExpoRemedy(projectRoot)}` ); } diff --git a/packages/cli/src/brownfield/utils/project.ts b/packages/cli/src/brownfield/utils/project.ts index 506e77e2..19aafc50 100644 --- a/packages/cli/src/brownfield/utils/project.ts +++ b/packages/cli/src/brownfield/utils/project.ts @@ -81,6 +81,71 @@ export function isExpoProject(projectRoot: string): boolean { return hasExpoAppConfig(projectRoot) && projectDependsOnExpo(projectRoot); } +type ExpoPluginEntry = string | [string, ...unknown[]] | unknown; + +/** + * Reads `ios.usePrecompiledModules` from the `expo-build-properties` plugin entry. + * + * `getExpoConfigIfIsExpo` uses `skipPlugins: true`, which makes `@expo/config` + * delete `exp.plugins`, so the plugins array is read from `rootConfig` (the raw + * static app.json / app.config.json) instead. `rootConfig` is the raw *static* + * config only, so a dynamic config (app.config.js/ts) exposes no plugins that + * way; for those we retry once with plugins enabled, which keeps the plugins + * array. That retry can throw for plugins that are not loadable as Node modules + * (the reason `skipPlugins` is set in the first place), so it is best-effort. + * + * @returns the explicit boolean value, or `undefined` if it is not set or not resolvable + */ +export function getExpoIosUsePrecompiledModules( + projectRoot: string +): boolean | undefined { + const config = getExpoConfigIfIsExpo(projectRoot); + if (!config) { + return undefined; + } + + const rootConfig = config.rootConfig as + | { plugins?: ExpoPluginEntry[]; expo?: { plugins?: ExpoPluginEntry[] } } + | undefined; + let plugins = + (config.exp as { plugins?: ExpoPluginEntry[] }).plugins ?? + rootConfig?.expo?.plugins ?? + rootConfig?.plugins; + + if (!Array.isArray(plugins)) { + // Dynamic config: no static plugins array exists. Retry with plugins + // enabled, which preserves `exp.plugins`. Best-effort — plugin resolution + // can throw, and an unreadable config is treated the same as an unset value. + try { + plugins = ( + getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp as { + plugins?: ExpoPluginEntry[]; + } + ).plugins; + } catch { + return undefined; + } + } + + if (!Array.isArray(plugins)) { + return undefined; + } + + for (const plugin of plugins) { + if (!Array.isArray(plugin) || plugin[0] !== 'expo-build-properties') { + continue; + } + const value = ( + plugin[1] as { ios?: { usePrecompiledModules?: unknown } } | undefined + )?.ios?.usePrecompiledModules; + if (typeof value === 'boolean') { + return value; + } + } + + return undefined; +} + export function getExpoSdkMajor(projectRoot: string): number | null { const rawExpoVersion = getExpoConfigIfIsExpo(projectRoot)?.exp.sdkVersion; if (!rawExpoVersion) { diff --git a/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts b/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts index 4213742f..29083683 100644 --- a/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts +++ b/packages/cli/src/brownfield/utils/resolvePackagedFrameworkName.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import { ALL_APPLE_SDKS, type AppleSdk } from './destinationSdks.js'; + type Resolution = 'explicit' | 'detected' | 'not_found' | 'ambiguous'; export interface ResolvePackagedFrameworkNameResult { @@ -13,6 +15,8 @@ interface ResolvePackagedFrameworkNameOptions { explicitScheme?: string; productsPath: string; configuration: string; + /** Slices the build produced; defaults to both. */ + sdks?: AppleSdk[]; } function collectFrameworkCandidates(configurationProductsPath: string): string[] { @@ -61,6 +65,7 @@ export function resolvePackagedFrameworkName({ explicitScheme, productsPath, configuration, + sdks = ALL_APPLE_SDKS, }: ResolvePackagedFrameworkNameOptions): ResolvePackagedFrameworkNameResult { if (explicitScheme) { return { @@ -69,11 +74,17 @@ export function resolvePackagedFrameworkName({ }; } - const configurationProductsPath = path.join( - productsPath, - `${configuration}-iphoneos` - ); - const candidates = collectFrameworkCandidates(configurationProductsPath); + // the JS bundle is emitted per slice, so scan every slice that was built rather + // than assuming the device one exists (`--destination simulator` skips it) + const candidates = [ + ...new Set( + sdks.flatMap((sdk) => + collectFrameworkCandidates( + path.join(productsPath, `${configuration}-${sdk}`) + ) + ) + ), + ].sort(); if (candidates.length === 1) { return { diff --git a/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts b/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts index 8fd9eb7a..0ea78d33 100644 --- a/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts +++ b/packages/cli/src/brownfield/utils/supportsPrebuiltExpo.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { getReactNativeVersion } from '@rock-js/tools'; -import { getExpoSdkMajor, isExpoProject } from './project.js'; +import { getExpoIosUsePrecompiledModules, getExpoSdkMajor, isExpoProject } from './project.js'; export const MIN_EXPO_SDK_MAJOR_FOR_PREBUILT_EXPO = 56; @@ -94,6 +94,14 @@ export function supportsPrebuiltExpo({ }; } + if (getExpoIosUsePrecompiledModules(projectRoot) === false) { + return { + supported: false, + reason: + "--use-prebuilt-expo is unsupported: expo-build-properties sets ios.usePrecompiledModules to false in your Expo config, so prebuilt Expo XCFrameworks are never produced. Set it to true (or remove it) and run 'pod install', or pass --use-prebuilt-expo false to build Expo modules from source.", + }; + } + return { supported: true, enabledByDefault: true, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 867a3dbb..b0d28667 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,18 @@ import navigationCommands, { groupName as navigationCommandsGroupName, } from './navigation/index.js'; +// CocoaPods (spawned by Rock) raises Encoding::CompatibilityError under a non-UTF-8 +// locale. Ruby resolves the character encoding as LC_ALL > LC_CTYPE > LANG, so check +// them in that precedence. Default to UTF-8 only when the effective value is missing +// or non-UTF-8. +const inheritedCharset = + process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || ''; +if (!/utf-?8/i.test(inheritedCharset)) { + process.env.LANG = 'en_US.UTF-8'; + process.env.LC_CTYPE = 'en_US.UTF-8'; + process.env.LC_ALL = 'en_US.UTF-8'; +} + const program = new Command(); program diff --git a/yarn.lock b/yarn.lock index 546eede0..79c14bdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1840,11 +1840,11 @@ __metadata: "@react-native-community/cli-types": "npm:^20.0.0" "@react-native/babel-preset": "npm:0.82.1" "@react-native/eslint-config": "npm:0.82.1" - "@rock-js/platform-android": "npm:^0.14.0" - "@rock-js/platform-apple-helpers": "npm:^0.14.0" - "@rock-js/plugin-brownfield-android": "npm:^0.14.0" - "@rock-js/plugin-brownfield-ios": "npm:^0.14.0" - "@rock-js/tools": "npm:^0.14.0" + "@rock-js/platform-android": "npm:^0.15.4" + "@rock-js/platform-apple-helpers": "npm:^0.15.4" + "@rock-js/plugin-brownfield-android": "npm:^0.15.4" + "@rock-js/plugin-brownfield-ios": "npm:^0.15.4" + "@rock-js/tools": "npm:^0.15.4" "@types/babel__core": "npm:^7.20.5" "@types/babel__preset-env": "npm:^7.10.0" "@types/node": "npm:^25.5.0" @@ -6503,53 +6503,53 @@ __metadata: languageName: node linkType: hard -"@rock-js/platform-android@npm:^0.14.0": - version: 0.14.0 - resolution: "@rock-js/platform-android@npm:0.14.0" +"@rock-js/platform-android@npm:^0.15.4": + version: 0.15.4 + resolution: "@rock-js/platform-android@npm:0.15.4" dependencies: "@react-native-community/cli-config-android": "npm:^20.0.0" - "@rock-js/tools": "npm:^0.14.0" + "@rock-js/tools": "npm:^0.15.4" tslib: "npm:^2.3.0" - checksum: 10/366a10cd0157fa75190e311196398813a4c20e647007421cce4e6734dbc68b790b74e9882b796d5d55d6fcabb9f687873c78c5f56415fc962967a3a7e6299a29 + checksum: 10/f90964023a231c53e20e7f950e52a69af578f0dd57132bed663c6afb2588aef48c35174981ec53dd7d70778e2be01d6cc5bb2a01d6b5e44dbae05d3c81d408e0 languageName: node linkType: hard -"@rock-js/platform-apple-helpers@npm:^0.14.0": - version: 0.14.0 - resolution: "@rock-js/platform-apple-helpers@npm:0.14.0" +"@rock-js/platform-apple-helpers@npm:^0.15.4": + version: 0.15.4 + resolution: "@rock-js/platform-apple-helpers@npm:0.15.4" dependencies: "@react-native-community/cli-config": "npm:^20.0.0" "@react-native-community/cli-config-apple": "npm:^20.0.0" - "@rock-js/tools": "npm:^0.14.0" + "@rock-js/tools": "npm:^0.15.4" adm-zip: "npm:^0.5.16" fast-xml-parser: "npm:^4.5.0" tslib: "npm:^2.3.0" - checksum: 10/442a90e6c6a064e9bac2ea68ae447734890734a26242bcab36c46ff11591d954cbe89cc0de62b4feec6da8bab13a3cbfca40b54fb7017349ef7403ddd086e55e + checksum: 10/dcfc85d39f28ed691b7737e08f794f325f457a53123597b4f54092438b89fa50d104e13cd8894b45ae7182a4a723a5e880d6c4ba0ad985492a55429f04b2ec9e languageName: node linkType: hard -"@rock-js/plugin-brownfield-android@npm:^0.14.0": - version: 0.14.0 - resolution: "@rock-js/plugin-brownfield-android@npm:0.14.0" +"@rock-js/plugin-brownfield-android@npm:^0.15.4": + version: 0.15.4 + resolution: "@rock-js/plugin-brownfield-android@npm:0.15.4" dependencies: "@react-native-community/cli-config-android": "npm:^20.0.0" - "@rock-js/platform-android": "npm:^0.14.0" - "@rock-js/tools": "npm:^0.14.0" + "@rock-js/platform-android": "npm:^0.15.4" + "@rock-js/tools": "npm:^0.15.4" tslib: "npm:^2.3.0" - checksum: 10/5c22ffd3da57b5107ec19fa9909f26d936e0719f7774955553dbc0d3260fc46ae0b9eb085d215b3994d52ccb90afd76e943691438fdb795df6a4ffc215aabae9 + checksum: 10/8554ac5ad0671ac4c0a0953146509ddb0971bf6246a309ad084a2d9178733fa53adf5ef64a650a110f8643014d873997009fec9802925ef1ca0fe12b56e1bcbb languageName: node linkType: hard -"@rock-js/plugin-brownfield-ios@npm:^0.14.0": - version: 0.14.0 - resolution: "@rock-js/plugin-brownfield-ios@npm:0.14.0" +"@rock-js/plugin-brownfield-ios@npm:^0.15.4": + version: 0.15.4 + resolution: "@rock-js/plugin-brownfield-ios@npm:0.15.4" dependencies: "@react-native-community/cli-config-apple": "npm:^20.0.0" "@react-native-community/cli-types": "npm:^20.0.0" - "@rock-js/platform-apple-helpers": "npm:^0.14.0" - "@rock-js/tools": "npm:^0.14.0" + "@rock-js/platform-apple-helpers": "npm:^0.15.4" + "@rock-js/tools": "npm:^0.15.4" tslib: "npm:^2.3.0" - checksum: 10/7ec4634d63d7b7913ed08e078dc52546cba08b176de864611cf36548b86ed4d7a67770f26b941816173e3cc5636114bbb1233a43652995ed18d186eebecc1aae + checksum: 10/664ff2aa683032048a4e83fee9ff472a849e6fa4c7daab1b1bddc28447b5ba05e2c46e5c2d67c21bcd4605ad980883d2ab2b1a69e2bd65f552014c9833bde7d4 languageName: node linkType: hard @@ -6571,9 +6571,9 @@ __metadata: languageName: node linkType: hard -"@rock-js/tools@npm:^0.14.0": - version: 0.14.0 - resolution: "@rock-js/tools@npm:0.14.0" +"@rock-js/tools@npm:^0.15.4": + version: 0.15.4 + resolution: "@rock-js/tools@npm:0.15.4" dependencies: "@clack/prompts": "npm:^0.11.0" adm-zip: "npm:^0.5.16" @@ -6585,7 +6585,7 @@ __metadata: string-argv: "npm:^0.3.2" tar: "npm:^7.5.1" tslib: "npm:^2.3.0" - checksum: 10/f1aa44e7f98379a40f5deeb004ab9c0c683ed43fd66042fcac5ab124b47b8cb2a1fa2e8fa156cf8443441f2ef07aad7837f2db2860e839cbd32d2c82252ad74b + checksum: 10/e3eed2cbf7e8da65cb2ca760619208822535193a6afcc00033b966df533954dff8639430256375626c5aeac0911fb2297c63976e377c219e5e8d8f05e42e59b0 languageName: node linkType: hard