From c7e6f823478a0e9f86fada37aee6d53af6e5d387 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 02:00:47 +0000 Subject: [PATCH] fix: report the real version from the released bundle The release asset ships dist/polylane.mjs standalone, but --version (and the update command, User-Agent, and client-version headers) re-read package.json relative to the bundle at runtime. With no package.json next to the downloaded asset, every install reported 0.0.0. build.ts already bakes the version into the bundle via an esbuild define on POLYLANE_CLI_VERSION; route all version lookups through a shared src/version.ts that prefers the baked value and falls back to package.json only in dev mode. The release workflow now also injects the tag version explicitly at build time so the bundle can never drift from the tag. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149UgXkD5n9z2yKrzCMgCTC --- .github/workflows/release.yml | 2 +- build.ts | 6 ++++-- src/client/http.ts | 23 +++-------------------- src/client/thread-chat.ts | 3 ++- src/commands/update.ts | 21 ++++----------------- src/main.ts | 18 ++---------------- src/telemetry/event.ts | 5 +---- src/version.ts | 26 ++++++++++++++++++++++++++ test/version.test.ts | 26 ++++++++++++++++++++++++++ 9 files changed, 69 insertions(+), 61 deletions(-) create mode 100644 src/version.ts create mode 100644 test/version.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b42b943..b40e5f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: run: npm ci - name: Build - run: npm run build + run: POLYLANE_CLI_VERSION="${GITHUB_REF_NAME#v}" npm run build env: POLYLANE_API_DOMAIN: ${{ secrets.POLYLANE_API_DOMAIN }} POLYLANE_OAUTH_CLIENT_ID: ${{ secrets.POLYLANE_OAUTH_CLIENT_ID }} diff --git a/build.ts b/build.ts index d3a22e4..25e4bc7 100644 --- a/build.ts +++ b/build.ts @@ -11,15 +11,17 @@ async function main(): Promise { await generateAll(); - // Step 2: read version + // Step 2: resolve version — an explicit POLYLANE_CLI_VERSION (e.g. the tag + // in the release workflow) wins over package.json. const pkg = JSON.parse(readFileSync('package.json', 'utf-8')) as { version: string }; + const version = process.env.POLYLANE_CLI_VERSION ?? pkg.version; // Step 3: bundle with esbuild mkdirSync('dist', { recursive: true }); const outfile = join('dist', 'polylane.mjs'); const define: Record = { - 'process.env.POLYLANE_CLI_VERSION': JSON.stringify(pkg.version), + 'process.env.POLYLANE_CLI_VERSION': JSON.stringify(version), }; // Bake every POLYLANE_* env var visible at build time into the bundle, so the // produced binary works without needing those vars set at runtime. diff --git a/src/client/http.ts b/src/client/http.ts index 3d63f6b..4aa9257 100644 --- a/src/client/http.ts +++ b/src/client/http.ts @@ -6,9 +6,7 @@ import { ExitCode } from '../errors/codes'; import { maskToken } from '../utils/token'; import { showStatusBar } from '../output/status-bar'; import { recordHttpRequest } from '../telemetry/http-counter'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; +import { getCliVersion } from '../version'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -32,21 +30,6 @@ interface ApiEnvelope { result: T; } -let cachedVersion: string | null = null; - -function getVersion(): string { - if (cachedVersion !== null) return cachedVersion; - try { - const __dirname = dirname(fileURLToPath(import.meta.url)); - const pkgPath = join(__dirname, '..', '..', 'package.json'); - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version: string }; - cachedVersion = pkg.version; - } catch { - cachedVersion = '0.0.0'; - } - return cachedVersion; -} - function buildQueryString(query: Query): string { const params: string[] = []; for (const [key, value] of Object.entries(query)) { @@ -78,9 +61,9 @@ export async function request(config: Config, opts: RequestOpts): Promise = { - 'User-Agent': `polylane-cli/${getVersion()}`, + 'User-Agent': `polylane-cli/${getCliVersion()}`, 'x-nominal-client': 'cli', - 'x-nominal-client-version': getVersion(), + 'x-nominal-client-version': getCliVersion(), ...opts.headers, }; diff --git a/src/client/thread-chat.ts b/src/client/thread-chat.ts index f8d35b5..6cf3c3d 100644 --- a/src/client/thread-chat.ts +++ b/src/client/thread-chat.ts @@ -4,6 +4,7 @@ import type { Config } from '../config/schema'; import { resolveCredential, getAuthHeader } from '../auth/resolver'; import { CLIError } from '../errors/base'; import { ExitCode } from '../errors/codes'; +import { getCliVersion } from '../version'; export interface UIMessagePart { type: string; @@ -91,7 +92,7 @@ async function buildConnection( const headers: Record = { ...getAuthHeader(cred), 'x-nominal-client': 'cli', - 'x-nominal-client-version': process.env.POLYLANE_CLI_VERSION ?? '0.0.0', + 'x-nominal-client-version': getCliVersion(), }; return { url, headers }; } diff --git a/src/commands/update.ts b/src/commands/update.ts index 5226c9f..51f7009 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -3,9 +3,7 @@ import type { Config } from '../config/schema'; import { UPDATE_STATE_FILE } from '../config/paths'; import { readJsonFile, writeJsonFile } from '../utils/fs'; import { isCI } from '../utils/env'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; +import { getCliVersion } from '../version'; interface UpdateState { lastCheck: number; @@ -14,17 +12,6 @@ interface UpdateState { const CACHE_TTL_MS = 24 * 60 * 60 * 1000; -function getCurrentVersion(): string { - try { - const __dirname = dirname(fileURLToPath(import.meta.url)); - const pkgPath = join(__dirname, '..', '..', 'package.json'); - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version: string }; - return pkg.version; - } catch { - return '0.0.0'; - } -} - async function fetchLatest(): Promise { try { const res = await fetch('https://registry.npmjs.org/@coreplane/polylane/latest', { @@ -53,7 +40,7 @@ export const updateCommand: Command = { name: 'update', description: 'Check for CLI updates', async execute(config: Config): Promise { - const current = getCurrentVersion(); + const current = getCliVersion(); process.stderr.write(`Current version: ${current}\n`); if (isCI() && !config.verbose) { @@ -83,7 +70,7 @@ export async function checkForUpdateAsync(): Promise { if (isCI()) return null; const state = readJsonFile(UPDATE_STATE_FILE); if (state && Date.now() - state.lastCheck < CACHE_TTL_MS) { - const current = getCurrentVersion(); + const current = getCliVersion(); if (state.latest && compareVersions(state.latest, current) > 0) { return state.latest; } @@ -91,7 +78,7 @@ export async function checkForUpdateAsync(): Promise { } const latest = await fetchLatest(); writeJsonFile(UPDATE_STATE_FILE, { lastCheck: Date.now(), latest }); - const current = getCurrentVersion(); + const current = getCliVersion(); if (latest && compareVersions(latest, current) > 0) return latest; return null; } diff --git a/src/main.ts b/src/main.ts index 4d4d870..de80a37 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,3 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; - import { parseFlags, scanCommandPath } from './args'; import { GLOBAL_OPTIONS } from './command'; import type { GlobalFlags } from './types/flags'; @@ -20,6 +16,7 @@ import { import type { Config } from './config/schema'; import type { Credential } from './auth/types'; import { buildEvent, dispatch, hasShownFirstRunNotice, markFirstRunNoticeShown } from './telemetry'; +import { getCliVersion } from './version'; const NO_AUTH_COMMANDS = new Set([ 'auth login', @@ -40,17 +37,6 @@ const NO_AUTH_COMMANDS = new Set([ 'api describe', ]); -function readVersion(): string { - try { - const __dirname = dirname(fileURLToPath(import.meta.url)); - const pkgPath = join(__dirname, '..', 'package.json'); - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version: string }; - return pkg.version; - } catch { - return '0.0.0'; - } -} - function setupSignalHandlers(): void { process.on('SIGINT', () => { process.stderr.write('\n'); @@ -85,7 +71,7 @@ async function run(): Promise { const argv = process.argv.slice(2); if (argv[0] === '--version' || argv[0] === '-v') { - process.stdout.write(`polylane ${readVersion()}\n`); + process.stdout.write(`polylane ${getCliVersion()}\n`); return; } diff --git a/src/telemetry/event.ts b/src/telemetry/event.ts index b1f03d6..bbb326d 100644 --- a/src/telemetry/event.ts +++ b/src/telemetry/event.ts @@ -6,6 +6,7 @@ import { isCI, getCIName, isStdoutTTY, isStderrTTY } from '../utils/env'; import { getInstallId, recordRun } from './state'; import { detectInstallSource, detectEnvironment, type InstallSource } from './environment'; import { getHttpStats } from './http-counter'; +import { getCliVersion } from '../version'; // The set of fields shipped to the telemetry endpoint. Everything here is // intentionally non-sensitive — see PRIVACY.md for the full inventory. @@ -82,10 +83,6 @@ export interface CliTelemetryEvent { const SESSION_ID = randomUUID(); -function getCliVersion(): string { - return process.env.POLYLANE_CLI_VERSION ?? '0.0.0'; -} - function authMethodOf(cred: Credential | null): CliTelemetryEvent['authMethod'] { if (!cred) return null; return cred.type === 'oauth' ? 'oauth' : 'api-key'; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..a6a0dac --- /dev/null +++ b/src/version.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +let cachedVersion: string | null = null; + +// The released bundle ships as a standalone dist/polylane.mjs with no +// package.json next to it, so build.ts bakes the version in via an esbuild +// define on POLYLANE_CLI_VERSION. Reading package.json is the dev-mode +// fallback (tsx src/main.ts from a checkout). +export function resolveVersion(baked = process.env.POLYLANE_CLI_VERSION): string { + if (baked) return baked; + try { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const pkgPath = join(__dirname, '..', 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version: string }; + return pkg.version; + } catch { + return '0.0.0'; + } +} + +export function getCliVersion(): string { + cachedVersion ??= resolveVersion(); + return cachedVersion; +} diff --git a/test/version.test.ts b/test/version.test.ts new file mode 100644 index 0000000..97a531b --- /dev/null +++ b/test/version.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolveVersion } from '../src/version'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe('resolveVersion', () => { + it('prefers the baked build-time version', () => { + assert.equal(resolveVersion('1.2.3'), '1.2.3'); + }); + + it('falls back to package.json in dev mode', () => { + const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')) as { + version: string; + }; + assert.equal(resolveVersion(undefined), pkg.version); + assert.notEqual(resolveVersion(undefined), '0.0.0'); + }); + + it('treats an empty baked value as unset', () => { + assert.notEqual(resolveVersion(''), ''); + }); +});