diff --git a/lib/getVersion.ts b/lib/getVersion.ts index 60e599df5..ab8cc1268 100644 --- a/lib/getVersion.ts +++ b/lib/getVersion.ts @@ -1,6 +1,5 @@ import { createRequire } from "node:module" -import pkg from "../package.json" -import semver from "semver" +import { getCliVersion } from "./shared/get-cli-version" const require = createRequire(import.meta.url) @@ -17,8 +16,6 @@ const resolvePackageVersionFromNodeModules: VersionResolver = (packageName) => { } } -const getCliVersion = () => semver.inc(pkg.version, "patch") ?? pkg.version - type GlobalWithTscircuitVersion = typeof globalThis & { TSCIRCUIT_VERSION?: string } diff --git a/lib/shared/check-for-cli-update.ts b/lib/shared/check-for-cli-update.ts index 56d0cb247..f7c234830 100644 --- a/lib/shared/check-for-cli-update.ts +++ b/lib/shared/check-for-cli-update.ts @@ -2,15 +2,13 @@ import ky from "ky" import { getPackageManager } from "./get-package-manager" import { getGlobalDepsInstallCommand } from "lib/shared/get-dep-install-command" import { execSync } from "node:child_process" -import { program } from "cli/main" import semver from "semver" -import { version as pkgVersion } from "../../package.json" +import { getCliVersion } from "./get-cli-version" import kleur from "kleur" import { prompts } from "lib/utils/prompts" import { shouldBeInteractive } from "lib/utils/should-be-interactive" -export const currentCliVersion = () => - program?.version() ?? semver.inc(pkgVersion, "patch") ?? pkgVersion +export const currentCliVersion = getCliVersion export const getLatestVersion = async () => { const { version: latestCliVersion } = await ky diff --git a/lib/shared/get-cli-version.ts b/lib/shared/get-cli-version.ts new file mode 100644 index 000000000..ece4a9ee3 --- /dev/null +++ b/lib/shared/get-cli-version.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs" +import semver from "semver" +import { version as bundledVersion } from "../../package.json" + +export const getCliVersion = (): string => { + try { + // Both this source file and dist/{cli,lib} are two levels below the manifest. + const manifest: unknown = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) + if ( + typeof manifest === "object" && + manifest !== null && + "name" in manifest && + manifest.name === "@tscircuit/cli" && + "version" in manifest && + typeof manifest.version === "string" && + semver.valid(manifest.version) !== null + ) { + return manifest.version + } + return bundledVersion + } catch (error) { + if ( + error instanceof SyntaxError || + (error instanceof Error && + "code" in error && + ["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(String(error.code))) + ) { + return bundledVersion + } + throw error + } +} diff --git a/tests/lib/get-cli-version.test.ts b/tests/lib/get-cli-version.test.ts new file mode 100644 index 000000000..befd96356 --- /dev/null +++ b/tests/lib/get-cli-version.test.ts @@ -0,0 +1,133 @@ +import { afterEach, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { version as sourceVersion } from "../../package.json" + +const temporaryDirectories: string[] = [] +let cachedBundle: string | undefined + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +const createBundle = async (outputPath: string) => { + const root = await mkdtemp(join(tmpdir(), "cli-version-")) + temporaryDirectories.push(root) + const entrypoint = join(root, "entry.ts") + const getVersionPath = fileURLToPath( + new URL("../../lib/getVersion.ts", import.meta.url), + ) + const helperPath = fileURLToPath( + new URL("../../lib/shared/get-cli-version.ts", import.meta.url), + ) + await writeFile( + entrypoint, + ` + import { getVersion, getVersionInfo } from ${JSON.stringify(getVersionPath)} + import { getCliVersion } from ${JSON.stringify(helperPath)} + Object.assign(globalThis, { TSCIRCUIT_VERSION: "9.9.9" }) + console.log(JSON.stringify({ + cli: getVersionInfo(() => undefined).cliVersion, + currentCli: getCliVersion(), + wrapper: getVersion(), + })) + `, + ) + const output = join(root, outputPath) + if (cachedBundle === undefined) { + const build = await Bun.build({ + entrypoints: [entrypoint], + target: "node", + format: "esm", + }) + expect(build.success).toBe(true) + const artifact = build.outputs[0] + if (!artifact) throw new TypeError("Missing version test bundle") + const text: string = await artifact.text() + cachedBundle = text + } + await mkdir(dirname(output), { recursive: true }) + await writeFile(output, cachedBundle) + await writeFile(join(root, "dist", "package.json"), '{"type":"module"}') + const cwd = join(root, "unrelated-project") + await mkdir(cwd) + await writeFile( + join(cwd, "package.json"), + '{"name":"@tscircuit/cli","version":"8.8.8"}', + ) + return { root, output, cwd } +} + +const runBundle = async (fixture: Awaited>) => { + const results: unknown[] = [] + // Exercise the node-target bundle in both supported runtimes. + for (const executable of [process.execPath, "node"]) { + const result = Bun.spawnSync([executable, fixture.output], { + cwd: fixture.cwd, + }) + expect(result.exitCode).toBe(0) + results.push(JSON.parse(result.stdout.toString())) + } + return results +} + +for (const layout of ["dist/cli/main.js", "dist/lib/index.js"]) { + test(`${layout} reports the installed version when inline metadata is stale`, async () => { + // Given a bundle built before the installed manifest receives its new version. + const fixture = await createBundle(layout) + await writeFile( + join(fixture.root, "package.json"), + JSON.stringify({ + name: "@tscircuit/cli", + version: "0.1.2021", + type: "module", + exports: { ".": "./dist/cli/main.js", "./lib": "./dist/lib/index.js" }, + }), + ) + // When the bundle runs from an unrelated package directory. + const results = await runBundle(fixture) + // Then CLI and wrapper versions remain distinct and no patch is guessed. + for (const result of results) { + expect(result).toEqual({ + cli: "0.1.2021", + currentCli: "0.1.2021", + wrapper: "9.9.9", + }) + } + }) + + for (const [description, manifest] of [ + ["missing", undefined], + ["malformed JSON", "{"], + [ + "wrong package name", + JSON.stringify({ name: "tscircuit", version: "8.8.8" }), + ], + [ + "invalid version", + JSON.stringify({ name: "@tscircuit/cli", version: "not-semver" }), + ], + ["missing version", JSON.stringify({ name: "@tscircuit/cli" })], + ]) { + test(`${layout} uses the unchanged source fallback for ${description} metadata`, async () => { + // Given unavailable or invalid installed metadata and a different CWD manifest. + const fixture = await createBundle(layout) + if (manifest !== undefined) + await writeFile(join(fixture.root, "package.json"), manifest) + // When the actual bundle reads its own installation metadata. + const results = await runBundle(fixture) + // Then fallback uses the exact source version, not a guessed next release. + for (const result of results) { + expect(result).toEqual({ + cli: sourceVersion, + currentCli: sourceVersion, + wrapper: "9.9.9", + }) + } + }) + } +} diff --git a/tests/lib/getVersion.test.ts b/tests/lib/getVersion.test.ts index ac7cbc15f..01aa35bcf 100644 --- a/tests/lib/getVersion.test.ts +++ b/tests/lib/getVersion.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, test } from "bun:test" import { getVersion, getVersionInfo } from "lib/getVersion" +import { version as sourceVersion } from "../../package.json" type GlobalWithTscircuitVersion = typeof globalThis & { TSCIRCUIT_VERSION?: string @@ -41,3 +42,11 @@ test("getVersion verbose output prints all requested packages", () => { expect(output).toContain("@tscircuit/runframe:") expect(output).toContain("@tscircuit/core:") }) + +test("getVersionInfo reports the source package version without guessing a patch", () => { + // Given the source checkout has its own current package version. + // When reading the CLI version independently of the wrapper package. + const { cliVersion } = getVersionInfo(() => undefined) + // Then the reported version matches the installed manifest exactly. + expect(cliVersion).toBe(sourceVersion) +})