diff --git a/packages/cli/core/src/lib/atomic/createAtomicProject.spec.ts b/packages/cli/core/src/lib/atomic/createAtomicProject.spec.ts new file mode 100644 index 0000000000..fb0da10799 --- /dev/null +++ b/packages/cli/core/src/lib/atomic/createAtomicProject.spec.ts @@ -0,0 +1,268 @@ +jest.mock('node:fs'); +jest.mock('../utils/process'); +jest.mock('../utils/misc'); +jest.mock('@coveo/cli-commons/platform/authenticatedClient'); +jest.mock('@coveo/platform-client'); +jest.mock('../ui/shared'); + +import {readFileSync, writeFileSync} from 'node:fs'; +import {spawnProcess} from '../utils/process'; +import {createAtomicApp} from './createAtomicProject'; +import {Configuration} from '@coveo/cli-commons/config/config'; +import {AuthenticatedClient} from '@coveo/cli-commons/platform/authenticatedClient'; +import PlatformClient from '@coveo/platform-client'; +import {getPackageVersion} from '../utils/misc'; +import {promptForSearchHub} from '../ui/shared'; + +describe('createAtomicProject', () => { + const mockedReadFileSync = jest.mocked(readFileSync); + const mockedWriteFileSync = jest.mocked(writeFileSync); + const mockedSpawnProcess = jest.mocked(spawnProcess); + const mockedAuthenticatedClient = jest.mocked(AuthenticatedClient); + const mockedPlatformClient = jest.mocked(PlatformClient); + const mockedGetPackageVersion = jest.mocked(getPackageVersion); + const mockedPromptForSearchHub = jest.mocked(promptForSearchHub); + + const mockConfig: Configuration = { + organization: 'test-org', + accessToken: 'test-token', + environment: 'dev', + region: 'us', + } as Configuration; + + const originalNodeVersion = process.version; + + beforeEach(() => { + jest.clearAllMocks(); + + mockedGetPackageVersion.mockReturnValue('1.0.0'); + mockedSpawnProcess.mockResolvedValue(0); + mockedPromptForSearchHub.mockResolvedValue('default'); + + mockedPlatformClient.mockImplementation( + () => + ({ + initialize: () => Promise.resolve(), + search: { + listSearchHubs: () => + Promise.resolve([{id: 'default', name: 'Default'}]), + }, + } as unknown as PlatformClient) + ); + + mockedAuthenticatedClient.mockImplementation( + () => + ({ + getUsername: () => Promise.resolve('test@example.com'), + getClient: () => + Promise.resolve( + mockedPlatformClient.getMockImplementation()!({ + accessToken: 'test-token', + organizationId: 'test-org', + }) + ), + } as unknown as AuthenticatedClient) + ); + }); + + afterEach(() => { + // Restore original Node version + Object.defineProperty(process, 'version', { + value: originalNodeVersion, + writable: true, + }); + }); + + // Helper function to extract written package.json content from mock + const getWrittenPackageJson = () => { + return JSON.parse(mockedWriteFileSync.mock.calls[0][1].toString().trim()); + }; + + describe('when Node version is < 20.19.0', () => { + beforeEach(() => { + // Mock Node version to 20.18.0 + Object.defineProperty(process, 'version', { + value: 'v20.18.0', + writable: true, + }); + }); + + it('should patch package.json scripts when project creation succeeds', async () => { + const mockPackageJson = { + name: 'test-project', + scripts: { + start: 'stencil build --dev --watch --serve', + build: 'stencil build && node deployment.esbuild.mjs', + }, + }; + + mockedReadFileSync.mockReturnValue(JSON.stringify(mockPackageJson)); + + await createAtomicApp({ + projectName: 'test-project', + cfg: mockConfig, + }); + + expect(mockedReadFileSync).toHaveBeenCalledWith( + expect.stringContaining('test-project/package.json'), + 'utf8' + ); + + expect(mockedWriteFileSync).toHaveBeenCalledWith( + expect.stringContaining('test-project/package.json'), + expect.stringContaining('--experimental-detect-module'), + 'utf8' + ); + + const writtenContent = getWrittenPackageJson(); + expect(writtenContent.scripts.start).toContain( + '--experimental-detect-module' + ); + expect(writtenContent.scripts.build).toContain( + '--experimental-detect-module' + ); + }); + + it('should not patch package.json if project creation fails', async () => { + mockedSpawnProcess.mockResolvedValue(1); + + await createAtomicApp({ + projectName: 'test-project', + cfg: mockConfig, + }); + + expect(mockedReadFileSync).not.toHaveBeenCalled(); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully when patching fails', async () => { + mockedReadFileSync.mockImplementation(() => { + throw new Error('File not found'); + }); + + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + await createAtomicApp({ + projectName: 'test-project', + cfg: mockConfig, + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Could not modify package.json scripts') + ); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('when Node version is >= 20.19.0', () => { + beforeEach(() => { + // Mock Node version to 20.19.0 + Object.defineProperty(process, 'version', { + value: 'v20.19.0', + writable: true, + }); + }); + + it('should not patch package.json scripts', async () => { + await createAtomicApp({ + projectName: 'test-project', + cfg: mockConfig, + }); + + expect(mockedReadFileSync).not.toHaveBeenCalled(); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('when Node version is 22.x', () => { + beforeEach(() => { + // Mock Node version to 22.0.0 + Object.defineProperty(process, 'version', { + value: 'v22.0.0', + writable: true, + }); + }); + + it('should not patch package.json scripts', async () => { + await createAtomicApp({ + projectName: 'test-project', + cfg: mockConfig, + }); + + expect(mockedReadFileSync).not.toHaveBeenCalled(); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('package.json patching logic', () => { + beforeEach(() => { + Object.defineProperty(process, 'version', { + value: 'v20.18.0', + writable: true, + }); + }); + + it('should correctly replace stencil command in start script', async () => { + const mockPackageJson = { + scripts: { + start: 'stencil build --dev --watch --serve', + }, + }; + + mockedReadFileSync.mockReturnValue(JSON.stringify(mockPackageJson)); + + await createAtomicApp({ + projectName: 'test-project', + cfg: mockConfig, + }); + + const writtenContent = getWrittenPackageJson(); + expect(writtenContent.scripts.start).toBe( + 'node --experimental-detect-module ./node_modules/.bin/stencil build --dev --watch --serve' + ); + }); + + it('should correctly replace stencil command in build script', async () => { + const mockPackageJson = { + scripts: { + build: 'stencil build && node deployment.esbuild.mjs', + }, + }; + + mockedReadFileSync.mockReturnValue(JSON.stringify(mockPackageJson)); + + await createAtomicApp({ + projectName: 'test-project', + cfg: mockConfig, + }); + + const writtenContent = getWrittenPackageJson(); + expect(writtenContent.scripts.build).toBe( + 'node --experimental-detect-module ./node_modules/.bin/stencil build && node deployment.esbuild.mjs' + ); + }); + + it('should not modify scripts that do not contain stencil', async () => { + const mockPackageJson = { + scripts: { + start: 'echo "no stencil here"', + build: 'tsc', + test: 'jest', + }, + }; + + mockedReadFileSync.mockReturnValue(JSON.stringify(mockPackageJson)); + + await createAtomicApp({ + projectName: 'test-project', + cfg: mockConfig, + }); + + const writtenContent = getWrittenPackageJson(); + expect(writtenContent.scripts.start).toBe('echo "no stencil here"'); + expect(writtenContent.scripts.build).toBe('tsc'); + expect(writtenContent.scripts.test).toBe('jest'); + }); + }); +}); diff --git a/packages/cli/core/src/lib/atomic/createAtomicProject.ts b/packages/cli/core/src/lib/atomic/createAtomicProject.ts index cef6b2a883..098a96fc87 100644 --- a/packages/cli/core/src/lib/atomic/createAtomicProject.ts +++ b/packages/cli/core/src/lib/atomic/createAtomicProject.ts @@ -1,10 +1,10 @@ -import {mkdirSync} from 'node:fs'; -import {resolve} from 'node:path'; +import {mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {resolve, join} from 'node:path'; import {Configuration} from '@coveo/cli-commons/config/config'; import {AuthenticatedClient} from '@coveo/cli-commons/platform/authenticatedClient'; import {platformUrl} from '@coveo/cli-commons/platform/environment'; import {appendCmdIfWindows} from '@coveo/cli-commons/utils/os'; -import {handleForkedProcess, spawnProcess} from '../utils/process'; +import {spawnProcess} from '../utils/process'; import { IsAuthenticated, AuthenticationType, @@ -22,6 +22,7 @@ import { } from '../decorators/preconditions'; import {getPackageVersion} from '../utils/misc'; import {promptForSearchHub} from '../ui/shared'; +import {lt} from 'semver'; interface CreateAppOptions { initializerVersion?: string; @@ -51,6 +52,70 @@ export const atomicAppPreconditions = [ ), ]; +/** + * Checks if the current Node.js version requires the --experimental-detect-module flag. + * Node versions < 20.19.0 have stricter ES module handling that requires this flag + * when using Stencil with ES module packages. + */ +function shouldAddExperimentalDetectModuleFlag(): boolean { + const nodeVersion = process.version; + // Check if Node version is less than 20.19.0 + return lt(nodeVersion, '20.19.0'); +} + +// Command prefix to use for stencil when Node < 20.19.0 +const STENCIL_COMMAND_PREFIX = + 'node --experimental-detect-module ./node_modules/.bin/stencil'; + +/** + * Modifies the package.json scripts to add --experimental-detect-module flag + * to the build and start commands for Node versions < 20.19.0. + */ +function patchPackageJsonScripts(projectPath: string): void { + const packageJsonPath = join(projectPath, 'package.json'); + + try { + const packageJsonContent = readFileSync(packageJsonPath, 'utf8'); + const packageJson = JSON.parse(packageJsonContent); + + if (packageJson.scripts) { + // Update the start script - only if it starts with 'stencil' command + if ( + packageJson.scripts.start && + /^\s*stencil\b/.test(packageJson.scripts.start) + ) { + packageJson.scripts.start = packageJson.scripts.start.replace( + /^\s*stencil/, + STENCIL_COMMAND_PREFIX + ); + } + + // Update the build script - only if it starts with 'stencil' command + if ( + packageJson.scripts.build && + /^\s*stencil\b/.test(packageJson.scripts.build) + ) { + packageJson.scripts.build = packageJson.scripts.build.replace( + /^\s*stencil/, + STENCIL_COMMAND_PREFIX + ); + } + + writeFileSync( + packageJsonPath, + JSON.stringify(packageJson, null, 2) + '\n', + 'utf8' + ); + } + } catch (error) { + // If we can't modify the package.json, we'll just log a warning but not fail + // The user can manually add the flag if needed + console.warn( + `Warning: Could not modify package.json scripts for project '${projectPath}': ${error}. You may need to manually add '--experimental-detect-module' to your stencil commands.` + ); + } +} + export async function createAtomicApp(options: CreateAppOptions) { const authenticatedClient = new AuthenticatedClient(); const platformClient = await authenticatedClient.getClient(); @@ -84,7 +149,16 @@ export async function createAtomicApp(options: CreateAppOptions) { cliArgs.push('--page-id', options.pageId); } - return spawnProcess(appendCmdIfWindows`npx`, cliArgs); + const exitCode = await spawnProcess(appendCmdIfWindows`npx`, cliArgs); + + // If the project was created successfully and Node version < 20.19.0, + // patch the package.json to add the --experimental-detect-module flag + if (exitCode === 0 && shouldAddExperimentalDetectModuleFlag()) { + const projectPath = resolve(options.projectName); + patchPackageJsonScripts(projectPath); + } + + return exitCode; } interface CreateLibOptions {