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(''), ''); + }); +});