diff --git a/packages/cli-helpers/src/index.ts b/packages/cli-helpers/src/index.ts index 0a7450357603..04e8c7a96987 100644 --- a/packages/cli-helpers/src/index.ts +++ b/packages/cli-helpers/src/index.ts @@ -5,6 +5,7 @@ export * from './lib' export * from './lib/colors' export * from './lib/paths' export * from './lib/project' +export * from './lib/version' export * from './auth/setupHelpers' export * from './lib/installHelpers' diff --git a/packages/cli-helpers/src/lib/__tests__/version.test.ts b/packages/cli-helpers/src/lib/__tests__/version.test.ts new file mode 100644 index 000000000000..8e8872708ba2 --- /dev/null +++ b/packages/cli-helpers/src/lib/__tests__/version.test.ts @@ -0,0 +1,368 @@ +jest.mock('@redwoodjs/project-config', () => { + return { + getPaths: () => { + return { + base: '', + } + }, + } +}) +jest.mock('fs') + +import fs from 'fs' + +import { getCompatibilityData } from '../version' + +const EXAMPLE_PACKUMENT = { + _id: '@scope/package-name', + _rev: 'a1b2c3a1b2c3a1b2c3a1b2c3a1b2c3a1b2c3', + name: '@scope/package-name', + 'dist-tags': { + latest: '0.0.3', + }, + versions: { + '0.0.1': { + name: '@scope/package-name', + version: '0.0.1', + main: 'index.js', + scripts: { + test: 'echo "Error: no test specified" && exit 1', + }, + author: '', + license: 'ISC', + description: '', + dependencies: { + 'some-package': '1.2.3', + }, + _id: '@scope/package-name@0.0.1', + _nodeVersion: '18.16.0', + _npmVersion: '9.5.1', + dist: { + integrity: 'sha512-somehashvalue', + shasum: 'somehashvalue', + tarball: 'someurl', + fileCount: 8, + unpackedSize: 1024, + signatures: [ + { + keyid: 'SHA256:somehashvalue', + sig: 'somehashvalue', + }, + ], + }, + _npmUser: { + name: 'someuser', + email: 'someemail', + }, + directories: {}, + maintainers: [ + { + name: 'someuser', + email: 'someemail', + }, + ], + _npmOperationalInternal: { + host: 'somes3', + tmp: 'sometmp', + }, + _hasShrinkwrap: false, + }, + '0.0.2': { + name: '@scope/package-name', + version: '0.0.2', + main: 'index.js', + scripts: { + test: 'echo "Error: no test specified" && exit 1', + }, + author: '', + license: 'ISC', + description: '', + dependencies: { + 'some-package': '1.2.3', + }, + engines: { + redwoodjs: '^5.1.0', + }, + _id: '@scope/package-name@0.0.2', + _nodeVersion: '20.2.0', + _npmVersion: '9.6.6', + dist: { + integrity: 'sha512-somehashvalue', + shasum: 'somehashvalue', + tarball: 'someurl', + fileCount: 8, + unpackedSize: 1024, + signatures: [ + { + keyid: 'SHA256:somehashvalue', + sig: 'somehashvalue', + }, + ], + }, + _npmUser: { + name: 'someuser', + email: 'someemail', + }, + directories: {}, + maintainers: [ + { + name: 'someuser', + email: 'someemail', + }, + ], + _npmOperationalInternal: { + host: 'somes3', + tmp: 'sometmp', + }, + _hasShrinkwrap: false, + }, + '0.0.3': { + name: '@scope/package-name', + version: '0.0.3', + main: 'index.js', + scripts: { + test: 'echo "Error: no test specified" && exit 1', + }, + author: '', + license: 'ISC', + description: '', + dependencies: { + 'some-package': '1.2.3', + }, + engines: { + redwoodjs: '^6.0.0', + }, + _id: '@scope/package-name@0.0.3', + _nodeVersion: '20.2.0', + _npmVersion: '9.6.6', + dist: { + integrity: 'sha512-somehashvalue', + shasum: 'somehashvalue', + tarball: 'someurl', + fileCount: 8, + unpackedSize: 1024, + signatures: [ + { + keyid: 'SHA256:somehashvalue', + sig: 'somehashvalue', + }, + ], + }, + _npmUser: { + name: 'someuser', + email: 'someemail', + }, + directories: {}, + maintainers: [ + { + name: 'someuser', + email: 'someemail', + }, + ], + _npmOperationalInternal: { + host: 'somes3', + tmp: 'sometmp', + }, + _hasShrinkwrap: false, + }, + }, + time: { + created: '2023-05-10T12:10:52.090Z', + '0.0.1': '2023-05-10T12:10:52.344Z', + '0.0.2': '2023-07-15T19:45:25.905Z', + }, + maintainers: [ + { + name: 'someuser', + email: 'someemail', + }, + ], + license: 'ISC', + readme: 'ERROR: No README data found!', + readmeFilename: '', + author: { + name: 'someuser', + }, +} + +describe('version compatibility detection', () => { + beforeEach(() => { + jest.spyOn(global, 'fetch').mockImplementation(() => { + return { + json: () => { + return EXAMPLE_PACKUMENT + }, + } as any + }) + + jest.spyOn(fs, 'readFileSync').mockImplementation(() => { + return JSON.stringify({ + devDependencies: { + '@redwoodjs/core': '^6.0.0', + }, + }) + }) + }) + + test('throws for some fetch related error', async () => { + // Mock the fetch function to throw an error + jest.spyOn(global, 'fetch').mockImplementation(() => { + throw new Error('Some fetch related error') + }) + await expect( + getCompatibilityData('some-package', 'latest') + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Some fetch related error"`) + + // Mock the json parsing to throw an error + jest.spyOn(global, 'fetch').mockImplementation(() => { + return { + json: () => { + throw new Error('Some json parsing error') + }, + } as any + }) + + await expect( + getCompatibilityData('some-package', 'latest') + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Some json parsing error"`) + }) + + test('throws for some packument related error', async () => { + jest.spyOn(global, 'fetch').mockImplementation(() => { + return { + json: () => { + return { + error: 'Some packument related error', + } + }, + } as any + }) + + await expect( + getCompatibilityData('some-package', 'latest') + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Some packument related error"` + ) + }) + + test('throws if preferred version is not found', async () => { + await expect( + getCompatibilityData('@scope/package-name', '0.0.4') + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The package '@scope/package-name' does not have a version '0.0.4'"` + ) + }) + + test('throws if preferred tag is not found', async () => { + await expect( + getCompatibilityData('@scope/package-name', 'next') + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The package '@scope/package-name' does not have a tag 'next'"` + ) + }) + + test('throws if no redwoodjs engine is found', async () => { + await expect( + getCompatibilityData('@scope/package-name', '0.0.1') + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The package '@scope/package-name' does not specify a RedwoodJS compatibility version/range"` + ) + }) + + test('throws if no latest version could be found', async () => { + jest.spyOn(global, 'fetch').mockImplementation(() => { + return { + json: () => { + return { + ...EXAMPLE_PACKUMENT, + 'dist-tags': {}, + } + }, + } as any + }) + + await expect( + getCompatibilityData('@scope/package-name', 'latest') + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The package '@scope/package-name' does not have a tag 'latest'"` + ) + }) + + test('returns the preferred version if it is compatible', async () => { + expect(await getCompatibilityData('@scope/package-name', '0.0.3')).toEqual({ + preferred: { + tag: 'latest', + version: '0.0.3', + }, + compatible: { + tag: 'latest', + version: '0.0.3', + }, + }) + }) + + test('returns the latest compatible version if the preferred version is not compatible', async () => { + expect(await getCompatibilityData('@scope/package-name', '0.0.2')).toEqual({ + preferred: { + tag: undefined, + version: '0.0.2', + }, + compatible: { + tag: 'latest', + version: '0.0.3', + }, + }) + }) + + test('returns the latest compatible version when given a tag', async () => { + expect(await getCompatibilityData('@scope/package-name', 'latest')).toEqual( + { + preferred: { + tag: 'latest', + version: '0.0.3', + }, + compatible: { + tag: 'latest', + version: '0.0.3', + }, + } + ) + + jest.spyOn(fs, 'readFileSync').mockImplementation(() => { + return JSON.stringify({ + devDependencies: { + '@redwoodjs/core': '5.2.0', + }, + }) + }) + + expect(await getCompatibilityData('@scope/package-name', 'latest')).toEqual( + { + preferred: { + tag: 'latest', + version: '0.0.3', + }, + compatible: { + tag: undefined, + version: '0.0.2', + }, + } + ) + }) + + test('throws if no compatible version could be found', async () => { + jest.spyOn(fs, 'readFileSync').mockImplementation(() => { + return JSON.stringify({ + devDependencies: { + '@redwoodjs/core': '7.0.0', + }, + }) + }) + + expect( + getCompatibilityData('@scope/package-name', 'latest') + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"No compatible version of '@scope/package-name' was found"` + ) + }) +}) diff --git a/packages/cli-helpers/src/lib/version.ts b/packages/cli-helpers/src/lib/version.ts new file mode 100644 index 000000000000..1ba1a426a833 --- /dev/null +++ b/packages/cli-helpers/src/lib/version.ts @@ -0,0 +1,119 @@ +import fs from 'fs' +import path from 'path' + +import semver from 'semver' + +import { getPaths } from '@redwoodjs/project-config' + +function getCorrespondingTag( + version: string, + distTags: Record +) { + return Object.entries(distTags).find(([_, v]) => v === version)?.[0] +} + +// NOTE: This only considers the package's engines.redwoodjs field and does not consider the package's dependencies, +// devDependencies, or peerDependencies. +/** + * Check if the package at the given version is compatible with the current version of the user's RedwoodJS project. This is + * determined by checking if the package's engines.redwoodjs field intersects with the user's RedwoodJS version. + * + * If the preferred version is not compatible, the latest compatible version will be returned if one exists. + */ +export async function getCompatibilityData( + packageName: string, + preferredVersionOrTag: string +) { + // Get the project's version of RedwoodJS from the root package.json's @redwoodjs/core dev dependency + const projectPackageJson = JSON.parse( + fs.readFileSync(path.join(getPaths().base, 'package.json'), { + encoding: 'utf8', + }) + ) + const projectRedwoodVersion = + projectPackageJson.devDependencies['@redwoodjs/core'] + + // Parse the version, we'll assume it's a tag if it's not a valid semver version + const semverVersion = semver.parse(preferredVersionOrTag) + const isUsingTag = semverVersion === null + + // Get the package information from NPM registry + // Valid package names are URL safe so we can just slot it right in here + const res = await fetch(`https://registry.npmjs.org/${packageName}`) + const packument = await res.json() + + // Check if there was an error fetching the package's information + if (packument.error !== undefined) { + throw new Error(packument.error) + } + + // Check if the package has the requested version/tag + if (isUsingTag) { + if (packument['dist-tags'][preferredVersionOrTag] === undefined) { + throw new Error( + `The package '${packageName}' does not have a tag '${preferredVersionOrTag}'` + ) + } + } else { + if (packument.versions[preferredVersionOrTag] === undefined) { + throw new Error( + `The package '${packageName}' does not have a version '${preferredVersionOrTag}'` + ) + } + } + + // Determine the version to try to use, defaulting to the latest published version of the package + const preferredVersion: string = isUsingTag + ? packument['dist-tags'][preferredVersionOrTag] + : preferredVersionOrTag + + // Does that version of the package support the current version of RedwoodJS? + const packageRedwoodSpecification = + packument.versions[preferredVersion].engines?.redwoodjs + + if (packageRedwoodSpecification === undefined) { + throw new Error( + `The package '${packageName}' does not specify a RedwoodJS compatibility version/range` + ) + } + + // We have to use the semver.intersects function because the package's redwoodjs engine could be a range + if (semver.intersects(projectRedwoodVersion, packageRedwoodSpecification)) { + const tag = getCorrespondingTag(preferredVersion, packument['dist-tags']) + return { + preferred: { + tag, + version: preferredVersion, + }, + compatible: { + tag, + version: preferredVersion, + }, + } + } + + // Look in the pacument for the latest version that is compatible with the current version of RedwoodJS + const versions = semver.sort(Object.keys(packument.versions)) + for (let i = versions.length - 1; i >= 0; i--) { + const redwoodVersionRequired = + packument.versions[versions[i]].engines?.redwoodjs + if (redwoodVersionRequired === undefined) { + continue + } + if (semver.intersects(projectRedwoodVersion, redwoodVersionRequired)) { + return { + preferred: { + tag: getCorrespondingTag(preferredVersion, packument['dist-tags']), + version: preferredVersion, + }, + compatible: { + tag: getCorrespondingTag(versions[i], packument['dist-tags']), + version: versions[i], + }, + } + } + } + + // No compatible version was found + throw new Error(`No compatible version of '${packageName}' was found`) +} diff --git a/packages/cli/src/commands/setup/package/__tests__/packageHandler.test.js b/packages/cli/src/commands/setup/package/__tests__/packageHandler.test.js new file mode 100644 index 000000000000..0149b0cea909 --- /dev/null +++ b/packages/cli/src/commands/setup/package/__tests__/packageHandler.test.js @@ -0,0 +1,525 @@ +jest.mock('@redwoodjs/project-config', () => { + return { + getPaths: () => { + const path = require('path') + return { + base: path.join('mocked', 'project'), + } + }, + } +}) +jest.mock('@redwoodjs/cli-helpers', () => { + return { + getCompatibilityData: jest.fn(() => { + throw new Error('Mock Not Implemented') + }), + } +}) +jest.mock('fs') +jest.mock('execa', () => + jest.fn((cmd, params) => ({ + cmd, + params, + })) +) +jest.mock('enquirer', () => { + return { + Select: jest.fn(() => { + return { + run: jest.fn(() => { + throw new Error('Mock Not Implemented') + }), + } + }), + } +}) + +import fs from 'fs' +import path from 'path' + +import execa from 'execa' + +import { getCompatibilityData } from '@redwoodjs/cli-helpers' + +import { handler } from '../packageHandler' + +const { Select } = require('enquirer') + +describe('packageHandler', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}) + jest.spyOn(console, 'error').mockImplementation(() => {}) + + fs.__setMockFiles({ + ['package.json']: JSON.stringify({ + devDependencies: { + '@redwoodjs/core': '1.0.0', + }, + }), + }) + }) + + afterEach(() => { + fs.__setMockFiles({}) + jest.clearAllMocks() + }) + + test('using force does not check compatibility', async () => { + await handler({ + npmPackage: 'some-package', + force: true, + _: ['setup', 'package'], + }) + + expect(console.log).toHaveBeenCalledWith( + 'No compatibility check will be performed because you used the --force flag.' + ) + expect(getCompatibilityData).not.toHaveBeenCalled() + }) + + test('using force warns of experimental package if possible', async () => { + await handler({ + npmPackage: 'some-package', + force: true, + _: ['setup', 'package'], + }) + await handler({ + npmPackage: 'some-package@latest', + force: true, + _: ['setup', 'package'], + }) + expect(console.log).not.toHaveBeenCalledWith( + 'Be aware that this package is under version 1.0.0 and so should be considered experimental.' + ) + + await handler({ + npmPackage: 'some-package@0.0.1', + force: true, + _: ['setup', 'package'], + }) + expect(console.log).toHaveBeenCalledWith( + 'Be aware that this package is under version 1.0.0 and so should be considered experimental.' + ) + }) + + test('compatiblity check error prompts to continue', async () => { + getCompatibilityData.mockImplementation(() => { + throw new Error('No compatible version found') + }) + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'cancel'), + } + }) + await handler({ + npmPackage: 'some-package', + force: false, + _: ['setup', 'package'], + }) + expect(Select).toHaveBeenCalledTimes(1) + expect(execa).not.toHaveBeenCalled() + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'continue'), + } + }) + await handler({ + npmPackage: 'some-package', + force: false, + _: ['setup', 'package'], + }) + expect(Select).toHaveBeenCalledTimes(2) + expect(execa).toHaveBeenCalledWith('yarn', ['dlx', 'some-package@latest'], { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + }) + }) + + test('default of latest is compatible', async () => { + getCompatibilityData.mockImplementation(() => { + return { + preferred: { + version: '1.0.0', + tag: 'latest', + }, + compatible: { + version: '1.0.0', + tag: 'latest', + }, + } + }) + + await handler({ + npmPackage: 'some-package', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenCalledWith('some-package', 'latest') + expect(execa).toHaveBeenCalledWith('yarn', ['dlx', 'some-package@1.0.0'], { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + }) + }) + + test('default of latest is not compatible', async () => { + getCompatibilityData.mockImplementation(() => { + return { + preferred: { + version: '2.0.0', + tag: 'latest', + }, + compatible: { + version: '1.0.0', + tag: undefined, + }, + } + }) + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'useLatestCompatibleVersion'), + } + }) + await handler({ + npmPackage: 'some-package', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenNthCalledWith( + 1, + 'some-package', + 'latest' + ) + expect(Select).toHaveBeenCalledTimes(1) + expect(execa).toHaveBeenNthCalledWith( + 1, + 'yarn', + ['dlx', 'some-package@1.0.0'], + { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + } + ) + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'usePreferredVersion'), + } + }) + await handler({ + npmPackage: 'some-package', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenNthCalledWith( + 2, + 'some-package', + 'latest' + ) + expect(Select).toHaveBeenCalledTimes(2) + expect(execa).toHaveBeenNthCalledWith( + 2, + 'yarn', + ['dlx', 'some-package@2.0.0'], + { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + } + ) + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'cancel'), + } + }) + await handler({ + npmPackage: 'some-package', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenNthCalledWith( + 3, + 'some-package', + 'latest' + ) + expect(Select).toHaveBeenCalledTimes(3) + expect(execa).toBeCalledTimes(2) // Only called for the previous two select options + }) + + test('tag is compatible', async () => { + getCompatibilityData.mockImplementation(() => { + return { + preferred: { + version: '1.0.0', + tag: 'stable', + }, + compatible: { + version: '1.0.0', + tag: 'stable', + }, + } + }) + + await handler({ + npmPackage: 'some-package@stable', + force: false, + _: ['setup', 'package'], + }) + + expect(getCompatibilityData).toHaveBeenCalledWith('some-package', 'stable') + expect(execa).toHaveBeenCalledWith('yarn', ['dlx', 'some-package@1.0.0'], { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + }) + }) + + test('tag is not compatible', async () => { + getCompatibilityData.mockImplementation(() => { + return { + preferred: { + version: '2.0.0', + tag: 'stable', + }, + compatible: { + version: '1.0.0', + tag: undefined, + }, + } + }) + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'useLatestCompatibleVersion'), + } + }) + await handler({ + npmPackage: 'some-package@stable', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenNthCalledWith( + 1, + 'some-package', + 'stable' + ) + expect(Select).toHaveBeenCalledTimes(1) + expect(execa).toHaveBeenNthCalledWith( + 1, + 'yarn', + ['dlx', 'some-package@1.0.0'], + { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + } + ) + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'usePreferredVersion'), + } + }) + await handler({ + npmPackage: 'some-package@stable', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenNthCalledWith( + 2, + 'some-package', + 'stable' + ) + expect(Select).toHaveBeenCalledTimes(2) + expect(execa).toHaveBeenNthCalledWith( + 2, + 'yarn', + ['dlx', 'some-package@2.0.0'], + { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + } + ) + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'cancel'), + } + }) + await handler({ + npmPackage: 'some-package@stable', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenNthCalledWith( + 3, + 'some-package', + 'stable' + ) + expect(Select).toHaveBeenCalledTimes(3) + expect(execa).toBeCalledTimes(2) // Only called for the previous two select options + }) + + test('specific version is compatible', async () => { + getCompatibilityData.mockImplementation(() => { + return { + preferred: { + version: '1.0.0', + tag: 'latest', + }, + compatible: { + version: '1.0.0', + tag: 'latest', + }, + } + }) + + await handler({ + npmPackage: 'some-package@1.0.0', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenCalledWith('some-package', '1.0.0') + expect(execa).toHaveBeenCalledWith('yarn', ['dlx', 'some-package@1.0.0'], { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + }) + }) + + test('specific version is not compatible', async () => { + getCompatibilityData.mockImplementation(() => { + return { + preferred: { + version: '2.0.0', + tag: 'latest', + }, + compatible: { + version: '1.0.0', + tag: undefined, + }, + } + }) + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'useLatestCompatibleVersion'), + } + }) + await handler({ + npmPackage: 'some-package@1.0.0', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenNthCalledWith( + 1, + 'some-package', + '1.0.0' + ) + expect(Select).toHaveBeenCalledTimes(1) + expect(execa).toHaveBeenNthCalledWith( + 1, + 'yarn', + ['dlx', 'some-package@1.0.0'], + { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + } + ) + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'usePreferredVersion'), + } + }) + await handler({ + npmPackage: 'some-package@1.0.0', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenNthCalledWith( + 2, + 'some-package', + '1.0.0' + ) + expect(Select).toHaveBeenCalledTimes(2) + expect(execa).toHaveBeenNthCalledWith( + 2, + 'yarn', + ['dlx', 'some-package@2.0.0'], + { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + } + ) + + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'cancel'), + } + }) + await handler({ + npmPackage: 'some-package@1.0.0', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenNthCalledWith( + 3, + 'some-package', + '1.0.0' + ) + expect(Select).toHaveBeenCalledTimes(3) + expect(execa).toBeCalledTimes(2) // Only called for the previous two select options + }) + + test('specific version is experimental', async () => { + getCompatibilityData.mockImplementation(() => { + return { + preferred: { + version: '0.0.1', + tag: 'latest', + }, + compatible: { + version: '0.0.1', + tag: 'latest', + }, + } + }) + + // Force should just log to the console + await handler({ + npmPackage: 'some-package@0.0.1', + force: true, + _: ['setup', 'package'], + }) + expect(console.log).toHaveBeenCalledWith( + 'Be aware that this package is under version 1.0.0 and so should be considered experimental.' + ) + + // No force should prompt + Select.mockImplementation(() => { + return { + run: jest.fn(() => 'useLatestCompatibleVersion'), + } + }) + await handler({ + npmPackage: 'some-package@0.0.1', + force: false, + _: ['setup', 'package'], + }) + expect(getCompatibilityData).toHaveBeenNthCalledWith( + 1, + 'some-package', + '0.0.1' + ) + expect(Select).toHaveBeenCalledTimes(1) + expect(execa).toHaveBeenNthCalledWith( + 1, + 'yarn', + ['dlx', 'some-package@0.0.1'], + { + stdio: 'inherit', + cwd: path.join('mocked', 'project'), + } + ) + }) +}) diff --git a/packages/cli/src/commands/setup/package/package.js b/packages/cli/src/commands/setup/package/package.js new file mode 100644 index 000000000000..3736a31c41d6 --- /dev/null +++ b/packages/cli/src/commands/setup/package/package.js @@ -0,0 +1,38 @@ +import terminalLink from 'terminal-link' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' + +export const command = 'package ' +export const description = + 'Run a bin from an NPM package with version compatibility checks' + +export const builder = (yargs) => { + yargs + .positional('npm-package', { + description: + 'The NPM package to run. This can be a package name or a package name with a version or tag.', + type: 'string', + }) + .option('force', { + default: false, + description: + 'Proceed with a potentially incompatible version of the package', + type: 'boolean', + alias: 'f', + }) + .epilogue( + `Also see the ${terminalLink( + 'Redwood CLI Reference', + 'https://redwoodjs.com/docs/cli-commands#lint' + )}` + ) +} + +export const handler = async (options) => { + recordTelemetryAttributes({ + command: 'setup package', + }) + + const { handler } = await import('./packageHandler.js') + return handler(options) +} diff --git a/packages/cli/src/commands/setup/package/packageHandler.js b/packages/cli/src/commands/setup/package/packageHandler.js new file mode 100644 index 000000000000..8f802e407d98 --- /dev/null +++ b/packages/cli/src/commands/setup/package/packageHandler.js @@ -0,0 +1,176 @@ +import execa from 'execa' +import semver from 'semver' + +import { getCompatibilityData } from '@redwoodjs/cli-helpers' +import { getPaths } from '@redwoodjs/project-config' + +const { Select } = require('enquirer') + +// TODO: Yarn3 requirement? What do we do, just not run? I'm not sure about this one. +export async function handler({ npmPackage, force, _: _args }) { + // Extract package name and version which the user provided + const isScoped = npmPackage.startsWith('@') + const packageName = + (isScoped ? '@' : '') + npmPackage.split('@')[isScoped ? 1 : 0] + const packageVersion = npmPackage.split('@')[isScoped ? 2 : 1] ?? 'latest' + + // Extract any additional arguments that came after a '--' + // See: https://github.com/yargs/yargs/blob/main/docs/tricks.md#stop-parsing + const additionalOptionsToForward = _args.slice(2) ?? [] + console.log({ + additionalOptionsToForward, + }) + + // If we're using force don't attempt anything fancy, just run the package after some messaging + if (force) { + console.log( + 'No compatibility check will be performed because you used the --force flag.' + ) + if ( + semver.parse(packageVersion) !== null && + semver.lt(packageVersion, '1.0.0') + ) { + console.log( + 'Be aware that this package is under version 1.0.0 and so should be considered experimental.' + ) + } + await runPackage(packageName, packageVersion, additionalOptionsToForward) + return + } + + console.log('Checking compatibility...') + let compatibilityData + try { + compatibilityData = await getCompatibilityData(packageName, packageVersion) + } catch (error) { + console.log('The following error occurred while checking compatibility:') + const errorMessage = error.message ?? error + console.log(errorMessage) + + // Exit without a chance to continue if it makes sense to do so + if ( + errorMessage.includes('does not have a tag') || + errorMessage.includes('does not have a version') + ) { + process.exit(1) + } + + const decision = await promptWithChoices('What would you like to do?', [ + { + name: 'cancel', + message: 'Cancel', + }, + { + name: 'continue', + message: 'Continue regardless of potential incompatibility', + }, + ]) + if (decision === 'continue') { + await runPackage(packageName, packageVersion, additionalOptionsToForward) + } + return + } + + const { preferred, compatible } = compatibilityData + const preferredVersionIsCompatible = preferred.version === compatible.version + + if (preferredVersionIsCompatible) { + await showExperimentalWarning(preferred.version) + await runPackage(packageName, preferred.version, additionalOptionsToForward) + return + } + + const preferredVersionText = `${preferred.version}${ + preferred.tag ? ` (${preferred.tag})` : '' + }` + const latestCompatibleVersionText = `${compatible.version}${ + compatible.tag ? ` (${compatible.tag})` : '' + }` + console.log( + `The version ${preferredVersionText} of '${packageName}' is not compatible with your RedwoodJS project version.\nThe latest version compatible with your project is ${latestCompatibleVersionText}.` + ) + + const decision = await promptWithChoices('What would you like to do?', [ + { + name: 'useLatestCompatibleVersion', + message: `Use the latest compatible version: ${latestCompatibleVersionText}`, + }, + { + name: 'usePreferredVersion', + message: `Continue anyway with version: ${preferredVersionText}`, + }, + { + name: 'cancel', + message: 'Cancel', + }, + ]) + if (decision === 'cancel') { + process.exitCode = 1 + return + } + + const versionToUse = + decision === 'useLatestCompatibleVersion' + ? compatible.version + : preferred.version + await showExperimentalWarning(versionToUse) + await runPackage(packageName, versionToUse, additionalOptionsToForward) +} + +async function showExperimentalWarning(version) { + if ( + version === undefined || + semver.parse(version) === null || + semver.gte(version, '1.0.0') + ) { + return + } + + const decision = await promptWithChoices( + 'This package is under version 1.0.0 and so should be considered experimental. Would you like to continue?', + [ + { + name: 'yes', + message: 'Yes', + }, + { + name: 'no', + message: 'No', + }, + ] + ) + if (decision === 'no') { + process.exit() + } +} + +async function runPackage(packageName, version, options = []) { + const versionString = version === undefined ? '' : `@${version}` + console.log(`Running ${packageName}${versionString}...`) + try { + await execa('yarn', ['dlx', `${packageName}${versionString}`, ...options], { + stdio: 'inherit', + cwd: getPaths().base, + }) + } catch (error) { + // The execa process should have already printed any errors + process.exitCode = error.exitCode ?? 1 + } +} + +async function promptWithChoices(message, choices) { + try { + const prompt = new Select({ + name: message.substring(0, 8).toLowerCase(), + message, + choices, + }) + return await prompt.run() + } catch (error) { + // SIGINT seems to throw a "" error so we'll attempt to ignore that + if (error) { + throw error + } + } + return null +}