diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 43ee44f833c7..48d9a14b28cc 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -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" </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 diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index 30afc8dfbaef..40661429578c 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -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); + 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