diff --git a/packages/twenty-sdk/src/cli/__tests__/bundle/__integration__/cli-esm-bundle-startup.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/bundle/__integration__/cli-esm-bundle-startup.integration.spec.ts new file mode 100644 index 0000000000..946710d27b --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/bundle/__integration__/cli-esm-bundle-startup.integration.spec.ts @@ -0,0 +1,36 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(currentDir, '../../../../..'); +const bundledCliEntry = resolve(packageRoot, 'dist/cli.mjs'); +const isBundleBuilt = existsSync(bundledCliEntry); + +describe('CLI ESM bundle startup', () => { + const runOrSkip = isBundleBuilt || process.env.CI ? it : it.skip; + + runOrSkip( + 'runs `--help` from the bundled .mjs entry without a CJS-in-ESM crash', + () => { + if (!isBundleBuilt) { + throw new Error( + `Expected the built ESM entry at ${bundledCliEntry}. Run \`npx nx build twenty-sdk\` before this test.`, + ); + } + + const result = spawnSync('node', [bundledCliEntry, '--help'], { + encoding: 'utf8', + timeout: 30_000, + }); + + const combinedOutput = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + + expect(combinedOutput).not.toMatch(/Calling `require` for/); + expect(combinedOutput).not.toMatch(/is not defined in ES module scope/); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Usage: twenty'); + }, + ); +}); diff --git a/packages/twenty-sdk/vite.config.node.ts b/packages/twenty-sdk/vite.config.node.ts index c2e62939bd..887d82131b 100644 --- a/packages/twenty-sdk/vite.config.node.ts +++ b/packages/twenty-sdk/vite.config.node.ts @@ -5,6 +5,28 @@ import { defineConfig } from 'vite'; import packageJson from './package.json'; +// Injected at the top of every ESM chunk so bundled CommonJS modules keep +// working after rolldown inlines them into the `.mjs` output. They call +// `require(...)` and read `__filename` / `__dirname` at runtime — none of which +// exist in an ES module — so we recreate them from `import.meta.url`. Without +// this the CLI crashes on startup ("Calling `require` for \"fs\" ...", +// "__filename is not defined"). The CJS output already provides all three. +// Aliased imports avoid colliding with bindings that already exist in a chunk. +// +// Note: __filename / __dirname resolve to the emitted chunk's own location +// (dist/), not each inlined module's original node_modules path — so a bundled +// dependency that reads __dirname to locate sibling files points at dist/. +// esbuild's CJS-globals shim had the same limitation, so this is not a +// regression; our own __dirname usage already targets dist/ intentionally. +const esmNodeGlobalsBanner = [ + "import { createRequire as __twentyCreateRequire } from 'node:module';", + "import { fileURLToPath as __twentyFileURLToPath } from 'node:url';", + "import { dirname as __twentyDirname } from 'node:path';", + 'const require = __twentyCreateRequire(import.meta.url);', + 'const __filename = __twentyFileURLToPath(import.meta.url);', + 'const __dirname = __twentyDirname(__filename);', +].join('\n'); + const copyCoverAssetsPlugin = () => ({ name: 'copy-cover-assets', closeBundle() { @@ -75,6 +97,7 @@ export default defineConfig(() => { { format: 'es' as const, entryFileNames: '[name].mjs', + banner: esmNodeGlobalsBanner, }, { format: 'cjs' as const,