feat(cli): include git sha in --version

This commit is contained in:
Peter Steinberger
2025-12-26 12:12:16 +01:00
parent b4e1ddbf2e
commit 5613caa149
4 changed files with 169 additions and 32 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
## 0.1.2 — Unreleased ## 0.1.2 — Unreleased
### Fixed ### 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 ## 0.1.1 — 2025-12-26
+1 -1
View File
@@ -16,7 +16,7 @@
"scripts": { "scripts": {
"build": "pnpm run build:dist && pnpm run build:binary", "build": "pnpm run build:dist && pnpm run build:binary",
"build:dist": "tsc", "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", "dev": "tsx src/index.ts",
"bird": "pnpm run build:dist && node dist/index.js", "bird": "pnpm run build:dist && node dist/index.js",
"test": "vitest run", "test": "vitest run",
+143 -24
View File
@@ -1,45 +1,164 @@
import { readFileSync } from 'node:fs'; import fs from 'node:fs';
import { dirname, join } from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { try {
const raw = readFileSync(path, 'utf8'); const raw = fs.readFileSync(candidate, 'utf8');
const parsed = JSON.parse(raw) as { version?: unknown }; const json = JSON.parse(raw) as { version?: unknown } | null;
return typeof parsed.version === 'string' ? parsed.version : undefined; if (json && typeof json.version === 'string' && json.version.trim().length > 0) {
return json.version.trim();
}
return null;
} catch { } catch {
return undefined; return null;
} }
} }
function readVersionFromTextFile(path: string): string | undefined { function readVersionFromTextFile(candidate: string): string | null {
try { try {
const raw = readFileSync(path, 'utf8').trim(); const raw = fs.readFileSync(candidate, 'utf8').trim();
return raw.length > 0 ? raw : undefined; return raw.length > 0 ? raw : null;
} catch { } catch {
return undefined; return null;
} }
} }
function tryReadVersionFromImportMeta(): string | undefined { function resolveStartDir(importMetaUrl?: string): string {
if (typeof importMetaUrl === 'string' && importMetaUrl.trim().length > 0) {
try { try {
const thisFile = fileURLToPath(import.meta.url); return path.dirname(fileURLToPath(importMetaUrl));
const baseDir = join(dirname(thisFile), '..', '..');
return (
readPackageVersionFromJsonFile(join(baseDir, 'package.json')) ?? readVersionFromTextFile(join(baseDir, 'VERSION'))
);
} catch { } catch {
return undefined; // ignore
} }
}
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname;
return process.cwd();
} }
function tryReadVersionNextToExecutable(): string | undefined { export function resolvePackageVersion(importMetaUrl?: string): string {
const execDir = dirname(process.argv[0] ?? process.execPath); const injected =
return ( typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
readPackageVersionFromJsonFile(join(execDir, 'package.json')) ?? readVersionFromTextFile(join(execDir, 'VERSION')) ? 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 { export function getCliVersion(): string {
return tryReadVersionFromImportMeta() ?? tryReadVersionNextToExecutable() ?? 'unknown'; return formatVersionLine(import.meta.url);
} }
+22 -4
View File
@@ -1,12 +1,30 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { getCliVersion } from '../src/lib/version.js'; import { formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js';
describe('getCliVersion', () => { 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 raw = readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8');
const pkg = JSON.parse(raw) as { version?: unknown }; 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})`);
}); });
}); });