diff --git a/packages/cli/build.ts b/packages/cli/build.ts index c595cadc7d..93fb3128e0 100644 --- a/packages/cli/build.ts +++ b/packages/cli/build.ts @@ -7,8 +7,9 @@ * 3. buildNapiBinding() - Builds the native Rust binding via NAPI * 4. syncCorePackageExports() - Creates shim files to re-export from @voidzero-dev/vite-plus-core * 5. syncTestPackageExports() - Creates shim files to re-export from @voidzero-dev/vite-plus-test - * 6. copySkillDocs() - Copies docs into skills/vite-plus/docs for runtime MCP access - * 7. syncReadmeFromRoot() - Keeps package README in sync + * 6. syncVersionsExport() - Generates ./versions module with bundled tool versions + * 7. copySkillDocs() - Copies docs into skills/vite-plus/docs for runtime MCP access + * 8. syncReadmeFromRoot() - Keeps package README in sync * * The sync functions allow this package to be a drop-in replacement for 'vite' by * re-exporting all the same subpaths (./client, ./types/*, etc.) while delegating @@ -38,6 +39,8 @@ import { } from 'typescript'; import { generateLicenseFile } from '../../scripts/generate-license.ts'; +import corePkg from '../core/package.json' with { type: 'json' }; +import testPkg from '../test/package.json' with { type: 'json' }; const projectDir = dirname(fileURLToPath(import.meta.url)); const TEST_PACKAGE_NAME = '@voidzero-dev/vite-plus-test'; @@ -80,6 +83,7 @@ if (!skipNative) { if (!skipTs) { await syncCorePackageExports(); await syncTestPackageExports(); + await syncVersionsExport(); } await copySkillDocs(); await syncReadmeFromRoot(); @@ -425,6 +429,75 @@ async function syncTestPackageExports() { console.log(`\nSynced ${Object.keys(generatedExports).length} exports from test package`); } +/** + * Read version from a dependency's package.json in node_modules. + * Uses readFile because these packages don't export ./package.json. + * + * TODO: Once https://github.com/oxc-project/oxc/pull/20784 lands and oxlint/oxfmt/oxlint-tsgolint + * export ./package.json, this function can be removed and replaced with static imports: + * ```js + * import oxlintPkg from 'oxlint/package.json' with { type: 'json' }; + * import oxfmtPkg from 'oxfmt/package.json' with { type: 'json' }; + * import oxlintTsgolintPkg from 'oxlint-tsgolint/package.json' with { type: 'json' }; + * ``` + */ +async function readDepVersion(packageName: string): Promise { + try { + const pkgPath = join(projectDir, 'node_modules', packageName, 'package.json'); + const pkg = JSON.parse(await readFile(pkgPath, 'utf-8')); + return pkg.version ?? null; + } catch { + return null; + } +} + +/** + * Generate ./versions export module with bundled tool versions. + * + * Collects versions from: + * - core/test package.json bundledVersions (vite, rolldown, tsdown, vitest) + * - CLI dependency package.json (oxlint, oxfmt, oxlint-tsgolint) + * + * Generates dist/versions.js and dist/versions.d.ts with inlined constants. + */ +async function syncVersionsExport() { + console.log('\nSyncing versions export...'); + const distDir = join(projectDir, 'dist'); + + // Collect versions from bundledVersions (core + test) + const versions: Record = { + ...(corePkg as Record).bundledVersions, + ...(testPkg as Record).bundledVersions, + }; + + // Collect versions from CLI dependencies (oxlint, oxfmt, oxlint-tsgolint) + // These don't export ./package.json, so we read from node_modules directly + const depTools = ['oxlint', 'oxfmt', 'oxlint-tsgolint'] as const; + for (const name of depTools) { + const version = await readDepVersion(name); + if (version) { + versions[name] = version; + } + } + + // dist/versions.js — inlined constants (no runtime I/O) + await writeFile( + join(distDir, 'versions.js'), + `export const versions = ${JSON.stringify(versions, null, 2)};\n`, + ); + + // dist/versions.d.ts — type declarations + const typeFields = Object.keys(versions) + .map((k) => ` readonly '${k}': string;`) + .join('\n'); + await writeFile( + join(distDir, 'versions.d.ts'), + `export declare const versions: {\n${typeFields}\n};\n`, + ); + + console.log(` Created ./versions (${Object.keys(versions).length} tools)`); +} + /** * Copy markdown doc files from the monorepo docs/ directory into skills/vite-plus/docs/, * preserving the relative directory structure. This keeps stable file paths for diff --git a/packages/cli/package.json b/packages/cli/package.json index ef4164a464..b1b50e298e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -75,6 +75,10 @@ "types": "./dist/pack.d.ts", "import": "./dist/pack.js" }, + "./versions": { + "types": "./dist/versions.d.ts", + "default": "./dist/versions.js" + }, "./test": { "import": { "types": "./dist/test/index.d.ts", diff --git a/packages/cli/src/__tests__/versions.spec.ts b/packages/cli/src/__tests__/versions.spec.ts new file mode 100644 index 0000000000..c8ef59dd9a --- /dev/null +++ b/packages/cli/src/__tests__/versions.spec.ts @@ -0,0 +1,130 @@ +/** + * Verify that the vite-plus/versions export works correctly. + * + * Tests run against the already-built dist/ directory, ensuring + * that syncVersionsExport() produces correct artifacts. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { describe, expect, it } from '@voidzero-dev/vite-plus-test'; + +const cliPkgDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '../..'); +const distDir = path.join(cliPkgDir, 'dist'); +const corePkgPath = path.join(cliPkgDir, '../core/package.json'); +const testPkgPath = path.join(cliPkgDir, '../test/package.json'); + +describe('versions export', () => { + describe('build artifacts', () => { + it('dist/versions.js should exist', () => { + expect(fs.existsSync(path.join(distDir, 'versions.js'))).toBe(true); + }); + + it('dist/versions.d.ts should exist', () => { + expect(fs.existsSync(path.join(distDir, 'versions.d.ts'))).toBe(true); + }); + + it('dist/versions.js should export a versions object', () => { + const content = fs.readFileSync(path.join(distDir, 'versions.js'), 'utf-8'); + expect(content).toContain('export const versions'); + }); + + it('dist/versions.d.ts should declare a versions type', () => { + const content = fs.readFileSync(path.join(distDir, 'versions.d.ts'), 'utf-8'); + expect(content).toContain('export declare const versions'); + }); + }); + + describe('bundledVersions consistency', () => { + it('should contain all core bundledVersions', async () => { + const corePkg = JSON.parse(fs.readFileSync(corePkgPath, 'utf-8')); + const mod = await import('../../dist/versions.js'); + const versions = mod.versions as Record; + for (const [key, value] of Object.entries( + corePkg.bundledVersions as Record, + )) { + expect(versions[key], `versions.${key} should match core bundledVersions`).toBe(value); + } + }); + + it('should contain all test bundledVersions', async () => { + const testPkg = JSON.parse(fs.readFileSync(testPkgPath, 'utf-8')); + const mod = await import('../../dist/versions.js'); + const versions = mod.versions as Record; + for (const [key, value] of Object.entries( + testPkg.bundledVersions as Record, + )) { + expect(versions[key], `versions.${key} should match test bundledVersions`).toBe(value); + } + }); + }); + + describe('dependency tool versions', () => { + it('should contain oxlint version', async () => { + const mod = await import('../../dist/versions.js'); + const versions = mod.versions as Record; + expect(versions.oxlint).toBeTypeOf('string'); + }); + + it('should contain oxfmt version', async () => { + const mod = await import('../../dist/versions.js'); + const versions = mod.versions as Record; + expect(versions.oxfmt).toBeTypeOf('string'); + }); + + it('should contain oxlint-tsgolint version', async () => { + const mod = await import('../../dist/versions.js'); + const versions = mod.versions as Record; + expect(versions['oxlint-tsgolint']).toBeTypeOf('string'); + }); + }); + + describe('type declarations', () => { + it('should have type fields for all bundled tools', () => { + const content = fs.readFileSync(path.join(distDir, 'versions.d.ts'), 'utf-8'); + const expectedKeys = [ + 'vite', + 'rolldown', + 'tsdown', + 'vitest', + 'oxlint', + 'oxfmt', + 'oxlint-tsgolint', + ]; + for (const key of expectedKeys) { + expect(content).toContain(key); + } + }); + + it('should declare all fields as readonly string', () => { + const content = fs.readFileSync(path.join(distDir, 'versions.d.ts'), 'utf-8'); + const fieldMatches = content.match(/readonly [\w'-]+: string;/g); + expect(fieldMatches).not.toBeNull(); + expect(fieldMatches!.length).toBeGreaterThanOrEqual(7); + }); + }); + + describe('runtime import', () => { + it('should be importable and return an object with expected keys', async () => { + const { versions } = await import('../../dist/versions.js'); + expect(versions).toBeDefined(); + expect(typeof versions).toBe('object'); + expect(versions.vite).toBeTypeOf('string'); + expect(versions.rolldown).toBeTypeOf('string'); + expect(versions.tsdown).toBeTypeOf('string'); + expect(versions.vitest).toBeTypeOf('string'); + expect(versions.oxlint).toBeTypeOf('string'); + expect(versions.oxfmt).toBeTypeOf('string'); + expect(versions['oxlint-tsgolint']).toBeTypeOf('string'); + }); + + it('should have valid semver-like versions', async () => { + const { versions } = await import('../../dist/versions.js'); + const semverPattern = /^\d+\.\d+\.\d+/; + for (const [key, value] of Object.entries(versions as Record)) { + expect(value, `${key} should be a valid version`).toMatch(semverPattern); + } + }); + }); +});