CLI: include commit hash in --version output (#39712)

* CLI: include commit hash in --version output

* fix(version): harden commit SHA resolution and keep output consistent

* CLI: keep install checks compatible with commit-tagged version output

* fix(cli): include commit hash in root version fast path

* test(cli): allow null commit-hash mocks

* Installer: share version parser across install scripts

* Installer: avoid sourcing helpers from stdin cwd

* CLI: note commit-tagged version output

* CLI: anchor commit hash resolution to module root

* CLI: harden commit hash resolution

* CLI: fix commit hash lookup edge cases

* CLI: prefer live git metadata in dev builds

* CLI: keep git lookup inside package root

* Infra: tolerate invalid moduleUrl hints

* CLI: cache baked commit metadata fallbacks

* CLI: align changelog attribution with prep gate

* CLI: restore changelog contributor credit

---------

Co-authored-by: echoVic <echovic@163.com>
Co-authored-by: echoVic <echoVic@users.noreply.github.com>
This commit is contained in:
Altay
2026-03-08 19:10:48 +03:00
committed by GitHub
parent c942655451
commit ca5e352c53
19 changed files with 903 additions and 42 deletions

View File

@@ -1,7 +1,9 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveGitHeadPath } from "./git-root.js";
import { resolveOpenClawPackageRootSync } from "./openclaw-root.js";
const formatCommit = (value?: string | null) => {
if (!value) {
@@ -11,10 +13,127 @@ const formatCommit = (value?: string | null) => {
if (!trimmed) {
return null;
}
return trimmed.length > 7 ? trimmed.slice(0, 7) : trimmed;
const match = trimmed.match(/[0-9a-fA-F]{7,40}/);
if (!match) {
return null;
}
return match[0].slice(0, 7).toLowerCase();
};
let cachedCommit: string | null | undefined;
const cachedGitCommitBySearchDir = new Map<string, string | null>();
function isMissingPathError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const code = (error as NodeJS.ErrnoException).code;
return code === "ENOENT" || code === "ENOTDIR";
}
const resolveCommitSearchDir = (options: { cwd?: string; moduleUrl?: string }) => {
if (options.cwd) {
return path.resolve(options.cwd);
}
if (options.moduleUrl) {
try {
return path.dirname(fileURLToPath(options.moduleUrl));
} catch {
// moduleUrl is not a valid file:// URL; fall back to process.cwd().
}
}
return process.cwd();
};
/** Read at most `limit` bytes from a file to avoid unbounded reads. */
const safeReadFilePrefix = (filePath: string, limit = 256) => {
const fd = fs.openSync(filePath, "r");
try {
const buf = Buffer.alloc(limit);
const bytesRead = fs.readSync(fd, buf, 0, limit, 0);
return buf.subarray(0, bytesRead).toString("utf-8");
} finally {
fs.closeSync(fd);
}
};
const cacheGitCommit = (searchDir: string, commit: string | null) => {
cachedGitCommitBySearchDir.set(searchDir, commit);
return commit;
};
const resolveGitLookupDepth = (searchDir: string, packageRoot: string | null) => {
if (!packageRoot) {
return undefined;
}
const relative = path.relative(packageRoot, searchDir);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return undefined;
}
const depth = relative ? relative.split(path.sep).filter(Boolean).length : 0;
return depth + 1;
};
const readCommitFromGit = (
searchDir: string,
packageRoot: string | null,
): string | null | undefined => {
const headPath = resolveGitHeadPath(searchDir, {
maxDepth: resolveGitLookupDepth(searchDir, packageRoot),
});
if (!headPath) {
return undefined;
}
const head = fs.readFileSync(headPath, "utf-8").trim();
if (!head) {
return null;
}
if (head.startsWith("ref:")) {
const ref = head.replace(/^ref:\s*/i, "").trim();
const refPath = resolveRefPath(headPath, ref);
if (!refPath) {
return null;
}
const refHash = safeReadFilePrefix(refPath).trim();
return formatCommit(refHash);
}
return formatCommit(head);
};
const resolveGitRefsBase = (headPath: string) => {
const gitDir = path.dirname(headPath);
try {
const commonDir = safeReadFilePrefix(path.join(gitDir, "commondir")).trim();
if (commonDir) {
return path.resolve(gitDir, commonDir);
}
} catch (error) {
if (!isMissingPathError(error)) {
throw error;
}
// Plain repo git dirs do not have commondir.
}
return gitDir;
};
/** Safely resolve a git ref path, rejecting traversal attacks from a crafted HEAD file. */
const resolveRefPath = (headPath: string, ref: string) => {
if (!ref.startsWith("refs/")) {
return null;
}
if (path.isAbsolute(ref)) {
return null;
}
if (ref.split(/[/]/).includes("..")) {
return null;
}
const refsBase = resolveGitRefsBase(headPath);
const resolved = path.resolve(refsBase, ref);
const rel = path.relative(refsBase, resolved);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
return null;
}
return resolved;
};
const readCommitFromPackageJson = () => {
try {
@@ -52,49 +171,46 @@ const readCommitFromBuildInfo = () => {
}
};
export const resolveCommitHash = (options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) => {
if (cachedCommit !== undefined) {
return cachedCommit;
}
export const resolveCommitHash = (
options: {
cwd?: string;
env?: NodeJS.ProcessEnv;
moduleUrl?: string;
} = {},
) => {
const env = options.env ?? process.env;
const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
const normalized = formatCommit(envCommit);
if (normalized) {
cachedCommit = normalized;
return cachedCommit;
return normalized;
}
const searchDir = resolveCommitSearchDir(options);
if (cachedGitCommitBySearchDir.has(searchDir)) {
return cachedGitCommitBySearchDir.get(searchDir) ?? null;
}
const packageRoot = resolveOpenClawPackageRootSync({
cwd: options.cwd,
moduleUrl: options.moduleUrl,
});
try {
const gitCommit = readCommitFromGit(searchDir, packageRoot);
if (gitCommit !== undefined) {
return cacheGitCommit(searchDir, gitCommit);
}
} catch {
// Fall through to baked metadata for packaged installs that are not in a live checkout.
}
const buildInfoCommit = readCommitFromBuildInfo();
if (buildInfoCommit) {
cachedCommit = buildInfoCommit;
return cachedCommit;
return cacheGitCommit(searchDir, buildInfoCommit);
}
const pkgCommit = readCommitFromPackageJson();
if (pkgCommit) {
cachedCommit = pkgCommit;
return cachedCommit;
return cacheGitCommit(searchDir, pkgCommit);
}
try {
const headPath = resolveGitHeadPath(options.cwd ?? process.cwd());
if (!headPath) {
cachedCommit = null;
return cachedCommit;
}
const head = fs.readFileSync(headPath, "utf-8").trim();
if (!head) {
cachedCommit = null;
return cachedCommit;
}
if (head.startsWith("ref:")) {
const ref = head.replace(/^ref:\s*/i, "").trim();
const refPath = path.resolve(path.dirname(headPath), ref);
const refHash = fs.readFileSync(refPath, "utf-8").trim();
cachedCommit = formatCommit(refHash);
return cachedCommit;
}
cachedCommit = formatCommit(head);
return cachedCommit;
return cacheGitCommit(searchDir, readCommitFromGit(searchDir, packageRoot) ?? null);
} catch {
cachedCommit = null;
return cachedCommit;
return cacheGitCommit(searchDir, null);
}
};