mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
|
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
|
||||||
- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
|
- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
|
||||||
- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.
|
- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.
|
||||||
|
- CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./version-parse.sh
|
||||||
|
source "$SCRIPT_DIR/version-parse.sh"
|
||||||
|
|
||||||
verify_installed_cli() {
|
verify_installed_cli() {
|
||||||
local package_name="$1"
|
local package_name="$1"
|
||||||
local expected_version="$2"
|
local expected_version="$2"
|
||||||
@@ -32,6 +36,8 @@ verify_installed_cli() {
|
|||||||
installed_version="$(node "$entry_path" --version 2>/dev/null | head -n 1 | tr -d '\r')"
|
installed_version="$(node "$entry_path" --version 2>/dev/null | head -n 1 | tr -d '\r')"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
installed_version="$(extract_openclaw_semver "$installed_version")"
|
||||||
|
|
||||||
echo "cli=$cli_name installed=$installed_version expected=$expected_version"
|
echo "cli=$cli_name installed=$installed_version expected=$expected_version"
|
||||||
if [[ "$installed_version" != "$expected_version" ]]; then
|
if [[ "$installed_version" != "$expected_version" ]]; then
|
||||||
echo "ERROR: expected ${cli_name}@${expected_version}, got ${cli_name}@${installed_version}" >&2
|
echo "ERROR: expected ${cli_name}@${expected_version}, got ${cli_name}@${installed_version}" >&2
|
||||||
|
|||||||
14
scripts/docker/install-sh-common/version-parse.sh
Normal file
14
scripts/docker/install-sh-common/version-parse.sh
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
extract_openclaw_semver() {
|
||||||
|
local raw="${1:-}"
|
||||||
|
local parsed=""
|
||||||
|
parsed="$(
|
||||||
|
printf '%s\n' "$raw" \
|
||||||
|
| tr -d '\r' \
|
||||||
|
| grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \
|
||||||
|
| head -n 1 \
|
||||||
|
|| true
|
||||||
|
)"
|
||||||
|
printf '%s' "${parsed#v}"
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ RUN apt-get update \
|
|||||||
git \
|
git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
|
||||||
COPY run.sh /usr/local/bin/openclaw-install-e2e
|
COPY run.sh /usr/local/bin/openclaw-install-e2e
|
||||||
RUN chmod +x /usr/local/bin/openclaw-install-e2e
|
RUN chmod +x /usr/local/bin/openclaw-install-e2e
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
VERIFY_HELPER_PATH="/usr/local/install-sh-common/version-parse.sh"
|
||||||
|
if [[ ! -f "$VERIFY_HELPER_PATH" ]]; then
|
||||||
|
VERIFY_HELPER_PATH="${SCRIPT_DIR}/../install-sh-common/version-parse.sh"
|
||||||
|
fi
|
||||||
|
# shellcheck source=../install-sh-common/version-parse.sh
|
||||||
|
source "$VERIFY_HELPER_PATH"
|
||||||
|
|
||||||
INSTALL_URL="${OPENCLAW_INSTALL_URL:-${CLAWDBOT_INSTALL_URL:-https://openclaw.bot/install.sh}}"
|
INSTALL_URL="${OPENCLAW_INSTALL_URL:-${CLAWDBOT_INSTALL_URL:-https://openclaw.bot/install.sh}}"
|
||||||
MODELS_MODE="${OPENCLAW_E2E_MODELS:-${CLAWDBOT_E2E_MODELS:-both}}" # both|openai|anthropic
|
MODELS_MODE="${OPENCLAW_E2E_MODELS:-${CLAWDBOT_E2E_MODELS:-both}}" # both|openai|anthropic
|
||||||
INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-${CLAWDBOT_INSTALL_TAG:-latest}}"
|
INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-${CLAWDBOT_INSTALL_TAG:-latest}}"
|
||||||
@@ -69,6 +77,7 @@ fi
|
|||||||
|
|
||||||
echo "==> Verify installed version"
|
echo "==> Verify installed version"
|
||||||
INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')"
|
INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')"
|
||||||
|
INSTALLED_VERSION="$(extract_openclaw_semver "$INSTALLED_VERSION")"
|
||||||
echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION"
|
echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION"
|
||||||
if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then
|
if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then
|
||||||
echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2
|
echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ ENV NPM_CONFIG_FUND=false
|
|||||||
ENV NPM_CONFIG_AUDIT=false
|
ENV NPM_CONFIG_AUDIT=false
|
||||||
|
|
||||||
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
|
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
|
||||||
|
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
|
||||||
COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
|
COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
|
||||||
RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot
|
RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ RUN set -eux; \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
|
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
|
||||||
|
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
|
||||||
COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
|
COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
|
||||||
RUN chmod +x /usr/local/bin/openclaw-install-smoke
|
RUN chmod +x /usr/local/bin/openclaw-install-smoke
|
||||||
|
|
||||||
|
|||||||
@@ -2085,14 +2085,52 @@ run_bootstrap_onboarding_if_needed() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load_install_version_helpers() {
|
||||||
|
local source_path="${BASH_SOURCE[0]-}"
|
||||||
|
local script_dir=""
|
||||||
|
local helper_path=""
|
||||||
|
if [[ -z "$source_path" || ! -f "$source_path" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
script_dir="$(cd "$(dirname "$source_path")" && pwd 2>/dev/null || true)"
|
||||||
|
helper_path="${script_dir}/docker/install-sh-common/version-parse.sh"
|
||||||
|
if [[ -n "$script_dir" && -r "$helper_path" ]]; then
|
||||||
|
# shellcheck source=docker/install-sh-common/version-parse.sh
|
||||||
|
source "$helper_path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
load_install_version_helpers
|
||||||
|
|
||||||
|
if ! declare -F extract_openclaw_semver >/dev/null 2>&1; then
|
||||||
|
# Inline fallback when version-parse.sh could not be sourced (for example, stdin install).
|
||||||
|
extract_openclaw_semver() {
|
||||||
|
local raw="${1:-}"
|
||||||
|
local parsed=""
|
||||||
|
parsed="$(
|
||||||
|
printf '%s\n' "$raw" \
|
||||||
|
| tr -d '\r' \
|
||||||
|
| grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \
|
||||||
|
| head -n 1 \
|
||||||
|
|| true
|
||||||
|
)"
|
||||||
|
printf '%s' "${parsed#v}"
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
resolve_openclaw_version() {
|
resolve_openclaw_version() {
|
||||||
local version=""
|
local version=""
|
||||||
|
local raw_version_output=""
|
||||||
local claw="${OPENCLAW_BIN:-}"
|
local claw="${OPENCLAW_BIN:-}"
|
||||||
if [[ -z "$claw" ]] && command -v openclaw &> /dev/null; then
|
if [[ -z "$claw" ]] && command -v openclaw &> /dev/null; then
|
||||||
claw="$(command -v openclaw)"
|
claw="$(command -v openclaw)"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$claw" ]]; then
|
if [[ -n "$claw" ]]; then
|
||||||
version=$("$claw" --version 2>/dev/null | head -n 1 | tr -d '\r')
|
raw_version_output=$("$claw" --version 2>/dev/null | head -n 1 | tr -d '\r')
|
||||||
|
version="$(extract_openclaw_semver "$raw_version_output")"
|
||||||
|
if [[ -z "$version" ]]; then
|
||||||
|
version="$raw_version_output"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ -z "$version" ]]; then
|
if [[ -z "$version" ]]; then
|
||||||
local npm_root=""
|
local npm_root=""
|
||||||
|
|||||||
@@ -655,7 +655,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
showFallbackAuth ? ` · 🔑 ${activeAuthLabelValue}` : ""
|
showFallbackAuth ? ` · 🔑 ${activeAuthLabelValue}` : ""
|
||||||
} (${fallbackState.reason ?? "selected model unavailable"})`
|
} (${fallbackState.reason ?? "selected model unavailable"})`
|
||||||
: null;
|
: null;
|
||||||
const commit = resolveCommitHash();
|
const commit = resolveCommitHash({ moduleUrl: import.meta.url });
|
||||||
const versionLine = `🦞 OpenClaw ${VERSION}${commit ? ` (${commit})` : ""}`;
|
const versionLine = `🦞 OpenClaw ${VERSION}${commit ? ` (${commit})` : ""}`;
|
||||||
const usagePair = formatUsagePair(inputTokens, outputTokens);
|
const usagePair = formatUsagePair(inputTokens, outputTokens);
|
||||||
const cacheLine = formatCacheLine(inputTokens, cacheRead, cacheWrite);
|
const cacheLine = formatCacheLine(inputTokens, cacheRead, cacheWrite);
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ function resolveTaglineMode(options: BannerOptions): TaglineMode | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatCliBannerLine(version: string, options: BannerOptions = {}): string {
|
export function formatCliBannerLine(version: string, options: BannerOptions = {}): string {
|
||||||
const commit = options.commit ?? resolveCommitHash({ env: options.env });
|
const commit =
|
||||||
|
options.commit ?? resolveCommitHash({ env: options.env, moduleUrl: import.meta.url });
|
||||||
const commitLabel = commit ?? "unknown";
|
const commitLabel = commit ?? "unknown";
|
||||||
const tagline = pickTagline({ ...options, mode: resolveTaglineMode(options) });
|
const tagline = pickTagline({ ...options, mode: resolveTaglineMode(options) });
|
||||||
const rich = options.richTty ?? isRich();
|
const rich = options.richTty ?? isRich();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ProgramContext } from "./context.js";
|
|||||||
const hasEmittedCliBannerMock = vi.fn(() => false);
|
const hasEmittedCliBannerMock = vi.fn(() => false);
|
||||||
const formatCliBannerLineMock = vi.fn(() => "BANNER-LINE");
|
const formatCliBannerLineMock = vi.fn(() => "BANNER-LINE");
|
||||||
const formatDocsLinkMock = vi.fn((_path: string, full: string) => `https://${full}`);
|
const formatDocsLinkMock = vi.fn((_path: string, full: string) => `https://${full}`);
|
||||||
|
const resolveCommitHashMock = vi.fn<() => string | null>(() => "abc1234");
|
||||||
|
|
||||||
vi.mock("../../terminal/links.js", () => ({
|
vi.mock("../../terminal/links.js", () => ({
|
||||||
formatDocsLink: formatDocsLinkMock,
|
formatDocsLink: formatDocsLinkMock,
|
||||||
@@ -26,6 +27,10 @@ vi.mock("../banner.js", () => ({
|
|||||||
hasEmittedCliBanner: hasEmittedCliBannerMock,
|
hasEmittedCliBanner: hasEmittedCliBannerMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../infra/git-commit.js", () => ({
|
||||||
|
resolveCommitHash: resolveCommitHashMock,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../cli-name.js", () => ({
|
vi.mock("../cli-name.js", () => ({
|
||||||
resolveCliName: () => "openclaw",
|
resolveCliName: () => "openclaw",
|
||||||
replaceCliName: (cmd: string) => cmd,
|
replaceCliName: (cmd: string) => cmd,
|
||||||
@@ -55,6 +60,7 @@ describe("configureProgramHelp", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
originalArgv = [...process.argv];
|
originalArgv = [...process.argv];
|
||||||
hasEmittedCliBannerMock.mockReturnValue(false);
|
hasEmittedCliBannerMock.mockReturnValue(false);
|
||||||
|
resolveCommitHashMock.mockReturnValue("abc1234");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -116,7 +122,25 @@ describe("configureProgramHelp", () => {
|
|||||||
|
|
||||||
const program = makeProgramWithCommands();
|
const program = makeProgramWithCommands();
|
||||||
expect(() => configureProgramHelp(program, testProgramContext)).toThrow("exit:0");
|
expect(() => configureProgramHelp(program, testProgramContext)).toThrow("exit:0");
|
||||||
expect(logSpy).toHaveBeenCalledWith("9.9.9-test");
|
expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)");
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||||
|
|
||||||
|
logSpy.mockRestore();
|
||||||
|
exitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints version and exits immediately without commit metadata", () => {
|
||||||
|
process.argv = ["node", "openclaw", "--version"];
|
||||||
|
resolveCommitHashMock.mockReturnValue(null);
|
||||||
|
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||||
|
throw new Error(`exit:${code ?? ""}`);
|
||||||
|
}) as typeof process.exit);
|
||||||
|
|
||||||
|
const program = makeProgramWithCommands();
|
||||||
|
expect(() => configureProgramHelp(program, testProgramContext)).toThrow("exit:0");
|
||||||
|
expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test");
|
||||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||||
|
|
||||||
logSpy.mockRestore();
|
logSpy.mockRestore();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import { resolveCommitHash } from "../../infra/git-commit.js";
|
||||||
import { formatDocsLink } from "../../terminal/links.js";
|
import { formatDocsLink } from "../../terminal/links.js";
|
||||||
import { isRich, theme } from "../../terminal/theme.js";
|
import { isRich, theme } from "../../terminal/theme.js";
|
||||||
import { escapeRegExp } from "../../utils.js";
|
import { escapeRegExp } from "../../utils.js";
|
||||||
@@ -109,7 +110,10 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
|
|||||||
hasFlag(process.argv, "--version") ||
|
hasFlag(process.argv, "--version") ||
|
||||||
hasRootVersionAlias(process.argv)
|
hasRootVersionAlias(process.argv)
|
||||||
) {
|
) {
|
||||||
console.log(ctx.programVersion);
|
const commit = resolveCommitHash({ moduleUrl: import.meta.url });
|
||||||
|
console.log(
|
||||||
|
commit ? `OpenClaw ${ctx.programVersion} (${commit})` : `OpenClaw ${ctx.programVersion}`,
|
||||||
|
);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,9 +127,11 @@ if (
|
|||||||
if (!isRootVersionInvocation(argv)) {
|
if (!isRootVersionInvocation(argv)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
import("./version.js")
|
Promise.all([import("./version.js"), import("./infra/git-commit.js")])
|
||||||
.then(({ VERSION }) => {
|
.then(([{ VERSION }, { resolveCommitHash }]) => {
|
||||||
console.log(VERSION);
|
const commit = resolveCommitHash({ moduleUrl: import.meta.url });
|
||||||
|
console.log(commit ? `OpenClaw ${VERSION} (${commit})` : `OpenClaw ${VERSION}`);
|
||||||
|
process.exit(0);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
104
src/entry.version-fast-path.test.ts
Normal file
104
src/entry.version-fast-path.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import process from "node:process";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const applyCliProfileEnvMock = vi.hoisted(() => vi.fn());
|
||||||
|
const attachChildProcessBridgeMock = vi.hoisted(() => vi.fn());
|
||||||
|
const installProcessWarningFilterMock = vi.hoisted(() => vi.fn());
|
||||||
|
const isMainModuleMock = vi.hoisted(() => vi.fn(() => true));
|
||||||
|
const isRootHelpInvocationMock = vi.hoisted(() => vi.fn(() => false));
|
||||||
|
const isRootVersionInvocationMock = vi.hoisted(() => vi.fn(() => true));
|
||||||
|
const normalizeEnvMock = vi.hoisted(() => vi.fn());
|
||||||
|
const normalizeWindowsArgvMock = vi.hoisted(() => vi.fn((argv: string[]) => argv));
|
||||||
|
const parseCliProfileArgsMock = vi.hoisted(() => vi.fn((argv: string[]) => ({ ok: true, argv })));
|
||||||
|
const resolveCommitHashMock = vi.hoisted(() => vi.fn<() => string | null>(() => "abc1234"));
|
||||||
|
const shouldSkipRespawnForArgvMock = vi.hoisted(() => vi.fn(() => true));
|
||||||
|
|
||||||
|
vi.mock("./cli/argv.js", () => ({
|
||||||
|
isRootHelpInvocation: isRootHelpInvocationMock,
|
||||||
|
isRootVersionInvocation: isRootVersionInvocationMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./cli/profile.js", () => ({
|
||||||
|
applyCliProfileEnv: applyCliProfileEnvMock,
|
||||||
|
parseCliProfileArgs: parseCliProfileArgsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./cli/respawn-policy.js", () => ({
|
||||||
|
shouldSkipRespawnForArgv: shouldSkipRespawnForArgvMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./cli/windows-argv.js", () => ({
|
||||||
|
normalizeWindowsArgv: normalizeWindowsArgvMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./infra/env.js", () => ({
|
||||||
|
isTruthyEnvValue: () => false,
|
||||||
|
normalizeEnv: normalizeEnvMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./infra/git-commit.js", () => ({
|
||||||
|
resolveCommitHash: resolveCommitHashMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./infra/is-main.js", () => ({
|
||||||
|
isMainModule: isMainModuleMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./infra/warning-filter.js", () => ({
|
||||||
|
installProcessWarningFilter: installProcessWarningFilterMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./process/child-process-bridge.js", () => ({
|
||||||
|
attachChildProcessBridge: attachChildProcessBridgeMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./version.js", () => ({
|
||||||
|
VERSION: "9.9.9-test",
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("entry root version fast path", () => {
|
||||||
|
let originalArgv: string[];
|
||||||
|
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
originalArgv = [...process.argv];
|
||||||
|
process.argv = ["node", "openclaw", "--version"];
|
||||||
|
exitSpy = vi
|
||||||
|
.spyOn(process, "exit")
|
||||||
|
.mockImplementation(((_code?: number) => undefined) as typeof process.exit);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.argv = originalArgv;
|
||||||
|
exitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints commit-tagged version output when commit metadata is available", async () => {
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await import("./entry.js");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)");
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
logSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to plain version output when commit metadata is unavailable", async () => {
|
||||||
|
resolveCommitHashMock.mockReturnValueOnce(null);
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await import("./entry.js");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test");
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
logSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
402
src/infra/git-commit.test.ts
Normal file
402
src/infra/git-commit.test.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
async function makeTempDir(label: string): Promise<string> {
|
||||||
|
return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeFakeGitRepo(
|
||||||
|
root: string,
|
||||||
|
options: {
|
||||||
|
head: string;
|
||||||
|
refs?: Record<string, string>;
|
||||||
|
gitdir?: string;
|
||||||
|
commondir?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await fs.mkdir(root, { recursive: true });
|
||||||
|
const gitdir = options.gitdir ?? path.join(root, ".git");
|
||||||
|
if (options.gitdir) {
|
||||||
|
await fs.writeFile(path.join(root, ".git"), `gitdir: ${options.gitdir}\n`, "utf-8");
|
||||||
|
} else {
|
||||||
|
await fs.mkdir(gitdir, { recursive: true });
|
||||||
|
}
|
||||||
|
await fs.mkdir(gitdir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(gitdir, "HEAD"), options.head, "utf-8");
|
||||||
|
if (options.commondir) {
|
||||||
|
await fs.writeFile(path.join(gitdir, "commondir"), options.commondir, "utf-8");
|
||||||
|
}
|
||||||
|
for (const [refPath, commit] of Object.entries(options.refs ?? {})) {
|
||||||
|
const targetPath = path.join(gitdir, refPath);
|
||||||
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fs.writeFile(targetPath, `${commit}\n`, "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("git commit resolution", () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves commit metadata from the caller module root instead of the caller cwd", async () => {
|
||||||
|
const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], {
|
||||||
|
cwd: originalCwd,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
const temp = await makeTempDir("git-commit-cwd");
|
||||||
|
const otherRepo = path.join(temp, "other");
|
||||||
|
await fs.mkdir(otherRepo, { recursive: true });
|
||||||
|
execFileSync("git", ["init", "-q"], { cwd: otherRepo });
|
||||||
|
await fs.writeFile(path.join(otherRepo, "note.txt"), "x\n", "utf-8");
|
||||||
|
execFileSync("git", ["add", "note.txt"], { cwd: otherRepo });
|
||||||
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "init"],
|
||||||
|
{ cwd: otherRepo },
|
||||||
|
);
|
||||||
|
const otherHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], {
|
||||||
|
cwd: otherRepo,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
process.chdir(otherRepo);
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
const entryModuleUrl = pathToFileURL(path.join(originalCwd, "src", "entry.ts")).href;
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).toBe(repoHead);
|
||||||
|
expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).not.toBe(otherHead);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers live git metadata over stale build info in a real checkout", async () => {
|
||||||
|
const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], {
|
||||||
|
cwd: originalCwd,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
vi.doMock("node:module", () => ({
|
||||||
|
createRequire: () => {
|
||||||
|
return (specifier: string) => {
|
||||||
|
if (specifier === "../build-info.json" || specifier === "./build-info.json") {
|
||||||
|
return { commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" };
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error(`Cannot find module ${specifier}`), {
|
||||||
|
code: "MODULE_NOT_FOUND",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
const entryModuleUrl = pathToFileURL(path.join(originalCwd, "src", "entry.ts")).href;
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ moduleUrl: entryModuleUrl, env: {} })).toBe(repoHead);
|
||||||
|
|
||||||
|
vi.doUnmock("node:module");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches build-info fallback results per resolved search directory", async () => {
|
||||||
|
const temp = await makeTempDir("git-commit-build-info-cache");
|
||||||
|
const moduleRequire = vi.fn((specifier: string) => {
|
||||||
|
if (specifier === "../build-info.json" || specifier === "./build-info.json") {
|
||||||
|
return { commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" };
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error(`Cannot find module ${specifier}`), {
|
||||||
|
code: "MODULE_NOT_FOUND",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("node:module", () => ({
|
||||||
|
createRequire: () => moduleRequire,
|
||||||
|
}));
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("deadbee");
|
||||||
|
const firstCallRequires = moduleRequire.mock.calls.length;
|
||||||
|
expect(firstCallRequires).toBeGreaterThan(0);
|
||||||
|
expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("deadbee");
|
||||||
|
expect(moduleRequire.mock.calls.length).toBe(firstCallRequires);
|
||||||
|
|
||||||
|
vi.doUnmock("node:module");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches package.json fallback results per resolved search directory", async () => {
|
||||||
|
const temp = await makeTempDir("git-commit-package-json-cache");
|
||||||
|
const moduleRequire = vi.fn((specifier: string) => {
|
||||||
|
if (specifier === "../build-info.json" || specifier === "./build-info.json") {
|
||||||
|
throw Object.assign(new Error(`Cannot find module ${specifier}`), {
|
||||||
|
code: "MODULE_NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (specifier === "../../package.json") {
|
||||||
|
return { gitHead: "badc0ffee0ddf00d" };
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error(`Cannot find module ${specifier}`), {
|
||||||
|
code: "MODULE_NOT_FOUND",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("node:module", () => ({
|
||||||
|
createRequire: () => moduleRequire,
|
||||||
|
}));
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("badc0ff");
|
||||||
|
const firstCallRequires = moduleRequire.mock.calls.length;
|
||||||
|
expect(firstCallRequires).toBeGreaterThan(0);
|
||||||
|
expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("badc0ff");
|
||||||
|
expect(moduleRequire.mock.calls.length).toBe(firstCallRequires);
|
||||||
|
|
||||||
|
vi.doUnmock("node:module");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats invalid moduleUrl inputs as a fallback hint instead of throwing", async () => {
|
||||||
|
const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], {
|
||||||
|
cwd: originalCwd,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: originalCwd, env: {} }),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: originalCwd, env: {} })).toBe(
|
||||||
|
repoHead,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not walk out of the openclaw package into a host repo", async () => {
|
||||||
|
const temp = await makeTempDir("git-commit-package-boundary");
|
||||||
|
const hostRepo = path.join(temp, "host");
|
||||||
|
await fs.mkdir(hostRepo, { recursive: true });
|
||||||
|
execFileSync("git", ["init", "-q"], { cwd: hostRepo });
|
||||||
|
await fs.writeFile(path.join(hostRepo, "host.txt"), "x\n", "utf-8");
|
||||||
|
execFileSync("git", ["add", "host.txt"], { cwd: hostRepo });
|
||||||
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "init"],
|
||||||
|
{ cwd: hostRepo },
|
||||||
|
);
|
||||||
|
|
||||||
|
const packageRoot = path.join(hostRepo, "node_modules", "openclaw");
|
||||||
|
await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(packageRoot, "package.json"),
|
||||||
|
JSON.stringify({ name: "openclaw", version: "2026.3.8" }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "entry.js")).href;
|
||||||
|
|
||||||
|
vi.doMock("node:module", () => ({
|
||||||
|
createRequire: () => {
|
||||||
|
return (specifier: string) => {
|
||||||
|
if (specifier === "../build-info.json" || specifier === "./build-info.json") {
|
||||||
|
return { commit: "feedfacefeedfacefeedfacefeedfacefeedface" };
|
||||||
|
}
|
||||||
|
if (specifier === "../../package.json") {
|
||||||
|
return { name: "openclaw", version: "2026.3.8", gitHead: "badc0ffee0ddf00d" };
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error(`Cannot find module ${specifier}`), {
|
||||||
|
code: "MODULE_NOT_FOUND",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ moduleUrl, cwd: packageRoot, env: {} })).toBe("feedfac");
|
||||||
|
|
||||||
|
vi.doUnmock("node:module");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches git lookups per resolved search directory", async () => {
|
||||||
|
const temp = await makeTempDir("git-commit-cache");
|
||||||
|
const repoA = path.join(temp, "repo-a");
|
||||||
|
const repoB = path.join(temp, "repo-b");
|
||||||
|
await makeFakeGitRepo(repoA, {
|
||||||
|
head: "0123456789abcdef0123456789abcdef01234567\n",
|
||||||
|
});
|
||||||
|
await makeFakeGitRepo(repoB, {
|
||||||
|
head: "89abcdef0123456789abcdef0123456789abcdef\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456");
|
||||||
|
expect(resolveCommitHash({ cwd: repoB, env: {} })).toBe("89abcde");
|
||||||
|
expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches deterministic null results per resolved search directory", async () => {
|
||||||
|
const temp = await makeTempDir("git-commit-null-cache");
|
||||||
|
const repoRoot = path.join(temp, "repo");
|
||||||
|
await makeFakeGitRepo(repoRoot, {
|
||||||
|
head: "not-a-commit\n",
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||||
|
const readFileSyncSpy = vi.fn(actualFs.readFileSync);
|
||||||
|
vi.doMock("node:fs", () => ({
|
||||||
|
...actualFs,
|
||||||
|
default: {
|
||||||
|
...actualFs,
|
||||||
|
readFileSync: readFileSyncSpy,
|
||||||
|
},
|
||||||
|
readFileSync: readFileSyncSpy,
|
||||||
|
}));
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull();
|
||||||
|
const firstCallReads = readFileSyncSpy.mock.calls.length;
|
||||||
|
expect(firstCallReads).toBeGreaterThan(0);
|
||||||
|
expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull();
|
||||||
|
expect(readFileSyncSpy.mock.calls.length).toBe(firstCallReads);
|
||||||
|
|
||||||
|
vi.doUnmock("node:fs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches caught null fallback results per resolved search directory", async () => {
|
||||||
|
const temp = await makeTempDir("git-commit-caught-null-cache");
|
||||||
|
const repoRoot = path.join(temp, "repo");
|
||||||
|
await makeFakeGitRepo(repoRoot, {
|
||||||
|
head: "0123456789abcdef0123456789abcdef01234567\n",
|
||||||
|
});
|
||||||
|
const headPath = path.join(repoRoot, ".git", "HEAD");
|
||||||
|
|
||||||
|
const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||||
|
const readFileSyncSpy = vi.fn((filePath: string, ...args: unknown[]) => {
|
||||||
|
if (path.resolve(filePath) === path.resolve(headPath)) {
|
||||||
|
const error = Object.assign(new Error(`EACCES: permission denied, open '${filePath}'`), {
|
||||||
|
code: "EACCES",
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return Reflect.apply(actualFs.readFileSync, actualFs, [filePath, ...args]);
|
||||||
|
});
|
||||||
|
vi.doMock("node:fs", () => ({
|
||||||
|
...actualFs,
|
||||||
|
default: {
|
||||||
|
...actualFs,
|
||||||
|
readFileSync: readFileSyncSpy,
|
||||||
|
},
|
||||||
|
readFileSync: readFileSyncSpy,
|
||||||
|
}));
|
||||||
|
vi.doMock("node:module", () => ({
|
||||||
|
createRequire: () => {
|
||||||
|
return (specifier: string) => {
|
||||||
|
throw Object.assign(new Error(`Cannot find module ${specifier}`), {
|
||||||
|
code: "MODULE_NOT_FOUND",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull();
|
||||||
|
const headReadCount = () =>
|
||||||
|
readFileSyncSpy.mock.calls.filter(([filePath]) => path.resolve(filePath) === headPath).length;
|
||||||
|
const firstCallReads = headReadCount();
|
||||||
|
expect(firstCallReads).toBe(2);
|
||||||
|
expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull();
|
||||||
|
expect(headReadCount()).toBe(firstCallReads);
|
||||||
|
|
||||||
|
vi.doUnmock("node:fs");
|
||||||
|
vi.doUnmock("node:module");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats env-provided commit strings consistently", async () => {
|
||||||
|
const temp = await makeTempDir("git-commit-env");
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "ABCDEF0123456789" } })).toBe(
|
||||||
|
"abcdef0",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveCommitHash({ cwd: temp, env: { GIT_SHA: "commit abcdef0123456789 dirty" } }),
|
||||||
|
).toBe("abcdef0");
|
||||||
|
expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "not-a-sha" } })).toBeNull();
|
||||||
|
expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "" } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsafe HEAD refs and accepts valid refs", async () => {
|
||||||
|
const temp = await makeTempDir("git-commit-refs");
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
const absoluteRepo = path.join(temp, "absolute");
|
||||||
|
await makeFakeGitRepo(absoluteRepo, { head: "ref: /tmp/evil\n" });
|
||||||
|
expect(resolveCommitHash({ cwd: absoluteRepo, env: {} })).toBeNull();
|
||||||
|
|
||||||
|
const traversalRepo = path.join(temp, "traversal");
|
||||||
|
await makeFakeGitRepo(traversalRepo, { head: "ref: refs/heads/../evil\n" });
|
||||||
|
expect(resolveCommitHash({ cwd: traversalRepo, env: {} })).toBeNull();
|
||||||
|
|
||||||
|
const invalidPrefixRepo = path.join(temp, "invalid-prefix");
|
||||||
|
await makeFakeGitRepo(invalidPrefixRepo, { head: "ref: heads/main\n" });
|
||||||
|
expect(resolveCommitHash({ cwd: invalidPrefixRepo, env: {} })).toBeNull();
|
||||||
|
|
||||||
|
const validRepo = path.join(temp, "valid");
|
||||||
|
await makeFakeGitRepo(validRepo, {
|
||||||
|
head: "ref: refs/heads/main\n",
|
||||||
|
refs: {
|
||||||
|
"refs/heads/main": "fedcba9876543210fedcba9876543210fedcba98",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resolveCommitHash({ cwd: validRepo, env: {} })).toBe("fedcba9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves refs from the git commondir in worktree layouts", async () => {
|
||||||
|
const temp = await makeTempDir("git-commit-worktree");
|
||||||
|
const repoRoot = path.join(temp, "repo");
|
||||||
|
const worktreeGitDir = path.join(temp, "worktree-git");
|
||||||
|
const commonGitDir = path.join(temp, "common-git");
|
||||||
|
await fs.mkdir(commonGitDir, { recursive: true });
|
||||||
|
const refPath = path.join(commonGitDir, "refs", "heads", "main");
|
||||||
|
await fs.mkdir(path.dirname(refPath), { recursive: true });
|
||||||
|
await fs.writeFile(refPath, "76543210fedcba9876543210fedcba9876543210\n", "utf-8");
|
||||||
|
await makeFakeGitRepo(repoRoot, {
|
||||||
|
gitdir: worktreeGitDir,
|
||||||
|
head: "ref: refs/heads/main\n",
|
||||||
|
commondir: "../common-git",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("7654321");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads full HEAD refs before parsing long branch names", async () => {
|
||||||
|
const temp = await makeTempDir("git-commit-long-head");
|
||||||
|
const repoRoot = path.join(temp, "repo");
|
||||||
|
const longRefName = `refs/heads/${"segment/".repeat(40)}main`;
|
||||||
|
await makeFakeGitRepo(repoRoot, {
|
||||||
|
head: `ref: ${longRefName}\n`,
|
||||||
|
refs: {
|
||||||
|
[longRefName]: "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resolveCommitHash } = await import("./git-commit.js");
|
||||||
|
|
||||||
|
expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("0123456");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { resolveGitHeadPath } from "./git-root.js";
|
import { resolveGitHeadPath } from "./git-root.js";
|
||||||
|
import { resolveOpenClawPackageRootSync } from "./openclaw-root.js";
|
||||||
|
|
||||||
const formatCommit = (value?: string | null) => {
|
const formatCommit = (value?: string | null) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -11,10 +13,127 @@ const formatCommit = (value?: string | null) => {
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return null;
|
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 = () => {
|
const readCommitFromPackageJson = () => {
|
||||||
try {
|
try {
|
||||||
@@ -52,49 +171,46 @@ const readCommitFromBuildInfo = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveCommitHash = (options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) => {
|
export const resolveCommitHash = (
|
||||||
if (cachedCommit !== undefined) {
|
options: {
|
||||||
return cachedCommit;
|
cwd?: string;
|
||||||
}
|
env?: NodeJS.ProcessEnv;
|
||||||
|
moduleUrl?: string;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
|
const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
|
||||||
const normalized = formatCommit(envCommit);
|
const normalized = formatCommit(envCommit);
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
cachedCommit = normalized;
|
return normalized;
|
||||||
return cachedCommit;
|
}
|
||||||
|
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();
|
const buildInfoCommit = readCommitFromBuildInfo();
|
||||||
if (buildInfoCommit) {
|
if (buildInfoCommit) {
|
||||||
cachedCommit = buildInfoCommit;
|
return cacheGitCommit(searchDir, buildInfoCommit);
|
||||||
return cachedCommit;
|
|
||||||
}
|
}
|
||||||
const pkgCommit = readCommitFromPackageJson();
|
const pkgCommit = readCommitFromPackageJson();
|
||||||
if (pkgCommit) {
|
if (pkgCommit) {
|
||||||
cachedCommit = pkgCommit;
|
return cacheGitCommit(searchDir, pkgCommit);
|
||||||
return cachedCommit;
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const headPath = resolveGitHeadPath(options.cwd ?? process.cwd());
|
return cacheGitCommit(searchDir, readCommitFromGit(searchDir, packageRoot) ?? null);
|
||||||
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;
|
|
||||||
} catch {
|
} catch {
|
||||||
cachedCommit = null;
|
return cacheGitCommit(searchDir, null);
|
||||||
return cachedCommit;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -141,6 +141,18 @@ describe("resolveOpenClawPackageRoot", () => {
|
|||||||
expect(resolveOpenClawPackageRootSync({ moduleUrl })).toBe(pkgRoot);
|
expect(resolveOpenClawPackageRootSync({ moduleUrl })).toBe(pkgRoot);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores invalid moduleUrl values and falls back to cwd", async () => {
|
||||||
|
const pkgRoot = fx("invalid-moduleurl");
|
||||||
|
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||||
|
|
||||||
|
expect(resolveOpenClawPackageRootSync({ moduleUrl: "not-a-file-url", cwd: pkgRoot })).toBe(
|
||||||
|
pkgRoot,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
resolveOpenClawPackageRoot({ moduleUrl: "not-a-file-url", cwd: pkgRoot }),
|
||||||
|
).resolves.toBe(pkgRoot);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null for non-openclaw package roots", async () => {
|
it("returns null for non-openclaw package roots", async () => {
|
||||||
const pkgRoot = fx("not-openclaw");
|
const pkgRoot = fx("not-openclaw");
|
||||||
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" }));
|
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" }));
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ function buildCandidates(opts: { cwd?: string; argv1?: string; moduleUrl?: strin
|
|||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
|
|
||||||
if (opts.moduleUrl) {
|
if (opts.moduleUrl) {
|
||||||
candidates.push(path.dirname(fileURLToPath(opts.moduleUrl)));
|
try {
|
||||||
|
candidates.push(path.dirname(fileURLToPath(opts.moduleUrl)));
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid file:// URLs and keep other package-root hints.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (opts.argv1) {
|
if (opts.argv1) {
|
||||||
candidates.push(...candidateDirsFromArgv1(opts.argv1));
|
candidates.push(...candidateDirsFromArgv1(opts.argv1));
|
||||||
|
|||||||
121
src/install-sh-version.test.ts
Normal file
121
src/install-sh-version.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
function withFakeCli(versionOutput: string): { root: string; cliPath: string } {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-install-sh-"));
|
||||||
|
const cliPath = path.join(root, "openclaw");
|
||||||
|
const escapedOutput = versionOutput.replace(/'/g, "'\\''");
|
||||||
|
fs.writeFileSync(
|
||||||
|
cliPath,
|
||||||
|
`#!/usr/bin/env bash
|
||||||
|
printf '%s\n' '${escapedOutput}'
|
||||||
|
`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
fs.chmodSync(cliPath, 0o755);
|
||||||
|
return { root, cliPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVersionFromInstaller(cliPath: string): string {
|
||||||
|
const installerPath = path.join(process.cwd(), "scripts", "install.sh");
|
||||||
|
const output = execFileSync(
|
||||||
|
"bash",
|
||||||
|
[
|
||||||
|
"-lc",
|
||||||
|
`source "${installerPath}" >/dev/null 2>&1
|
||||||
|
OPENCLAW_BIN="$FAKE_OPENCLAW_BIN"
|
||||||
|
resolve_openclaw_version`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: process.cwd(),
|
||||||
|
encoding: "utf-8",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
FAKE_OPENCLAW_BIN: cliPath,
|
||||||
|
OPENCLAW_INSTALL_SH_NO_RUN: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return output.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVersionFromInstallerViaStdin(cliPath: string, cwd: string): string {
|
||||||
|
const installerPath = path.join(process.cwd(), "scripts", "install.sh");
|
||||||
|
const installerSource = fs.readFileSync(installerPath, "utf-8");
|
||||||
|
const output = execFileSync("bash", [], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf-8",
|
||||||
|
input: `${installerSource}
|
||||||
|
OPENCLAW_BIN="$FAKE_OPENCLAW_BIN"
|
||||||
|
resolve_openclaw_version
|
||||||
|
`,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
FAKE_OPENCLAW_BIN: cliPath,
|
||||||
|
OPENCLAW_INSTALL_SH_NO_RUN: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return output.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("install.sh version resolution", () => {
|
||||||
|
const tempRoots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of tempRoots.splice(0)) {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.runIf(process.platform !== "win32")(
|
||||||
|
"extracts the semantic version from decorated CLI output",
|
||||||
|
() => {
|
||||||
|
const fixture = withFakeCli("OpenClaw 2026.3.8 (abcdef0)");
|
||||||
|
tempRoots.push(fixture.root);
|
||||||
|
|
||||||
|
expect(resolveVersionFromInstaller(fixture.cliPath)).toBe("2026.3.8");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.runIf(process.platform !== "win32")(
|
||||||
|
"falls back to raw output when no semantic version is present",
|
||||||
|
() => {
|
||||||
|
const fixture = withFakeCli("OpenClaw dev's build");
|
||||||
|
tempRoots.push(fixture.root);
|
||||||
|
|
||||||
|
expect(resolveVersionFromInstaller(fixture.cliPath)).toBe("OpenClaw dev's build");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.runIf(process.platform !== "win32")(
|
||||||
|
"does not source version helpers from cwd when installer runs via stdin",
|
||||||
|
() => {
|
||||||
|
const fixture = withFakeCli("OpenClaw 2026.3.8 (abcdef0)");
|
||||||
|
tempRoots.push(fixture.root);
|
||||||
|
|
||||||
|
const hostileCwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-install-stdin-"));
|
||||||
|
tempRoots.push(hostileCwd);
|
||||||
|
const hostileHelper = path.join(
|
||||||
|
hostileCwd,
|
||||||
|
"docker",
|
||||||
|
"install-sh-common",
|
||||||
|
"version-parse.sh",
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(hostileHelper), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
hostileHelper,
|
||||||
|
`#!/usr/bin/env bash
|
||||||
|
extract_openclaw_semver() {
|
||||||
|
printf '%s' 'poisoned'
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolveVersionFromInstallerViaStdin(fixture.cliPath, hostileCwd)).toBe("2026.3.8");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user