Skip to content

Commit 2d26d64

Browse files
authored
feat(cli): expose bundled tool versions via vite-plus/versions export (#1162)
resolve #1149
1 parent 97c7571 commit 2d26d64

File tree

3 files changed

+209
-2
lines changed

3 files changed

+209
-2
lines changed

packages/cli/build.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
* 3. buildNapiBinding() - Builds the native Rust binding via NAPI
88
* 4. syncCorePackageExports() - Creates shim files to re-export from @voidzero-dev/vite-plus-core
99
* 5. syncTestPackageExports() - Creates shim files to re-export from @voidzero-dev/vite-plus-test
10-
* 6. copySkillDocs() - Copies docs into skills/vite-plus/docs for runtime MCP access
11-
* 7. syncReadmeFromRoot() - Keeps package README in sync
10+
* 6. syncVersionsExport() - Generates ./versions module with bundled tool versions
11+
* 7. copySkillDocs() - Copies docs into skills/vite-plus/docs for runtime MCP access
12+
* 8. syncReadmeFromRoot() - Keeps package README in sync
1213
*
1314
* The sync functions allow this package to be a drop-in replacement for 'vite' by
1415
* re-exporting all the same subpaths (./client, ./types/*, etc.) while delegating
@@ -38,6 +39,8 @@ import {
3839
} from 'typescript';
3940

4041
import { generateLicenseFile } from '../../scripts/generate-license.ts';
42+
import corePkg from '../core/package.json' with { type: 'json' };
43+
import testPkg from '../test/package.json' with { type: 'json' };
4144

4245
const projectDir = dirname(fileURLToPath(import.meta.url));
4346
const TEST_PACKAGE_NAME = '@voidzero-dev/vite-plus-test';
@@ -80,6 +83,7 @@ if (!skipNative) {
8083
if (!skipTs) {
8184
await syncCorePackageExports();
8285
await syncTestPackageExports();
86+
await syncVersionsExport();
8387
}
8488
await copySkillDocs();
8589
await syncReadmeFromRoot();
@@ -425,6 +429,75 @@ async function syncTestPackageExports() {
425429
console.log(`\nSynced ${Object.keys(generatedExports).length} exports from test package`);
426430
}
427431

432+
/**
433+
* Read version from a dependency's package.json in node_modules.
434+
* Uses readFile because these packages don't export ./package.json.
435+
*
436+
* TODO: Once https://github.com/oxc-project/oxc/pull/20784 lands and oxlint/oxfmt/oxlint-tsgolint
437+
* export ./package.json, this function can be removed and replaced with static imports:
438+
* ```js
439+
* import oxlintPkg from 'oxlint/package.json' with { type: 'json' };
440+
* import oxfmtPkg from 'oxfmt/package.json' with { type: 'json' };
441+
* import oxlintTsgolintPkg from 'oxlint-tsgolint/package.json' with { type: 'json' };
442+
* ```
443+
*/
444+
async function readDepVersion(packageName: string): Promise<string | null> {
445+
try {
446+
const pkgPath = join(projectDir, 'node_modules', packageName, 'package.json');
447+
const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));
448+
return pkg.version ?? null;
449+
} catch {
450+
return null;
451+
}
452+
}
453+
454+
/**
455+
* Generate ./versions export module with bundled tool versions.
456+
*
457+
* Collects versions from:
458+
* - core/test package.json bundledVersions (vite, rolldown, tsdown, vitest)
459+
* - CLI dependency package.json (oxlint, oxfmt, oxlint-tsgolint)
460+
*
461+
* Generates dist/versions.js and dist/versions.d.ts with inlined constants.
462+
*/
463+
async function syncVersionsExport() {
464+
console.log('\nSyncing versions export...');
465+
const distDir = join(projectDir, 'dist');
466+
467+
// Collect versions from bundledVersions (core + test)
468+
const versions: Record<string, string> = {
469+
...(corePkg as Record<string, any>).bundledVersions,
470+
...(testPkg as Record<string, any>).bundledVersions,
471+
};
472+
473+
// Collect versions from CLI dependencies (oxlint, oxfmt, oxlint-tsgolint)
474+
// These don't export ./package.json, so we read from node_modules directly
475+
const depTools = ['oxlint', 'oxfmt', 'oxlint-tsgolint'] as const;
476+
for (const name of depTools) {
477+
const version = await readDepVersion(name);
478+
if (version) {
479+
versions[name] = version;
480+
}
481+
}
482+
483+
// dist/versions.js — inlined constants (no runtime I/O)
484+
await writeFile(
485+
join(distDir, 'versions.js'),
486+
`export const versions = ${JSON.stringify(versions, null, 2)};\n`,
487+
);
488+
489+
// dist/versions.d.ts — type declarations
490+
const typeFields = Object.keys(versions)
491+
.map((k) => ` readonly '${k}': string;`)
492+
.join('\n');
493+
await writeFile(
494+
join(distDir, 'versions.d.ts'),
495+
`export declare const versions: {\n${typeFields}\n};\n`,
496+
);
497+
498+
console.log(` Created ./versions (${Object.keys(versions).length} tools)`);
499+
}
500+
428501
/**
429502
* Copy markdown doc files from the monorepo docs/ directory into skills/vite-plus/docs/,
430503
* preserving the relative directory structure. This keeps stable file paths for

packages/cli/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@
7575
"types": "./dist/pack.d.ts",
7676
"import": "./dist/pack.js"
7777
},
78+
"./versions": {
79+
"types": "./dist/versions.d.ts",
80+
"default": "./dist/versions.js"
81+
},
7882
"./test": {
7983
"import": {
8084
"types": "./dist/test/index.d.ts",
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Verify that the vite-plus/versions export works correctly.
3+
*
4+
* Tests run against the already-built dist/ directory, ensuring
5+
* that syncVersionsExport() produces correct artifacts.
6+
*/
7+
import fs from 'node:fs';
8+
import path from 'node:path';
9+
import url from 'node:url';
10+
11+
import { describe, expect, it } from '@voidzero-dev/vite-plus-test';
12+
13+
const cliPkgDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '../..');
14+
const distDir = path.join(cliPkgDir, 'dist');
15+
const corePkgPath = path.join(cliPkgDir, '../core/package.json');
16+
const testPkgPath = path.join(cliPkgDir, '../test/package.json');
17+
18+
describe('versions export', () => {
19+
describe('build artifacts', () => {
20+
it('dist/versions.js should exist', () => {
21+
expect(fs.existsSync(path.join(distDir, 'versions.js'))).toBe(true);
22+
});
23+
24+
it('dist/versions.d.ts should exist', () => {
25+
expect(fs.existsSync(path.join(distDir, 'versions.d.ts'))).toBe(true);
26+
});
27+
28+
it('dist/versions.js should export a versions object', () => {
29+
const content = fs.readFileSync(path.join(distDir, 'versions.js'), 'utf-8');
30+
expect(content).toContain('export const versions');
31+
});
32+
33+
it('dist/versions.d.ts should declare a versions type', () => {
34+
const content = fs.readFileSync(path.join(distDir, 'versions.d.ts'), 'utf-8');
35+
expect(content).toContain('export declare const versions');
36+
});
37+
});
38+
39+
describe('bundledVersions consistency', () => {
40+
it('should contain all core bundledVersions', async () => {
41+
const corePkg = JSON.parse(fs.readFileSync(corePkgPath, 'utf-8'));
42+
const mod = await import('../../dist/versions.js');
43+
const versions = mod.versions as Record<string, string>;
44+
for (const [key, value] of Object.entries(
45+
corePkg.bundledVersions as Record<string, string>,
46+
)) {
47+
expect(versions[key], `versions.${key} should match core bundledVersions`).toBe(value);
48+
}
49+
});
50+
51+
it('should contain all test bundledVersions', async () => {
52+
const testPkg = JSON.parse(fs.readFileSync(testPkgPath, 'utf-8'));
53+
const mod = await import('../../dist/versions.js');
54+
const versions = mod.versions as Record<string, string>;
55+
for (const [key, value] of Object.entries(
56+
testPkg.bundledVersions as Record<string, string>,
57+
)) {
58+
expect(versions[key], `versions.${key} should match test bundledVersions`).toBe(value);
59+
}
60+
});
61+
});
62+
63+
describe('dependency tool versions', () => {
64+
it('should contain oxlint version', async () => {
65+
const mod = await import('../../dist/versions.js');
66+
const versions = mod.versions as Record<string, string>;
67+
expect(versions.oxlint).toBeTypeOf('string');
68+
});
69+
70+
it('should contain oxfmt version', async () => {
71+
const mod = await import('../../dist/versions.js');
72+
const versions = mod.versions as Record<string, string>;
73+
expect(versions.oxfmt).toBeTypeOf('string');
74+
});
75+
76+
it('should contain oxlint-tsgolint version', async () => {
77+
const mod = await import('../../dist/versions.js');
78+
const versions = mod.versions as Record<string, string>;
79+
expect(versions['oxlint-tsgolint']).toBeTypeOf('string');
80+
});
81+
});
82+
83+
describe('type declarations', () => {
84+
it('should have type fields for all bundled tools', () => {
85+
const content = fs.readFileSync(path.join(distDir, 'versions.d.ts'), 'utf-8');
86+
const expectedKeys = [
87+
'vite',
88+
'rolldown',
89+
'tsdown',
90+
'vitest',
91+
'oxlint',
92+
'oxfmt',
93+
'oxlint-tsgolint',
94+
];
95+
for (const key of expectedKeys) {
96+
expect(content).toContain(key);
97+
}
98+
});
99+
100+
it('should declare all fields as readonly string', () => {
101+
const content = fs.readFileSync(path.join(distDir, 'versions.d.ts'), 'utf-8');
102+
const fieldMatches = content.match(/readonly [\w'-]+: string;/g);
103+
expect(fieldMatches).not.toBeNull();
104+
expect(fieldMatches!.length).toBeGreaterThanOrEqual(7);
105+
});
106+
});
107+
108+
describe('runtime import', () => {
109+
it('should be importable and return an object with expected keys', async () => {
110+
const { versions } = await import('../../dist/versions.js');
111+
expect(versions).toBeDefined();
112+
expect(typeof versions).toBe('object');
113+
expect(versions.vite).toBeTypeOf('string');
114+
expect(versions.rolldown).toBeTypeOf('string');
115+
expect(versions.tsdown).toBeTypeOf('string');
116+
expect(versions.vitest).toBeTypeOf('string');
117+
expect(versions.oxlint).toBeTypeOf('string');
118+
expect(versions.oxfmt).toBeTypeOf('string');
119+
expect(versions['oxlint-tsgolint']).toBeTypeOf('string');
120+
});
121+
122+
it('should have valid semver-like versions', async () => {
123+
const { versions } = await import('../../dist/versions.js');
124+
const semverPattern = /^\d+\.\d+\.\d+/;
125+
for (const [key, value] of Object.entries(versions as Record<string, string>)) {
126+
expect(value, `${key} should be a valid version`).toMatch(semverPattern);
127+
}
128+
});
129+
});
130+
});

0 commit comments

Comments
 (0)