fix(install): --json preserves valid NDJSON for dynamic values (#128682)

* fix(install): preserve valid NDJSON for dynamic values

* fix(install): satisfy installer shell checks
This commit is contained in:
xingzhou
2026-08-26 18:19:43 +08:00
committed by GitHub
parent 869d2073f6
commit ee2f5f2084
2 changed files with 153 additions and 49 deletions
+97 -49
View File
@@ -161,10 +161,10 @@ cleanup_legacy_submodules() {
local repo_dir="${1:-${OPENCLAW_GIT_DIR:-${OPENCLAW_EFFECTIVE_HOME}/openclaw}}"
local legacy_dir="${repo_dir}/Peekaboo"
if [[ -d "$legacy_dir" ]]; then
emit_json "{\"event\":\"step\",\"name\":\"legacy-submodule\",\"status\":\"start\",\"path\":\"${legacy_dir//\"/\\\"}\"}"
emit_json step name legacy-submodule status start path "$legacy_dir"
log "Removing legacy submodule checkout: ${legacy_dir}"
rm -rf "$legacy_dir"
emit_json "{\"event\":\"step\",\"name\":\"legacy-submodule\",\"status\":\"ok\",\"path\":\"${legacy_dir//\"/\\\"}\"}"
emit_json step name legacy-submodule status ok path "$legacy_dir"
fi
}
@@ -185,15 +185,63 @@ sha256_file() {
fail "Missing sha256 tool (need sha256sum, shasum, or openssl)"
}
JSON_STRING=""
quote_json_string() {
local value="${1:-}"
local char code escaped index
# Byte iteration escapes every C0 control without rewriting valid UTF-8 bytes.
# The final emitter can therefore guarantee one physical line per event.
local LC_ALL=C
JSON_STRING='"'
for ((index = 0; index < ${#value}; index++)); do
char="${value:index:1}"
case "$char" in
'"') JSON_STRING+='\"' ;;
\\) JSON_STRING+="\\\\" ;;
*)
printf -v code '%d' "'$char"
if ((code < 32)); then
printf -v escaped '\\u%04x' "$code"
JSON_STRING+="$escaped"
else
JSON_STRING+="$char"
fi
;;
esac
done
JSON_STRING+='"'
}
emit_json() {
if [[ "$JSON" -eq 1 ]]; then
printf '%s\n' "$1"
if [[ "$JSON" -ne 1 ]]; then
return 0
fi
local event="$1"
local key value output
shift
quote_json_string "$event"
output="{\"event\":${JSON_STRING}"
# `ok` is the installer's only non-string event field and belongs to done.
if [[ "$event" == "done" ]]; then
output+=',"ok":true'
fi
while [[ $# -gt 0 ]]; do
key="$1"
value="$2"
shift 2
quote_json_string "$key"
output+=",${JSON_STRING}:"
quote_json_string "$value"
output+="$JSON_STRING"
done
printf '%s}\n' "$output"
}
fail() {
local msg="$1"
emit_json "{\"event\":\"error\",\"message\":\"${msg//\"/\\\"}\"}"
emit_json error message "$msg"
log "ERROR: $msg"
exit 1
}
@@ -220,7 +268,7 @@ preflight_fresh_git_disk_space() {
return 0
fi
emit_json "{\"event\":\"step\",\"name\":\"disk-space\",\"status\":\"start\"}"
emit_json step name disk-space status start
ancestor="$repo_dir"
while [[ ! -e "$ancestor" ]]; do
local parent
@@ -236,14 +284,14 @@ preflight_fresh_git_disk_space() {
available_kib="$(available_disk_kib "$ancestor")"
if [[ ! "$available_kib" =~ ^[0-9]+$ ]]; then
emit_json "{\"event\":\"step\",\"name\":\"disk-space\",\"status\":\"warn\",\"reason\":\"unreadable\"}"
emit_json step name disk-space status warn reason unreadable
return 0
fi
if ((available_kib < FRESH_GIT_MIN_FREE_KIB)); then
available_gib="$(awk -v kib="$available_kib" 'BEGIN { printf "%.1f", kib / 1048576 }')"
fail "Fresh Git installs require at least 6 GiB of free disk space; only ${available_gib} GiB is available. Free disk space and retry."
fi
emit_json "{\"event\":\"step\",\"name\":\"disk-space\",\"status\":\"ok\"}"
emit_json step name disk-space status ok
}
has_sudo() {
@@ -256,11 +304,11 @@ is_root() {
ensure_git() {
if command -v git >/dev/null 2>&1; then
emit_json '{"event":"step","name":"git","status":"ok"}'
emit_json step name git status ok
return
fi
emit_json '{"event":"step","name":"git","status":"start"}'
emit_json step name git status start
log "Installing Git (required for npm installs)..."
case "$(os_detect)" in
@@ -316,7 +364,7 @@ ensure_git() {
fail "Git install failed. Install git manually and retry."
fi
emit_json '{"event":"step","name":"git","status":"ok"}'
emit_json step name git status ok
}
parse_args() {
@@ -688,10 +736,10 @@ install_alpine_node() {
local required_version
local sqlite_version
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"start\",\"method\":\"apk\"}"
emit_json step name node status start method apk
if try_link_usable_node_runtime_from_path; then
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"method\":\"system\",\"version\":\"${installed_version}\"}"
emit_json step name node status ok method system version "$installed_version"
return
fi
@@ -718,7 +766,7 @@ install_alpine_node() {
fi
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"method\":\"apk\",\"version\":\"${installed_version}\"}"
emit_json step name node status ok method apk version "$installed_version"
}
set_pnpm_cmd() {
@@ -1090,11 +1138,11 @@ install_node() {
fi
if linked_node_is_usable; then
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"skip\",\"path\":\"${dir//\"/\\\\\\\"}\"}"
emit_json step name node status skip path "$dir"
return
fi
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"start\",\"version\":\"${NODE_VERSION}\"}"
emit_json step name node status start version "$NODE_VERSION"
log "Installing Node ${NODE_VERSION} (user-space)..."
mkdir -p "${PREFIX}/tools"
@@ -1135,7 +1183,7 @@ install_node() {
sqlite_version="$(linked_node_sqlite_version)"
fail "Installed Node ${NODE_VERSION} must provide Node >= ${required_version} with WAL-reset-safe SQLite; found Node ${installed_version}, SQLite ${sqlite_version}. Re-run with --node-version 24.19.0 (or newer)"
fi
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"version\":\"${NODE_VERSION}\"}"
emit_json step name node status ok version "$NODE_VERSION"
}
ensure_pnpm() {
@@ -1149,27 +1197,27 @@ ensure_pnpm() {
fi
if [[ -x "$(node_dir)/bin/corepack" ]]; then
emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"start\",\"method\":\"corepack\"}"
emit_json step name pnpm status start method corepack
log "Installing pnpm via Corepack..."
"$(node_dir)/bin/corepack" enable >/dev/null 2>&1 || true
# Corepack downloads fail hard on npm registry key rotation (its bundled
# signature set goes stale); the npm fallback below must stay reachable.
if "$(node_dir)/bin/corepack" prepare pnpm@11 --activate; then
if detect_pnpm_cmd && pnpm_cmd_is_ready && [[ "$("${PNPM_CMD[@]}" --version 2>/dev/null || true)" =~ ^11\. ]]; then
emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"ok\"}"
emit_json step name pnpm status ok
return 0
fi
else
emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"warn\",\"reason\":\"corepack-failed\"}"
emit_json step name pnpm status warn reason corepack-failed
log "Corepack could not provision pnpm; falling back to npm."
fi
fi
emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"start\",\"method\":\"npm\"}"
emit_json step name pnpm status start method npm
log "Installing pnpm via npm..."
"$(npm_bin)" install -g --prefix "$PREFIX" pnpm@11
detect_pnpm_cmd || true
emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"ok\"}"
emit_json step name pnpm status ok
return 0
}
@@ -1201,7 +1249,7 @@ fix_npm_prefix_if_needed() {
done
export PATH="${target}/bin:${PATH}"
emit_json "{\"event\":\"step\",\"name\":\"npm-prefix\",\"status\":\"ok\",\"prefix\":\"${target//\"/\\\"}\"}"
emit_json step name npm-prefix status ok prefix "$target"
log "Configured npm prefix to ${target}"
}
@@ -1376,7 +1424,7 @@ install_openclaw() {
npm_cmd="$(npm_bin)"
local npm_cwd="$PWD"
lifecycle_arg="$(npm_lifecycle_allow_arg "$npm_cmd" "$install_spec" "$npm_cwd")" || return 1
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"start\",\"version\":\"${requested}\"}"
emit_json step name openclaw status start version "$requested"
log "Installing OpenClaw (${requested})..."
if [[ "$SET_NPM_PREFIX" -eq 1 ]]; then
fix_npm_prefix_if_needed
@@ -1391,7 +1439,7 @@ install_openclaw() {
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" "${npm_install_args[@]}" || [[ ! -f "$installed_entry" || -e "$install_guard" ]]; then
log "npm install openclaw@${resolved_requested} did not produce a usable package; retrying once"
if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" "${npm_install_args[@]}" || [[ ! -f "$installed_entry" || -e "$install_guard" ]]; then
emit_json '{"event":"error","message":"npm install did not produce a usable OpenClaw package"}'
emit_json error message "npm install did not produce a usable OpenClaw package"
log "ERROR: npm install did not produce a usable OpenClaw package"
return 1
fi
@@ -1403,7 +1451,7 @@ install_openclaw() {
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "$(node_dir)/lib/node_modules/openclaw/dist/entry.js" "\$@"
EOF
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"ok\",\"version\":\"${requested}\"}"
emit_json step name openclaw status ok version "$requested"
}
ensure_pnpm_git_prepare_allowlist() {
@@ -1534,18 +1582,18 @@ install_openclaw_from_git() {
repo_dir="$(cd "$(dirname "$repo_dir")" && pwd -P)/$(basename "$repo_dir")"
fi
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"start\",\"method\":\"git\",\"repo\":\"${repo_url//\"/\\\"}\"}"
emit_json step name openclaw status start method git repo "$repo_url"
if [[ -d "$repo_dir/.git" ]]; then
log "Installing Openclaw from git checkout: ${repo_dir}"
else
log "Installing Openclaw from GitHub (${repo_url})..."
fi
emit_json '{"event":"step","name":"git-tools","status":"start"}'
emit_json step name git-tools status start
ensure_git
ensure_pnpm
ensure_pnpm_binary_for_scripts
emit_json '{"event":"step","name":"git-tools","status":"ok"}'
emit_json step name git-tools status ok
if [[ -d "$repo_dir/.git" ]] &&
! git --git-dir="$repo_dir/.git" --work-tree="$repo_dir" rev-parse --verify --quiet 'HEAD^{commit}' >/dev/null 2>&1; then
@@ -1556,17 +1604,17 @@ install_openclaw_from_git() {
:
elif [[ -d "$repo_dir" ]]; then
if [[ -z "$(ls -A "$repo_dir" 2>/dev/null || true)" ]]; then
emit_json '{"event":"step","name":"git-clone","status":"start"}'
emit_json step name git-clone status start
clone_git_checkout_transactionally "$repo_url" "$repo_dir"
emit_json '{"event":"step","name":"git-clone","status":"ok"}'
emit_json step name git-clone status ok
fresh_checkout=1
else
fail "Git install dir exists but is not a git repo: ${repo_dir}"
fi
else
emit_json '{"event":"step","name":"git-clone","status":"start"}'
emit_json step name git-clone status start
clone_git_checkout_transactionally "$repo_url" "$repo_dir"
emit_json '{"event":"step","name":"git-clone","status":"ok"}'
emit_json step name git-clone status ok
fresh_checkout=1
fi
@@ -1575,15 +1623,15 @@ install_openclaw_from_git() {
if [[ -z "$(git -C "$repo_dir" status --porcelain 2>/dev/null || true)" ]]; then
log "Using git ref: ${git_ref}"
if [[ "$fresh_checkout" -eq 0 ]]; then
emit_json '{"event":"step","name":"git-update","status":"start"}'
emit_json step name git-update status start
fi
checkout_git_openclaw_ref "$repo_dir" "$git_ref"
if [[ "$fresh_checkout" -eq 0 ]]; then
emit_json '{"event":"step","name":"git-update","status":"ok"}'
emit_json step name git-update status ok
fi
else
log "Repo is dirty; skipping git checkout/update"
emit_json '{"event":"step","name":"git-update","status":"warn","reason":"dirty"}'
emit_json step name git-update status warn reason dirty
fi
if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then
@@ -1601,20 +1649,20 @@ install_openclaw_from_git() {
local install_lockfile_flag
install_lockfile_flag="$(git_install_lockfile_flag "$repo_dir" "$git_ref")"
emit_json '{"event":"step","name":"dependencies","status":"start"}'
emit_json step name dependencies status start
CI="${CI:-true}" run_pnpm -C "$repo_dir" install "$install_lockfile_flag"
emit_json '{"event":"step","name":"dependencies","status":"ok"}'
emit_json step name dependencies status ok
emit_json '{"event":"step","name":"control-ui","status":"start"}'
emit_json step name control-ui status start
if ! run_pnpm -C "$repo_dir" ui:build; then
log "UI build failed; continuing (CLI may still work)"
emit_json '{"event":"step","name":"control-ui","status":"warn"}'
emit_json step name control-ui status warn
else
emit_json '{"event":"step","name":"control-ui","status":"ok"}'
emit_json step name control-ui status ok
fi
emit_json '{"event":"step","name":"cli-build","status":"start"}'
emit_json step name cli-build status start
run_pnpm -C "$repo_dir" build
emit_json '{"event":"step","name":"cli-build","status":"ok"}'
emit_json step name cli-build status ok
mkdir -p "${PREFIX}/bin"
publish_executable_wrapper "${PREFIX}/bin/openclaw" <<EOF
@@ -1622,7 +1670,7 @@ install_openclaw_from_git() {
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "${repo_dir}/dist/entry.js" "\$@"
EOF
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"ok\",\"method\":\"git\"}"
emit_json step name openclaw status ok method git
}
is_gateway_daemon_loaded() {
@@ -1670,15 +1718,15 @@ refresh_gateway_service_if_loaded() {
fi
if ! is_gateway_daemon_loaded "$claw"; then
emit_json '{"event":"step","name":"gateway-service","status":"skip","reason":"not-loaded"}'
emit_json step name gateway-service status skip reason not-loaded
return 0
fi
emit_json '{"event":"step","name":"gateway-service","status":"start"}'
emit_json step name gateway-service status start
log "Refreshing loaded gateway service..."
if ! "$claw" gateway install --force >/dev/null 2>&1; then
emit_json '{"event":"step","name":"gateway-service","status":"warn","reason":"install-failed"}'
emit_json step name gateway-service status warn reason install-failed
log "Warning: gateway service refresh failed; continuing."
return 0
fi
@@ -1686,7 +1734,7 @@ refresh_gateway_service_if_loaded() {
# `gateway install --force` activates the replacement service. A second
# restart can kill startup migrations and strand their lock until expiry.
"$claw" gateway status --probe --json >/dev/null 2>&1 || true
emit_json '{"event":"step","name":"gateway-service","status":"ok"}'
emit_json step name gateway-service status ok
}
main() {
@@ -1727,7 +1775,7 @@ main() {
commit_wrapper_backup
refresh_gateway_service_if_loaded
emit_json "{\"event\":\"done\",\"ok\":true,\"version\":\"${installed_version//\"/\\\"}\"}"
emit_json "done" version "$installed_version"
log "OpenClaw installed (${installed_version})."
if [[ "$RUN_ONBOARD" -eq 1 ]]; then
+56
View File
@@ -207,6 +207,62 @@ describe("install-cli.sh", () => {
}
});
it("round-trips dynamic installer values through independent NDJSON records", () => {
const root = tempDirs.make("openclaw-install-cli-json-events-");
const dynamicValue = `quote"\\lobster🦞${String.fromCharCode(
...Array.from({ length: 31 }, (_, index) => index + 1),
)}end`;
const repo = join(root, dynamicValue);
const legacyDir = join(repo, "Peekaboo");
const fakeNode = join(root, "node");
mkdirSync(legacyDir, { recursive: true });
writeFileSync(fakeNode, '#!/bin/bash\nprintf "%s" "$EVENT_VALUE"\n');
chmodSync(fakeNode, 0o755);
const success = runInstallCliShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"JSON=1",
'cleanup_legacy_submodules "$REPO"',
"try_link_usable_node_runtime_from_path() { return 0; }",
`node_bin() { printf '%s\\n' ${JSON.stringify(fakeNode)}; }`,
"install_alpine_node",
'emit_json done version "$EVENT_VALUE"',
].join("\n"),
{ EVENT_VALUE: dynamicValue, REPO: repo },
);
expect(success.status, success.stderr || success.stdout).toBe(0);
expect(existsSync(legacyDir)).toBe(false);
const successLines = success.stdout.trimEnd().split("\n");
expect(successLines).toHaveLength(5);
const successEvents = successLines.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(successEvents).toEqual([
{ event: "step", name: "legacy-submodule", status: "start", path: legacyDir },
{ event: "step", name: "legacy-submodule", status: "ok", path: legacyDir },
{ event: "step", name: "node", status: "start", method: "apk" },
{ event: "step", name: "node", status: "ok", method: "system", version: dynamicValue },
{ event: "done", ok: true, version: dynamicValue },
]);
const failure = runInstallCliShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"JSON=1",
'fail "$EVENT_VALUE"',
].join("\n"),
{ EVENT_VALUE: dynamicValue },
);
expect(failure.status).toBe(1);
expect(failure.stdout.trimEnd().split("\n")).toHaveLength(1);
expect(JSON.parse(failure.stdout)).toEqual({ event: "error", message: dynamicValue });
});
it("rejects a git checkout without a commit before updating it", () => {
const result = runInstallCliShell(`
set -euo pipefail