diff --git a/CHANGELOG.md b/CHANGELOG.md index bfdecba..4f64566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.1.2 — Unreleased ### Fixed -- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string). +- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available. ## 0.1.1 — 2025-12-26 diff --git a/package.json b/package.json index 236ea1d..ad00891 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "scripts": { "build": "pnpm run build:dist && pnpm run build:binary", "build:dist": "tsc", - "build:binary": "bun build --compile --minify src/index.ts --outfile bird", + "build:binary": "BIRD_VERSION=$(node -p \"require('./package.json').version\") BIRD_GIT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || true) bun build --compile --minify --env=BIRD_* src/index.ts --outfile bird", "dev": "tsx src/index.ts", "bird": "pnpm run build:dist && node dist/index.js", "test": "vitest run", diff --git a/src/lib/version.ts b/src/lib/version.ts index 0b9d570..5a1fb0b 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,45 +1,164 @@ -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import fs from 'node:fs'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; -function readPackageVersionFromJsonFile(path: string): string | undefined { +declare const __dirname: string | undefined; + +export const FALLBACK_VERSION = 'unknown'; + +function readPackageVersionFromJsonFile(candidate: string): string | null { try { - const raw = readFileSync(path, 'utf8'); - const parsed = JSON.parse(raw) as { version?: unknown }; - return typeof parsed.version === 'string' ? parsed.version : undefined; + const raw = fs.readFileSync(candidate, 'utf8'); + const json = JSON.parse(raw) as { version?: unknown } | null; + if (json && typeof json.version === 'string' && json.version.trim().length > 0) { + return json.version.trim(); + } + return null; } catch { - return undefined; + return null; } } -function readVersionFromTextFile(path: string): string | undefined { +function readVersionFromTextFile(candidate: string): string | null { try { - const raw = readFileSync(path, 'utf8').trim(); - return raw.length > 0 ? raw : undefined; + const raw = fs.readFileSync(candidate, 'utf8').trim(); + return raw.length > 0 ? raw : null; } catch { - return undefined; + return null; } } -function tryReadVersionFromImportMeta(): string | undefined { - try { - const thisFile = fileURLToPath(import.meta.url); - const baseDir = join(dirname(thisFile), '..', '..'); - return ( - readPackageVersionFromJsonFile(join(baseDir, 'package.json')) ?? readVersionFromTextFile(join(baseDir, 'VERSION')) - ); - } catch { - return undefined; +function resolveStartDir(importMetaUrl?: string): string { + if (typeof importMetaUrl === 'string' && importMetaUrl.trim().length > 0) { + try { + return path.dirname(fileURLToPath(importMetaUrl)); + } catch { + // ignore + } } + + if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname; + + return process.cwd(); } -function tryReadVersionNextToExecutable(): string | undefined { - const execDir = dirname(process.argv[0] ?? process.execPath); - return ( - readPackageVersionFromJsonFile(join(execDir, 'package.json')) ?? readVersionFromTextFile(join(execDir, 'VERSION')) - ); +export function resolvePackageVersion(importMetaUrl?: string): string { + const injected = + typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string' + ? process.env.BIRD_VERSION.trim() + : ''; + if (injected.length > 0) return injected; + + let dir = resolveStartDir(importMetaUrl); + for (let i = 0; i < 10; i += 1) { + const version = + readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ?? + readVersionFromTextFile(path.join(dir, 'VERSION')); + if (version) return version; + + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + return FALLBACK_VERSION; +} + +function truncateSha(sha: string, length = 8): string { + const trimmed = sha.trim(); + if (!trimmed) return ''; + if (trimmed.length <= length) return trimmed; + return trimmed.slice(0, length); +} + +function resolveGitShaFromGitDir(gitDir: string): string | null { + const headPath = path.join(gitDir, 'HEAD'); + let head = ''; + try { + head = fs.readFileSync(headPath, 'utf8').trim(); + } catch { + return null; + } + + if (!head) return null; + if (!head.startsWith('ref:')) { + const sha = truncateSha(head); + return sha.length > 0 ? sha : null; + } + + const ref = head.replace(/^ref:\s*/i, '').trim(); + if (!ref) return null; + + const refPath = path.join(gitDir, ref); + try { + const sha = truncateSha(fs.readFileSync(refPath, 'utf8')); + return sha.length > 0 ? sha : null; + } catch { + // fall through to packed-refs + } + + const packedRefsPath = path.join(gitDir, 'packed-refs'); + try { + const packed = fs.readFileSync(packedRefsPath, 'utf8'); + const lines = packed.split(/\r?\n/); + for (const line of lines) { + if (!line || line.startsWith('#') || line.startsWith('^')) continue; + const [shaRaw, refName] = line.split(' '); + if (refName?.trim() === ref) { + const sha = truncateSha(shaRaw ?? ''); + return sha.length > 0 ? sha : null; + } + } + } catch { + // ignore + } + + return null; +} + +export function resolveGitSha(importMetaUrl?: string): string | null { + const injected = + typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string' + ? process.env.BIRD_GIT_SHA.trim() + : ''; + if (injected.length > 0) return truncateSha(injected); + + let dir = resolveStartDir(importMetaUrl); + for (let i = 0; i < 10; i += 1) { + const dotGit = path.join(dir, '.git'); + try { + const stat = fs.statSync(dotGit); + if (stat.isDirectory()) { + const sha = resolveGitShaFromGitDir(dotGit); + if (sha) return sha; + } else if (stat.isFile()) { + const txt = fs.readFileSync(dotGit, 'utf8'); + const match = txt.match(/gitdir:\s*(.+)\s*$/i); + const gitDir = match?.[1]?.trim(); + if (gitDir) { + const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir); + const sha = resolveGitShaFromGitDir(resolved); + if (sha) return sha; + } + } + } catch { + // ignore + } + + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + return null; +} + +export function formatVersionLine(importMetaUrl?: string): string { + const version = resolvePackageVersion(importMetaUrl); + const sha = resolveGitSha(importMetaUrl); + return sha ? `${version} (${sha})` : version; } export function getCliVersion(): string { - return tryReadVersionFromImportMeta() ?? tryReadVersionNextToExecutable() ?? 'unknown'; + return formatVersionLine(import.meta.url); } diff --git a/tests/version.test.ts b/tests/version.test.ts index fd8f2ef..380a1b2 100644 --- a/tests/version.test.ts +++ b/tests/version.test.ts @@ -1,12 +1,30 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; -import { getCliVersion } from '../src/lib/version.js'; +import { afterEach, describe, expect, it } from 'vitest'; +import { formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js'; describe('getCliVersion', () => { - it('matches package.json version when available', () => { + afterEach(() => { + delete process.env.BIRD_VERSION; + delete process.env.BIRD_GIT_SHA; + }); + + it('reads package.json version when available', () => { const raw = readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'); const pkg = JSON.parse(raw) as { version?: unknown }; - expect(getCliVersion()).toBe(pkg.version); + expect(resolvePackageVersion()).toBe(pkg.version); + }); + + it('formats injected version + sha', () => { + process.env.BIRD_VERSION = '9.9.9'; + process.env.BIRD_GIT_SHA = 'abcdef123456'; + expect(formatVersionLine('not a url')).toBe('9.9.9 (abcdef12)'); + }); + + it('adds git sha when available', () => { + const version = resolvePackageVersion(); + const sha = resolveGitSha(); + expect(sha).toMatch(/^[0-9a-f]{8}$/); + expect(formatVersionLine()).toBe(`${version} (${sha})`); }); });