From dc51610b28d3cbbb2deeb7beccefc1da7f0da74d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:56:08 +0000 Subject: [PATCH 1/3] Initial plan From 90b52c248257dd429232df65eaebdfb970341c72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:16:33 +0000 Subject: [PATCH 2/3] Add Node version detection and package.json patching for Atomic projects Co-authored-by: fbeaudoincoveo <23503066+fbeaudoincoveo@users.noreply.github.com> --- .../lib/atomic/createAtomicProject.spec.ts | 271 ++++++++++++++++++ .../src/lib/atomic/createAtomicProject.ts | 76 ++++- 2 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 packages/cli/core/src/lib/atomic/createAtomicProject.spec.ts 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..bb9aa8ac41 --- /dev/null +++ b/packages/cli/core/src/lib/atomic/createAtomicProject.spec.ts @@ -0,0 +1,271 @@ +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, + }); + }); + + 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 = JSON.parse( + mockedWriteFileSync.mock.calls[0][1].toString().trim() + ); + 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 = JSON.parse( + mockedWriteFileSync.mock.calls[0][1].toString().trim() + ); + 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 = JSON.parse( + mockedWriteFileSync.mock.calls[0][1].toString().trim() + ); + 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 = JSON.parse( + mockedWriteFileSync.mock.calls[0][1].toString().trim() + ); + 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..b68e5042b1 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,64 @@ 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'); +} + +/** + * 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/, + 'node --experimental-detect-module ./node_modules/.bin/stencil' + ); + } + + // 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/, + 'node --experimental-detect-module ./node_modules/.bin/stencil' + ); + } + + 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: ${error}`); + } +} + export async function createAtomicApp(options: CreateAppOptions) { const authenticatedClient = new AuthenticatedClient(); const platformClient = await authenticatedClient.getClient(); @@ -84,7 +143,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 { From baed2e20d51f57b239d095167015e57ad1696056 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:21:36 +0000 Subject: [PATCH 3/3] Address code review feedback - extract constant and improve error messages Co-authored-by: fbeaudoincoveo <23503066+fbeaudoincoveo@users.noreply.github.com> --- .../lib/atomic/createAtomicProject.spec.ts | 21 ++++++++----------- .../src/lib/atomic/createAtomicProject.ts | 12 ++++++++--- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/cli/core/src/lib/atomic/createAtomicProject.spec.ts b/packages/cli/core/src/lib/atomic/createAtomicProject.spec.ts index bb9aa8ac41..fb0da10799 100644 --- a/packages/cli/core/src/lib/atomic/createAtomicProject.spec.ts +++ b/packages/cli/core/src/lib/atomic/createAtomicProject.spec.ts @@ -73,6 +73,11 @@ describe('createAtomicProject', () => { }); }); + // 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 @@ -109,9 +114,7 @@ describe('createAtomicProject', () => { 'utf8' ); - const writtenContent = JSON.parse( - mockedWriteFileSync.mock.calls[0][1].toString().trim() - ); + const writtenContent = getWrittenPackageJson(); expect(writtenContent.scripts.start).toContain( '--experimental-detect-module' ); @@ -214,9 +217,7 @@ describe('createAtomicProject', () => { cfg: mockConfig, }); - const writtenContent = JSON.parse( - mockedWriteFileSync.mock.calls[0][1].toString().trim() - ); + const writtenContent = getWrittenPackageJson(); expect(writtenContent.scripts.start).toBe( 'node --experimental-detect-module ./node_modules/.bin/stencil build --dev --watch --serve' ); @@ -236,9 +237,7 @@ describe('createAtomicProject', () => { cfg: mockConfig, }); - const writtenContent = JSON.parse( - mockedWriteFileSync.mock.calls[0][1].toString().trim() - ); + const writtenContent = getWrittenPackageJson(); expect(writtenContent.scripts.build).toBe( 'node --experimental-detect-module ./node_modules/.bin/stencil build && node deployment.esbuild.mjs' ); @@ -260,9 +259,7 @@ describe('createAtomicProject', () => { cfg: mockConfig, }); - const writtenContent = JSON.parse( - mockedWriteFileSync.mock.calls[0][1].toString().trim() - ); + 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 b68e5042b1..098a96fc87 100644 --- a/packages/cli/core/src/lib/atomic/createAtomicProject.ts +++ b/packages/cli/core/src/lib/atomic/createAtomicProject.ts @@ -63,6 +63,10 @@ function shouldAddExperimentalDetectModuleFlag(): boolean { 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. @@ -82,7 +86,7 @@ function patchPackageJsonScripts(projectPath: string): void { ) { packageJson.scripts.start = packageJson.scripts.start.replace( /^\s*stencil/, - 'node --experimental-detect-module ./node_modules/.bin/stencil' + STENCIL_COMMAND_PREFIX ); } @@ -93,7 +97,7 @@ function patchPackageJsonScripts(projectPath: string): void { ) { packageJson.scripts.build = packageJson.scripts.build.replace( /^\s*stencil/, - 'node --experimental-detect-module ./node_modules/.bin/stencil' + STENCIL_COMMAND_PREFIX ); } @@ -106,7 +110,9 @@ function patchPackageJsonScripts(projectPath: string): void { } 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: ${error}`); + 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.` + ); } }