From 01e73d477c5ca30f32cb5141b18a39c35b339a7c Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Mon, 6 Jul 2026 16:00:48 +0100 Subject: [PATCH 1/4] Fix for issue #350 (dual ESM/CJS output so Node16 TypeScript consumers work) --- package.json | 9 +- scripts/package-exports.test.ts | 142 ++++++++++++++++++++++++++++++++ tsconfig.json | 1 + tsdown.config.ts | 2 +- 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 scripts/package-exports.test.ts diff --git a/package.json b/package.json index 5b009009..68991b1e 100644 --- a/package.json +++ b/package.json @@ -29,16 +29,19 @@ "!src/**/*.test.ts" ], "type": "module", - "main": "./dist/index.mjs", + "main": "./dist/index.cjs", "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", + "types": "./dist/index.d.cts", "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, "publishConfig": { "exports": { - ".": "./dist/index.mjs", + ".": { + "require": "./dist/index.cjs", + "import": "./dist/index.mjs" + }, "./package.json": "./package.json" } }, diff --git a/scripts/package-exports.test.ts b/scripts/package-exports.test.ts new file mode 100644 index 00000000..664753e6 --- /dev/null +++ b/scripts/package-exports.test.ts @@ -0,0 +1,142 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { publishConfig } from '../package.json'; + +const execFileAsync = promisify(execFile); + +const rootDir = path.resolve(import.meta.dirname, '..'); + +// Typed wider than the current package.json shape so that a malformed exports +// map surfaces as a test failure rather than a compile error in this file. +const rootExport: string | { require?: string; import?: string } = publishConfig.exports['.']; +const conditions = typeof rootExport === 'string' ? { import: rootExport } : rootExport; +const cjsEntry = conditions.require ? path.resolve(rootDir, conditions.require) : undefined; +const esmEntry = conditions.import ? path.resolve(rootDir, conditions.import) : undefined; + +const cjsReady = cjsEntry !== undefined && existsSync(cjsEntry); +const esmReady = esmEntry !== undefined && existsSync(esmEntry); + +const PUBLIC_API_SAMPLE = ['StackOneToolSet', 'BaseTool', 'StackOneError'] as const; + +const CONSUMER_SOURCE = [ + "import { StackOneToolSet } from '@stackone/ai';", + '', + "export const toolset = new StackOneToolSet({ apiKey: 'test' });", + '', +].join('\n'); + +/** + * Packs the project and unpacks it as `node_modules/@stackone/ai` of a minimal + * consumer project inside `tempDir`, returning the consumer directory. + * + * Packing goes through `pnpm pack` because it applies the publishConfig + * overrides (exports/main/types), producing the same package.json a consumer + * gets from the registry. Lifecycle scripts are disabled: the prepack script + * runs a full build, which must not happen inside the test. + */ +async function createConsumerFixture(tempDir: string): Promise { + await execFileAsync( + 'pnpm', + ['--config.ignore-scripts=true', 'pack', '--pack-destination', tempDir], + { cwd: rootDir }, + ); + const tarballName = (await readdir(tempDir)).find((file) => file.endsWith('.tgz')); + if (!tarballName) { + throw new Error('pnpm pack did not produce a tarball'); + } + await execFileAsync('tar', ['-xzf', tarballName], { cwd: tempDir }); + + const consumerDir = path.join(tempDir, 'consumer'); + const scopeDir = path.join(consumerDir, 'node_modules', '@stackone'); + await mkdir(scopeDir, { recursive: true }); + await rename(path.join(tempDir, 'package'), path.join(scopeDir, 'ai')); + + await writeFile( + path.join(consumerDir, 'package.json'), + JSON.stringify({ name: 'consumer', private: true }), + ); + await writeFile( + path.join(consumerDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'node16', + moduleResolution: 'node16', + strict: true, + noEmit: true, + skipLibCheck: true, + }, + }), + ); + // The .cts consumer exercises CommonJS resolution and the .mts consumer + // exercises ESM resolution under the Node16 tsconfig. + await writeFile(path.join(consumerDir, 'consumer.cts'), CONSUMER_SOURCE); + await writeFile(path.join(consumerDir, 'consumer.mts'), CONSUMER_SOURCE); + return consumerDir; +} + +/** + * Verifies the published package is consumable from both CommonJS and ESM, + * including TypeScript projects using Node16 module resolution. + * Regression coverage for https://github.com/StackOneHQ/stackone-ai-node/issues/350. + * + * Tests that exercise the build output are skipped when `dist/` is missing + * (run `pnpm build` first); CI always builds before testing. + */ +describe('published package output', () => { + it('declares require and import conditions for the root export', () => { + expect(conditions.require, 'publishConfig exports["."] must have a require condition').toMatch( + /\.cjs$/, + ); + expect(conditions.import, 'publishConfig exports["."] must have an import condition').toMatch( + /\.mjs$/, + ); + }); + + it.skipIf(!cjsReady)('exposes the public API from the CJS entry via require()', () => { + const requireFromTest = createRequire(import.meta.url); + const moduleExports = requireFromTest(String(cjsEntry)) as Record; + for (const exportName of PUBLIC_API_SAMPLE) { + expect(moduleExports[exportName], exportName).toBeTypeOf('function'); + } + }); + + it.skipIf(!esmReady)('exposes the public API from the ESM entry via import()', async () => { + const moduleExports = (await import(pathToFileURL(String(esmEntry)).href)) as Record< + string, + unknown + >; + for (const exportName of PUBLIC_API_SAMPLE) { + expect(moduleExports[exportName], exportName).toBeTypeOf('function'); + } + }); + + it.skipIf(!esmReady)( + 'type-checks for consumers using Node16 module resolution', + async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'stackone-node16-')); + try { + const consumerDir = await createConsumerFixture(tempDir); + try { + // The consumer fixture has no TypeScript install of its own, so tsc + // runs from the repository's pnpm context. + await execFileAsync('pnpm', ['exec', 'tsc', '-p', consumerDir], { cwd: rootDir }); + } catch (error) { + const execError = (error ?? {}) as { stdout?: unknown; stderr?: unknown }; + const details = + `${String(execError.stdout ?? '')}${String(execError.stderr ?? '')}`.trim() || + String(error); + throw new Error(`tsc failed for the Node16 consumer fixture:\n${details}`); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }, + 60_000, + ); +}); diff --git a/tsconfig.json b/tsconfig.json index ae1f0ce5..27e16215 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ /* Build for a library */ "module": "ESNext", "moduleResolution": "bundler", + "rootDir": ".", "outDir": "dist", "sourceMap": true, "declaration": true, diff --git a/tsdown.config.ts b/tsdown.config.ts index 1ba916c6..edb5ee5c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['./src/index.ts'], outDir: 'dist', - format: 'esm', + format: ['esm', 'cjs'], clean: true, sourcemap: true, treeshake: true, From a87f4554043761cceb5f1ce5d897ade159d96b92 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Mon, 6 Jul 2026 16:53:25 +0100 Subject: [PATCH 2/4] Address copilot and cubic comments --- scripts/package-exports.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/package-exports.test.ts b/scripts/package-exports.test.ts index 664753e6..bead4862 100644 --- a/scripts/package-exports.test.ts +++ b/scripts/package-exports.test.ts @@ -36,7 +36,7 @@ const CONSUMER_SOURCE = [ * consumer project inside `tempDir`, returning the consumer directory. * * Packing goes through `pnpm pack` because it applies the publishConfig - * overrides (exports/main/types), producing the same package.json a consumer + * override for the exports map, producing the same package.json a consumer * gets from the registry. Lifecycle scripts are disabled: the prepack script * runs a full build, which must not happen inside the test. */ From adce750399ea2f68cb8e481ad04c462ef8d54d41 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Mon, 6 Jul 2026 17:14:59 +0100 Subject: [PATCH 3/4] Fix CI issues --- scripts/package-exports.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/package-exports.test.ts b/scripts/package-exports.test.ts index bead4862..26deab69 100644 --- a/scripts/package-exports.test.ts +++ b/scripts/package-exports.test.ts @@ -127,10 +127,9 @@ describe('published package output', () => { // runs from the repository's pnpm context. await execFileAsync('pnpm', ['exec', 'tsc', '-p', consumerDir], { cwd: rootDir }); } catch (error) { - const execError = (error ?? {}) as { stdout?: unknown; stderr?: unknown }; + const execError = (error ?? {}) as { stdout?: string; stderr?: string }; const details = - `${String(execError.stdout ?? '')}${String(execError.stderr ?? '')}`.trim() || - String(error); + `${execError.stdout ?? ''}${execError.stderr ?? ''}`.trim() || String(error); throw new Error(`tsc failed for the Node16 consumer fixture:\n${details}`); } } finally { From 53beb9ac06849a2ae91e5366b595058c011188a7 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Tue, 7 Jul 2026 09:31:24 +0100 Subject: [PATCH 4/4] Addresss copilot comment regarding import.meta.dirname --- scripts/package-exports.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/package-exports.test.ts b/scripts/package-exports.test.ts index 26deab69..519a0d04 100644 --- a/scripts/package-exports.test.ts +++ b/scripts/package-exports.test.ts @@ -4,13 +4,13 @@ import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from 'node:fs/promises import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { publishConfig } from '../package.json'; const execFileAsync = promisify(execFile); -const rootDir = path.resolve(import.meta.dirname, '..'); +const rootDir = fileURLToPath(new URL('..', import.meta.url)); // Typed wider than the current package.json shape so that a malformed exports // map surfaces as a test failure rather than a compile error in this file.