diff --git a/install.sh b/install.sh index 1fa70b9c1..5ed7cafc7 100755 --- a/install.sh +++ b/install.sh @@ -1,4 +1,12 @@ #!/bin/bash +{ +# Truncation guard: this brace group opens on line 2 and closes on the last +# line, so bash must read the entire file before it runs any command. On the +# `curl ... | bash` path, a download cut off anywhere before the final `}` is +# an unterminated compound command, which bash reports as a syntax error at +# EOF without running anything. Without the guard, a cut right after `set` +# would run the bare builtin and print every shell variable, and a cut right +# after `main` would run the installer without the caller's flags. set -euo pipefail PACKAGE="rea-agents" @@ -21,104 +29,112 @@ usage() { cat <<'EOF' Usage: install.sh [--version VERSION] [--dry-run] [--no-setup] [--no-prompt] [--verbose] -Installs only the REA CLI. Run `rea setup` separately to configure Hopper and agents. +Installs the REA CLI, then starts `rea setup` interactively to configure Hopper +and agents when a terminal is available. Pass --no-setup (or --no-prompt) to +skip setup and run `rea setup` later. EOF } -while (($# > 0)); do - case "$1" in - --version) - (($# >= 2)) || fail "--version requires a value." - version="$2" - shift 2 - ;; - --dry-run) - dry_run=true - shift - ;; - --no-setup|--no-prompt) - start_setup=false - shift - ;; - --verbose) - verbose=true - shift - ;; - --help|-h) - usage - exit 0 - ;; - *) fail "unknown option: $1" ;; - esac -done +main() { + while (($# > 0)); do + case "$1" in + --version) + (($# >= 2)) || fail "--version requires a value." + version="$2" + shift 2 + ;; + --dry-run) + dry_run=true + shift + ;; + --no-setup|--no-prompt) + start_setup=false + shift + ;; + --verbose) + verbose=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) fail "unknown option: $1" ;; + esac + done -if [[ "$verbose" == true ]]; then - set -x -fi + if [[ "$verbose" == true ]]; then + set -x + fi -platform="$(uname -s)" -[[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions." -command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer." -command -v node >/dev/null 2>&1 || fail "Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ is required; install it from https://nodejs.org and retry." -command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry." + platform="$(uname -s)" + [[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions." + command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer." + command -v node >/dev/null 2>&1 || fail "Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+ is required; install it from https://nodejs.org and retry." + command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry." -node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" -IFS=. read -r node_major node_minor _node_patch <<<"$node_version" -[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry." -if ! [[ "$_node_patch" =~ ^[0-9]+(\+[0-9A-Za-z.-]+)?$ ]] || - ! ((node_major == 22 && node_minor >= 19 || node_major == 24 && node_minor >= 11 || node_major >= 26)); then - fail "Node.js $node_version is unsupported; use Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+." -fi + node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" + IFS=. read -r node_major node_minor _node_patch <<<"$node_version" + [[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry." + if ! [[ "$_node_patch" =~ ^[0-9]+(\+[0-9A-Za-z.-]+)?$ ]] || + ! ((node_major == 22 && node_minor >= 19 || node_major == 24 && node_minor >= 11 || node_major >= 26)); then + fail "Node.js $node_version is unsupported; use Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+." + fi -if [[ -n "$version" ]]; then - [[ "$version" =~ ^${version_pattern}$ ]] || fail "version must be an exact semantic version." -else - release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry." - tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION." - [[ "$tag" =~ ^rea-agents-(${version_pattern})$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION." - version="${BASH_REMATCH[1]}" -fi + if [[ -n "$version" ]]; then + [[ "$version" =~ ^${version_pattern}$ ]] || fail "version must be an exact semantic version." + else + release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry." + tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION." + [[ "$tag" =~ ^rea-agents-(${version_pattern})$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION." + version="${BASH_REMATCH[1]}" + fi -# npm's exact-version resolver cleans build metadata from the requested version, -# so it may install the base release and make the post-install identity check fail. -if [[ "$version" =~ \+ ]]; then - fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry." -fi + # npm's exact-version resolver cleans build metadata from the requested version, + # so it may install the base release and make the post-install identity check fail. + if [[ "$version" =~ \+ ]]; then + fail "npm cannot install an exact version with build metadata. Pass a version without build metadata, then retry." + fi -install_args=(install --global) -if [[ "$platform" == "Linux" ]]; then - install_args+=(--prefix "$HOME/.local") - install_bin="$HOME/.local/bin/rea" -else - npm_prefix="$(npm prefix --global)" || fail "the npm global prefix could not be read. Repair the npm configuration, then retry." - install_bin="$npm_prefix/bin/rea" -fi -install_args+=("$PACKAGE@$version") + install_args=(install --global) + if [[ "$platform" == "Linux" ]]; then + install_args+=(--prefix "$HOME/.local") + install_bin="$HOME/.local/bin/rea" + else + npm_prefix="$(npm prefix --global)" || fail "the npm global prefix could not be read. Repair the npm configuration, then retry." + install_bin="$npm_prefix/bin/rea" + fi + install_args+=("$PACKAGE@$version") -printf 'REA install plan\n' -printf ' Version: %s\n' "$version" -printf ' Runtime: Node.js %s\n' "$node_version" -printf ' Command:' -printf ' %q' npm "${install_args[@]}" -printf '\n' -printf ' Binary: %s\n' "$install_bin" -printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')" + printf 'REA install plan\n' + printf ' Version: %s\n' "$version" + printf ' Runtime: Node.js %s\n' "$node_version" + printf ' Command:' + printf ' %q' npm "${install_args[@]}" + printf '\n' + printf ' Binary: %s\n' "$install_bin" + printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')" -if [[ "$dry_run" == true ]]; then - printf 'Dry run complete; no changes made.\n' - exit 0 -fi + if [[ "$dry_run" == true ]]; then + printf 'Dry run complete; no changes made.\n' + exit 0 + fi -printf 'Installing %s@%s...\n' "$PACKAGE" "$version" -npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry." -[[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry." -installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry." -[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry." + printf 'Installing %s@%s...\n' "$PACKAGE" "$version" + npm "${install_args[@]}" || fail "npm could not install REA. Check registry access and npm permissions, then retry." + [[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry." + installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry." + [[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry." -printf 'REA %s is installed.\n' "$version" -if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then - printf 'Starting REA setup...\n' - "$install_bin" setup /dev/tty -else - printf 'Run %s setup to configure Hopper and agents.\n' "$install_bin" -fi + printf 'REA %s is installed.\n' "$version" + if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then + printf 'Starting REA setup...\n' + "$install_bin" setup /dev/tty + else + printf 'Run %s setup to configure Hopper and agents.\n' "$install_bin" + fi +} + +main "$@" +exit $? +} diff --git a/tests/boundary/filesystem/installScript.test.ts b/tests/boundary/filesystem/installScript.test.ts index 4984d37ee..30af7d61d 100644 --- a/tests/boundary/filesystem/installScript.test.ts +++ b/tests/boundary/filesystem/installScript.test.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { chmod, mkdir, @@ -172,6 +172,99 @@ describe("curl installer scenarios", { timeout: 20_000 }, () => { }); }); +describe("truncated installer download", { timeout: 20_000 }, () => { + it("runs nothing from a download truncated after the npm install line", async () => { + const fixture = await createFixture(); + const source = await readFile(join(process.cwd(), "install.sh"), "utf8"); + const installLine = source.indexOf('npm "${install_args[@]}" ||'); + expect(installLine).toBeGreaterThan(0); + const truncated = join(fixture.home, "..", "install-truncated.sh"); + await writeFile( + truncated, + source.slice(0, source.indexOf("\n", installLine) + 1), + ); + const failure: unknown = await runInstaller( + fixture, + ["--version", "0.3.0", "--no-setup"], + {}, + truncated, + ).then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(Error); + expect(getProcessOutput(failure, "stdout")).toBe(""); + expect(getProcessOutput(failure, "stderr")).toContain("syntax error"); + await expect(readFile(fixture.npmLog, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); + +describe( + "installer download truncated at any byte", + { + timeout: 180_000, + }, + () => { + // Every prefix of the script, from the empty download to the full file, as + // `curl ... | bash -s -- --dry-run` would see it. Covers the preamble (a + // cut right after `set` would print every shell variable), the body, and + // the final invocation (a cut right after `main` would drop the flags). + it("runs nothing and prints nothing until the closing brace arrives", async () => { + const source = await readFile(join(process.cwd(), "install.sh")); + expect(source.subarray(0, 14).toString()).toBe("#!/bin/bash\n{\n"); + const groupOpen = source.indexOf("\n{\n") + 2; + const groupEnd = source.lastIndexOf("\n}\n") + 2; + expect(groupEnd).toBe(source.length - 1); + expect(source.subarray(groupOpen, groupEnd).toString()).toContain( + "set -euo pipefail", + ); + const fixture = await createFixture(); + const offsets = Array.from( + { length: source.length + 1 }, + (_, offset) => offset, + ); + const failures: string[] = []; + const check = async (offset: number): Promise => { + const result = await runPipedInstaller( + fixture, + source.subarray(0, offset), + ["--dry-run"], + { SECRET_TOKEN: "inherited-secret" }, + ); + const context = `truncated at byte ${offset} after ${JSON.stringify( + source.subarray(Math.max(0, offset - 24), offset).toString(), + )}`; + if (offset >= groupEnd) { + if (result.code !== 0 || !result.stdout.includes("no changes made")) + failures.push(`${context}: complete script did not dry-run`); + return; + } + if (result.stdout !== "") + failures.push(`${context}: stdout ${JSON.stringify(result.stdout)}`); + if (offset >= groupOpen) { + if (result.code === 0) failures.push(`${context}: exit code 0`); + if (!/syntax error|unexpected EOF/u.test(result.stderr)) + failures.push( + `${context}: stderr ${JSON.stringify(result.stderr)}`, + ); + } else if (result.code !== 0 || result.stderr !== "") + failures.push(`${context}: preamble did not exit quietly`); + }; + for (let index = 0; index < offsets.length; index += 16) + await Promise.all(offsets.slice(index, index + 16).map(check)); + expect(failures).toEqual([]); + await expect(readFile(fixture.npmLog, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(readFile(fixture.reaLog, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }, +); + describe("Linux user-prefix installer", { timeout: 20_000 }, () => { it("prints the executed arguments as a reusable command with shell metacharacters", async () => { const fixture = await createFixture(); @@ -408,8 +501,9 @@ const runInstaller = async ( fixture: InstallerFixture, args: readonly string[] = [], overrides: Readonly> = {}, + script = join(process.cwd(), "install.sh"), ): Promise<{ readonly stdout: string; readonly stderr: string }> => - execFileAsync("/bin/bash", [join(process.cwd(), "install.sh"), ...args], { + execFileAsync("/bin/bash", [script, ...args], { env: { ...process.env, HOME: fixture.home, @@ -423,5 +517,46 @@ const runInstaller = async ( }, }); +/** + * Run the installer as `curl ... | bash -s -- ARGS` does: source on stdin. + * The environment is minimal and does not inherit the test runner's. + */ +const runPipedInstaller = async ( + fixture: InstallerFixture, + source: Uint8Array, + args: readonly string[], + overrides: Readonly> = {}, +): Promise<{ + readonly code: number | null; + readonly stdout: string; + readonly stderr: string; +}> => + new Promise((resolve, reject) => { + const child = spawn("/bin/bash", ["-s", "--", ...args], { + env: { + HOME: fixture.home, + TMPDIR: fixture.temporary, + PATH: `${fixture.bin}:/usr/bin:/bin`, + FAKE_NPM_LOG: fixture.npmLog, + FAKE_REA_LOG: fixture.reaLog, + FAKE_REA_SOURCE: join(fixture.bin, "rea-source"), + FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"), + ...overrides, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(source); + }); + const shellQuote = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'`;