diff --git a/src/version.ts b/src/version.ts index 950d6b7..db47935 100644 --- a/src/version.ts +++ b/src/version.ts @@ -2,6 +2,19 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +/** Read and validate the package.json version string. */ +export function readVersionFromPackageJson(packageJsonPath: string): string { + const pkg = JSON.parse( + readFileSync(packageJsonPath, "utf8"), + ) as { version?: unknown }; + + if (typeof pkg.version !== "string" || pkg.version.trim().length === 0) { + throw new Error("package.json is missing a valid version field."); + } + + return pkg.version; +} + /** * CLI version, read from package.json at runtime so it can never drift from the * published version (see #48 — this used to be a hard-coded literal that fell @@ -15,8 +28,5 @@ import { fileURLToPath } from "node:url"; * plain runtime read instead of a copied asset. */ const here = dirname(fileURLToPath(import.meta.url)); -const pkg = JSON.parse( - readFileSync(join(here, "..", "package.json"), "utf8"), -) as { version: string }; -export const VERSION = pkg.version; +export const VERSION = readVersionFromPackageJson(join(here, "..", "package.json")); diff --git a/test/version.test.ts b/test/version.test.ts new file mode 100644 index 0000000..690bd9b --- /dev/null +++ b/test/version.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readVersionFromPackageJson, VERSION } from "../src/version.js"; + +const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + version: string; +}; + +const tmpDirs: string[] = []; + +function writePackageJson(contents: unknown): string { + const tmpDir = mkdtempSync(join(tmpdir(), "mex-version-")); + tmpDirs.push(tmpDir); + const packageJsonPath = join(tmpDir, "package.json"); + writeFileSync(packageJsonPath, JSON.stringify(contents)); + return packageJsonPath; +} + +afterEach(() => { + while (tmpDirs.length > 0) { + const tmpDir = tmpDirs.pop(); + if (!tmpDir) continue; + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +describe("version metadata", () => { + it("exports the root package.json version", () => { + expect(VERSION).toBe(packageJson.version); + }); + + it("reads a valid package version", () => { + const packageJsonPath = writePackageJson({ version: "1.2.3" }); + + expect(readVersionFromPackageJson(packageJsonPath)).toBe("1.2.3"); + }); + + it("rejects missing, empty, or non-string versions", () => { + for (const contents of [ + {}, + { version: "" }, + { version: " " }, + { version: 1 }, + { version: null }, + ]) { + const packageJsonPath = writePackageJson(contents); + + expect(() => readVersionFromPackageJson(packageJsonPath)).toThrow( + "package.json is missing a valid version field.", + ); + } + }); +});