From a039784792e06a7c20364f838c6afaaae4dce3dd Mon Sep 17 00:00:00 2001 From: mshddev Date: Fri, 27 Mar 2026 13:11:30 +0700 Subject: [PATCH 1/2] Add release installer script --- CHANGELOG.md | 2 +- README.md | 25 +++- install.sh | 293 +++++++++++++++++++++++++++++++++++++ install_test.go | 229 +++++++++++++++++++++++++++++ tests/11-install-script.md | 140 ++++++++++++++++++ tests/README.md | 1 + 6 files changed, 688 insertions(+), 2 deletions(-) create mode 100755 install.sh create mode 100644 install_test.go create mode 100644 tests/11-install-script.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9edabc0..f99e285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable user-facing changes to `sonacli` are documented in this file. [CONTRIBUTING.md](CONTRIBUTING.md) for when and how to update it. ## [Unreleased] -- Nothing yet. +- Added a root `install.sh` and simplified `README.md` so users can install `sonacli` directly from `curl` or `wget`. ## [v0.1.0-rc.2] - 2026-03-27 diff --git a/README.md b/README.md index 340d73a..d07f26b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,30 @@ ## Install -### Recommended: build from source +### Quick install + +Install the latest GitHub release: + +```sh +curl -fsSL https://raw.githubusercontent.com/mshddev/sonacli/main/install.sh | sh +``` + +Or with `wget`: + +```sh +wget -qO- https://raw.githubusercontent.com/mshddev/sonacli/main/install.sh | sh +``` + +Install a specific release or choose a custom install directory: + +```sh +curl -fsSL https://raw.githubusercontent.com/mshddev/sonacli/main/install.sh | sh -s -- --version v0.1.0-rc.2 +curl -fsSL https://raw.githubusercontent.com/mshddev/sonacli/main/install.sh | sh -s -- --install-dir "$HOME/.local/bin" +``` + +The installer downloads the matching GitHub release archive for your OS and CPU, verifies `checksums.txt`, installs `sonacli`, and prints a `PATH` hint when needed. + +### Build from source ```sh make build diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..cad7dc3 --- /dev/null +++ b/install.sh @@ -0,0 +1,293 @@ +#!/bin/sh + +set -eu + +REPO="${SONACLI_INSTALL_REPO:-mshddev/sonacli}" +API_BASE="${SONACLI_INSTALL_API_BASE:-https://api.github.com}" +DOWNLOAD_BASE="${SONACLI_INSTALL_DOWNLOAD_BASE:-https://github.com/${REPO}/releases/download}" +VERSION="${VERSION:-}" +INSTALL_DIR="${INSTALL_DIR:-}" +TMPDIR_ROOT="${TMPDIR:-/tmp}" +tmpdir="" + +usage() { + cat <<'EOF' +Install sonacli from a GitHub release. + +Usage: + install.sh [--version ] [--install-dir ] + +Options: + --version Install a specific release tag such as v0.1.0 + --install-dir Install into a specific directory + -h, --help Show this help + +Environment: + VERSION Same as --version + INSTALL_DIR Same as --install-dir +EOF +} + +log() { + printf '%s\n' "$*" +} + +fail() { + printf 'install.sh: %s\n' "$*" >&2 + exit 1 +} + +have_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +cleanup() { + if [ -n "${tmpdir:-}" ] && [ -d "${tmpdir}" ]; then + rm -rf "${tmpdir}" + fi +} + +expand_home() { + value=$1 + case "$value" in + "~/"*) + [ -n "${HOME:-}" ] || fail "HOME is not set, cannot expand ${value}" + printf '%s/%s\n' "$HOME" "${value#~/}" + ;; + *) + printf '%s\n' "$value" + ;; + esac +} + +fetch_text() { + url=$1 + if have_cmd curl; then + curl -fsSL "$url" + return + fi + if have_cmd wget; then + wget -qO- "$url" + return + fi + fail "curl or wget is required" +} + +download_to_file() { + url=$1 + destination=$2 + if have_cmd curl; then + curl -fsSL "$url" -o "$destination" + return + fi + if have_cmd wget; then + wget -qO "$destination" "$url" + return + fi + fail "curl or wget is required" +} + +fetch_release_tag() { + url=$1 + json=$(fetch_text "$url") || return 1 + printf '%s' "$json" | tr '\n' ' ' | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' +} + +resolve_version() { + if [ -n "${VERSION}" ]; then + printf '%s\n' "${VERSION}" + return + fi + + latest_url="${API_BASE%/}/repos/${REPO}/releases/latest" + if tag=$(fetch_release_tag "$latest_url" 2>/dev/null); then + if [ -n "$tag" ]; then + printf '%s\n' "$tag" + return + fi + fi + + releases_url="${API_BASE%/}/repos/${REPO}/releases?per_page=1" + if tag=$(fetch_release_tag "$releases_url" 2>/dev/null); then + if [ -n "$tag" ]; then + printf '%s\n' "$tag" + return + fi + fi + + fail "could not determine a release tag from ${REPO}" +} + +resolve_os() { + case "$(uname -s)" in + Linux) + printf '%s\n' "linux" + ;; + Darwin) + printf '%s\n' "darwin" + ;; + *) + fail "unsupported operating system: $(uname -s)" + ;; + esac +} + +resolve_arch() { + case "$(uname -m)" in + x86_64|amd64) + printf '%s\n' "amd64" + ;; + arm64|aarch64) + printf '%s\n' "arm64" + ;; + *) + fail "unsupported architecture: $(uname -m)" + ;; + esac +} + +resolve_install_dir() { + if [ -n "${INSTALL_DIR}" ]; then + expand_home "${INSTALL_DIR}" + return + fi + + current_binary=$(command -v sonacli 2>/dev/null || true) + if [ -n "$current_binary" ]; then + current_dir=$(dirname "$current_binary") + if [ -d "$current_dir" ] && [ -w "$current_dir" ]; then + printf '%s\n' "$current_dir" + return + fi + fi + + if [ -d "/usr/local/bin" ] && [ -w "/usr/local/bin" ]; then + printf '%s\n' "/usr/local/bin" + return + fi + + if [ -n "${HOME:-}" ]; then + printf '%s\n' "${HOME}/.local/bin" + return + fi + + fail "set INSTALL_DIR to a writable directory" +} + +make_tmpdir() { + if dir=$(mktemp -d "${TMPDIR_ROOT%/}/sonacli-install.XXXXXX" 2>/dev/null); then + printf '%s\n' "$dir" + return + fi + if dir=$(mktemp -d 2>/dev/null); then + printf '%s\n' "$dir" + return + fi + fail "could not create a temporary directory" +} + +sha256_file() { + file=$1 + if have_cmd sha256sum; then + sha256sum "$file" | awk '{print $1}' + return + fi + if have_cmd shasum; then + shasum -a 256 "$file" | awk '{print $1}' + return + fi + if have_cmd openssl; then + openssl dgst -sha256 "$file" | sed 's/^.*= //' + return + fi + fail "sha256sum, shasum, or openssl is required" +} + +verify_checksum() { + asset_name=$1 + archive_path=$2 + checksums_path=$3 + + expected=$(awk -v asset="$asset_name" '$2 == asset { print $1; exit }' "$checksums_path") + [ -n "$expected" ] || fail "could not find ${asset_name} in checksums.txt" + + actual=$(sha256_file "$archive_path") + [ "$expected" = "$actual" ] || fail "checksum verification failed for ${asset_name}" +} + +path_contains() { + dir=$1 + case ":${PATH:-}:" in + *:"$dir":*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$#" -ge 2 ] || fail "--version requires a value" + VERSION=$2 + shift 2 + ;; + --install-dir) + [ "$#" -ge 2 ] || fail "--install-dir requires a value" + INSTALL_DIR=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac + done +} + +main() { + parse_args "$@" + + have_cmd tar || fail "tar is required" + have_cmd mktemp || fail "mktemp is required" + have_cmd uname || fail "uname is required" + + version=$(resolve_version) + os=$(resolve_os) + arch=$(resolve_arch) + install_dir=$(resolve_install_dir) + version_no_v=${version#v} + asset_basename="sonacli_${version_no_v}_${os}_${arch}" + archive_name="${asset_basename}.tar.gz" + + tmpdir=$(make_tmpdir) + trap cleanup EXIT HUP INT TERM + + archive_path="${tmpdir}/${archive_name}" + checksums_path="${tmpdir}/checksums.txt" + package_dir="${tmpdir}/${asset_basename}" + + download_to_file "${DOWNLOAD_BASE%/}/${version}/${archive_name}" "$archive_path" + download_to_file "${DOWNLOAD_BASE%/}/${version}/checksums.txt" "$checksums_path" + verify_checksum "$archive_name" "$archive_path" "$checksums_path" + + mkdir -p "$install_dir" + tar -xzf "$archive_path" -C "$tmpdir" + [ -f "${package_dir}/sonacli" ] || fail "release archive did not contain ${asset_basename}/sonacli" + + cp "${package_dir}/sonacli" "${install_dir}/sonacli" + chmod 755 "${install_dir}/sonacli" + + log "installed sonacli ${version} to ${install_dir}/sonacli" + if ! path_contains "$install_dir"; then + log "add ${install_dir} to your PATH, for example: export PATH=\"${install_dir}:\$PATH\"" + fi +} + +main "$@" diff --git a/install_test.go b/install_test.go new file mode 100644 index 0000000..cba4425 --- /dev/null +++ b/install_test.go @@ -0,0 +1,229 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestInstallScriptInstallsSpecificVersion(t *testing.T) { + t.Parallel() + + server := newInstallTestServer(t, installTestConfig{ + tag: "v1.2.3", + latestTag: "v1.2.3", + releasesTag: "v1.2.3", + latestStatus: http.StatusOK, + releasesStatus: http.StatusOK, + }) + installDir := t.TempDir() + + output := runInstallScript(t, server, "--version", "v1.2.3", "--install-dir", installDir) + verifyInstalledBinary(t, installDir, "v1.2.3") + + if !strings.Contains(output, "installed sonacli v1.2.3 to "+filepath.Join(installDir, "sonacli")) { + t.Fatalf("install output %q did not report the installed binary path", output) + } +} + +func TestInstallScriptFallsBackToFirstReleaseWhenLatestEndpointFails(t *testing.T) { + t.Parallel() + + server := newInstallTestServer(t, installTestConfig{ + tag: "v2.0.0-rc.1", + releasesTag: "v2.0.0-rc.1", + latestStatus: http.StatusNotFound, + releasesStatus: http.StatusOK, + }) + installDir := t.TempDir() + + runInstallScript(t, server, "--install-dir", installDir) + verifyInstalledBinary(t, installDir, "v2.0.0-rc.1") +} + +type installTestConfig struct { + tag string + latestTag string + releasesTag string + latestStatus int + releasesStatus int +} + +type installTestServer struct { + apiBase string + downloadBase string +} + +func newInstallTestServer(t *testing.T, cfg installTestConfig) installTestServer { + t.Helper() + + goos, goarch := installReleaseTarget(t) + assetBase := fmt.Sprintf("sonacli_%s_%s_%s", strings.TrimPrefix(cfg.tag, "v"), goos, goarch) + archiveName := assetBase + ".tar.gz" + binaryContents := "#!/bin/sh\nprintf '%s\\n' \"fake sonacli " + cfg.tag + "\"\n" + archiveBytes := makeInstallArchive(t, assetBase, binaryContents) + checksum := sha256.Sum256(archiveBytes) + checksums := fmt.Sprintf("%s %s\n", hex.EncodeToString(checksum[:]), archiveName) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/mshddev/sonacli/releases/latest": + if cfg.latestStatus != http.StatusOK { + http.Error(w, http.StatusText(cfg.latestStatus), cfg.latestStatus) + return + } + fmt.Fprintf(w, `{"tag_name":%q}`, cfg.latestTag) + case r.URL.Path == "/repos/mshddev/sonacli/releases": + if cfg.releasesStatus != http.StatusOK { + http.Error(w, http.StatusText(cfg.releasesStatus), cfg.releasesStatus) + return + } + fmt.Fprintf(w, `[{"tag_name":%q}]`, cfg.releasesTag) + case r.URL.Path == "/download/"+cfg.tag+"/"+archiveName: + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write(archiveBytes) + case r.URL.Path == "/download/"+cfg.tag+"/checksums.txt": + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = io.WriteString(w, checksums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + return installTestServer{ + apiBase: server.URL, + downloadBase: server.URL + "/download", + } +} + +func runInstallScript(t *testing.T, server installTestServer, args ...string) string { + t.Helper() + + scriptArgs := append([]string{"./install.sh"}, args...) + cmd := exec.Command("sh", scriptArgs...) + cmd.Env = append(os.Environ(), + "SONACLI_INSTALL_API_BASE="+server.apiBase, + "SONACLI_INSTALL_DOWNLOAD_BASE="+server.downloadBase, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install.sh failed: %v\n%s", err, output) + } + + return string(output) +} + +func verifyInstalledBinary(t *testing.T, installDir, version string) { + t.Helper() + + binaryPath := filepath.Join(installDir, "sonacli") + info, err := os.Stat(binaryPath) + if err != nil { + t.Fatalf("installed binary missing: %v", err) + } + if info.Mode()&0o111 == 0 { + t.Fatalf("installed binary %s is not executable", binaryPath) + } + + cmd := exec.Command(binaryPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("installed binary failed: %v\n%s", err, output) + } + + if string(output) != "fake sonacli "+version+"\n" { + t.Fatalf("installed binary output = %q, want %q", output, "fake sonacli "+version+"\n") + } +} + +func makeInstallArchive(t *testing.T, assetBase, binaryContents string) []byte { + t.Helper() + + tempDir := t.TempDir() + archivePath := filepath.Join(tempDir, assetBase+".tar.gz") + file, err := os.Create(archivePath) + if err != nil { + t.Fatalf("create archive: %v", err) + } + + gzipWriter := gzip.NewWriter(file) + tarWriter := tar.NewWriter(gzipWriter) + + writeTarFile(t, tarWriter, assetBase+"/", 0o755, nil, tar.TypeDir) + writeTarFile(t, tarWriter, assetBase+"/sonacli", 0o755, []byte(binaryContents), tar.TypeReg) + + if err := tarWriter.Close(); err != nil { + t.Fatalf("close tar writer: %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("close gzip writer: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close archive file: %v", err) + } + + archiveBytes, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("read archive: %v", err) + } + return archiveBytes +} + +func writeTarFile(t *testing.T, tw *tar.Writer, name string, mode int64, content []byte, typeflag byte) { + t.Helper() + + header := &tar.Header{ + Name: name, + Mode: mode, + Size: int64(len(content)), + Typeflag: typeflag, + } + if typeflag == tar.TypeDir { + header.Size = 0 + } + + if err := tw.WriteHeader(header); err != nil { + t.Fatalf("write tar header for %s: %v", name, err) + } + if len(content) == 0 { + return + } + if _, err := tw.Write(content); err != nil { + t.Fatalf("write tar contents for %s: %v", name, err) + } +} + +func installReleaseTarget(t *testing.T) (string, string) { + t.Helper() + + var goos string + switch runtime.GOOS { + case "linux", "darwin": + goos = runtime.GOOS + default: + t.Skipf("unsupported test GOOS %q", runtime.GOOS) + } + + var goarch string + switch runtime.GOARCH { + case "amd64", "arm64": + goarch = runtime.GOARCH + default: + t.Skipf("unsupported test GOARCH %q", runtime.GOARCH) + } + + return goos, goarch +} diff --git a/tests/11-install-script.md b/tests/11-install-script.md new file mode 100644 index 0000000..e13b1cc --- /dev/null +++ b/tests/11-install-script.md @@ -0,0 +1,140 @@ +# Install Script End-to-End Case + +This case file defines end-to-end coverage for the root `install.sh` installer. + +## Preconditions + +- Run from the repository root. +- Keep one isolated temporary `HOME` for the full run. +- `python3`, `tar`, and either `shasum` or `sha256sum` must be available. + +## Shared Setup + +```sh +export ORIGINAL_HOME="${HOME:-}" +export TEST_HOME="$(mktemp -d)" +export HOME="$TEST_HOME" +export INSTALL_FIXTURE_ROOT="$(mktemp -d)" +export INSTALL_STDOUT_FILE="$(mktemp)" +export INSTALL_STDERR_FILE="$(mktemp)" +export INSTALL_BIN_DIR="$HOME/.local/bin" +export TEST_VERSION="v9.8.7" + +case "$(uname -s)" in + Linux) export TEST_GOOS="linux" ;; + Darwin) export TEST_GOOS="darwin" ;; + *) echo "unsupported OS" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) export TEST_GOARCH="amd64" ;; + arm64|aarch64) export TEST_GOARCH="arm64" ;; + *) echo "unsupported architecture" >&2; exit 1 ;; +esac + +export TEST_ASSET_BASENAME="sonacli_${TEST_VERSION#v}_${TEST_GOOS}_${TEST_GOARCH}" + +mkdir -p "$INSTALL_FIXTURE_ROOT/repos/mshddev/sonacli/releases" +mkdir -p "$INSTALL_FIXTURE_ROOT/download/$TEST_VERSION" +mkdir -p "$INSTALL_FIXTURE_ROOT/$TEST_ASSET_BASENAME" + +cat >"$INSTALL_FIXTURE_ROOT/repos/mshddev/sonacli/releases/latest" <"$INSTALL_FIXTURE_ROOT/$TEST_ASSET_BASENAME/sonacli" </dev/null 2>&1; then + ( + cd "$INSTALL_FIXTURE_ROOT/download/$TEST_VERSION" && + shasum -a 256 "$TEST_ASSET_BASENAME.tar.gz" > checksums.txt + ) +else + ( + cd "$INSTALL_FIXTURE_ROOT/download/$TEST_VERSION" && + sha256sum "$TEST_ASSET_BASENAME.tar.gz" > checksums.txt + ) +fi + +export INSTALL_SERVER_PORT_FILE="$INSTALL_FIXTURE_ROOT/server.port" +export INSTALL_SERVER_LOG="$INSTALL_FIXTURE_ROOT/server.log" + +python3 - <<'PY' "$INSTALL_FIXTURE_ROOT" "$INSTALL_SERVER_PORT_FILE" >"$INSTALL_SERVER_LOG" 2>&1 & +import functools +import http.server +import pathlib +import socketserver +import sys + +root = pathlib.Path(sys.argv[1]) +port_file = pathlib.Path(sys.argv[2]) +handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(root)) + +with socketserver.TCPServer(("127.0.0.1", 0), handler) as httpd: + port_file.write_text(str(httpd.server_address[1]), encoding="utf-8") + httpd.serve_forever() +PY + +export INSTALL_SERVER_PID=$! + +while [ ! -s "$INSTALL_SERVER_PORT_FILE" ]; do + sleep 1 +done + +export INSTALL_SERVER_PORT="$(cat "$INSTALL_SERVER_PORT_FILE")" +``` + +## Case INSTALL-SCRIPT-01 + +Install the latest release from the local fixture server. + +```sh +PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ +SONACLI_INSTALL_API_BASE="http://127.0.0.1:$INSTALL_SERVER_PORT" \ +SONACLI_INSTALL_DOWNLOAD_BASE="http://127.0.0.1:$INSTALL_SERVER_PORT/download" \ +sh ./install.sh --install-dir "$INSTALL_BIN_DIR" >"$INSTALL_STDOUT_FILE" 2>"$INSTALL_STDERR_FILE" +``` + +Expected: + +- Exit code `0` +- `"$INSTALL_BIN_DIR/sonacli"` exists and is executable +- stdout reports the installed path +- stderr is empty + +Verify the installed binary: + +```sh +"$INSTALL_BIN_DIR/sonacli" +``` + +Expected stdout: + +```text +fake sonacli v9.8.7 +``` + +## Cleanup + +```sh +kill "$INSTALL_SERVER_PID" +wait "$INSTALL_SERVER_PID" 2>/dev/null || true +rm -f "$INSTALL_STDOUT_FILE" "$INSTALL_STDERR_FILE" +rm -rf "$INSTALL_FIXTURE_ROOT" "$TEST_HOME" +if [ -n "${ORIGINAL_HOME:-}" ]; then + export HOME="$ORIGINAL_HOME" +else + unset HOME +fi +unset ORIGINAL_HOME TEST_HOME INSTALL_FIXTURE_ROOT INSTALL_STDOUT_FILE INSTALL_STDERR_FILE INSTALL_BIN_DIR +unset TEST_VERSION TEST_GOOS TEST_GOARCH TEST_ASSET_BASENAME INSTALL_SERVER_PORT_FILE INSTALL_SERVER_LOG +unset INSTALL_SERVER_PID INSTALL_SERVER_PORT diff --git a/tests/README.md b/tests/README.md index 7710cf2..2e4aaf7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -103,6 +103,7 @@ Case files that generate temporary tokens are responsible for revoking them duri | [08-skill-uninstall.md](08-skill-uninstall.md) | No | Uses isolated HOME and managed skill fixtures only | | [09-project-list.md](09-project-list.md) | Yes | Requires saved auth config and the sample project seeded during shared setup | | [10-group-commands.md](10-group-commands.md) | No | CLI-only coverage for grouped commands rejecting unknown subcommands | +| [11-install-script.md](11-install-script.md) | No | Uses a local HTTP fixture to validate the release installer script | Each case file may define additional requirements beyond the shared setup, read the case file for details. From d02599ccf11447da3af8fb59619cf95cff840092 Mon Sep 17 00:00:00 2001 From: mshddev Date: Fri, 27 Mar 2026 13:14:22 +0700 Subject: [PATCH 2/2] Prepare v0.1.0-rc.3 release --- CHANGELOG.md | 6 +- tests/11-install-script.md | 124 ++++++++++++++++++++++--------------- 2 files changed, 79 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f99e285..87aecf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable user-facing changes to `sonacli` are documented in this file. [CONTRIBUTING.md](CONTRIBUTING.md) for when and how to update it. ## [Unreleased] +- Nothing yet. + +## [v0.1.0-rc.3] - 2026-03-27 - Added a root `install.sh` and simplified `README.md` so users can install `sonacli` directly from `curl` or `wget`. ## [v0.1.0-rc.2] - 2026-03-27 @@ -31,6 +34,7 @@ All notable user-facing changes to `sonacli` are documented in this file. macOS. - Security policy for vulnerability reporting and supported version guidance. -[Unreleased]: https://github.com/mshddev/sonacli/compare/v0.1.0-rc.2...HEAD +[Unreleased]: https://github.com/mshddev/sonacli/compare/v0.1.0-rc.3...HEAD +[v0.1.0-rc.3]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0-rc.3 [v0.1.0-rc.2]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0-rc.2 [v0.1.0-rc.1]: https://github.com/mshddev/sonacli/releases/tag/v0.1.0-rc.1 diff --git a/tests/11-install-script.md b/tests/11-install-script.md index e13b1cc..8ef6490 100644 --- a/tests/11-install-script.md +++ b/tests/11-install-script.md @@ -1,38 +1,47 @@ -# Install Script End-to-End Case +# 11 Install Script This case file defines end-to-end coverage for the root `install.sh` installer. -## Preconditions +## Additional Requirements - Run from the repository root. -- Keep one isolated temporary `HOME` for the full run. +- Use the shared isolated `HOME` approach from [README.md](README.md). - `python3`, `tar`, and either `shasum` or `sha256sum` must be available. +- Write the final Markdown report to `tests/results/11-install-script-testresults.md`. -## Shared Setup +## Cases + +### INSTALL-SCRIPT-01 Install The Latest Release From A Local Fixture Server + +#### Execute ```sh -export ORIGINAL_HOME="${HOME:-}" -export TEST_HOME="$(mktemp -d)" -export HOME="$TEST_HOME" -export INSTALL_FIXTURE_ROOT="$(mktemp -d)" -export INSTALL_STDOUT_FILE="$(mktemp)" -export INSTALL_STDERR_FILE="$(mktemp)" -export INSTALL_BIN_DIR="$HOME/.local/bin" -export TEST_VERSION="v9.8.7" +STDOUT_FILE="$(mktemp)" +STDERR_FILE="$(mktemp)" +VERIFY_STDOUT_FILE="$(mktemp)" +VERIFY_STDERR_FILE="$(mktemp)" +EXPECTED_STDOUT_FILE="$(mktemp)" +EXPECTED_STDERR_FILE="$(mktemp)" +EXPECTED_VERIFY_STDOUT_FILE="$(mktemp)" +ACTUAL_FILE_LIST="$(mktemp)" +EXPECTED_FILE_LIST="$(mktemp)" +INSTALL_FIXTURE_ROOT="$(mktemp -d)" +INSTALL_BIN_DIR="$HOME/.local/bin" +TEST_VERSION="v9.8.7" case "$(uname -s)" in - Linux) export TEST_GOOS="linux" ;; - Darwin) export TEST_GOOS="darwin" ;; + Linux) TEST_GOOS="linux" ;; + Darwin) TEST_GOOS="darwin" ;; *) echo "unsupported OS" >&2; exit 1 ;; esac case "$(uname -m)" in - x86_64|amd64) export TEST_GOARCH="amd64" ;; - arm64|aarch64) export TEST_GOARCH="arm64" ;; + x86_64|amd64) TEST_GOARCH="amd64" ;; + arm64|aarch64) TEST_GOARCH="arm64" ;; *) echo "unsupported architecture" >&2; exit 1 ;; esac -export TEST_ASSET_BASENAME="sonacli_${TEST_VERSION#v}_${TEST_GOOS}_${TEST_GOARCH}" +TEST_ASSET_BASENAME="sonacli_${TEST_VERSION#v}_${TEST_GOOS}_${TEST_GOARCH}" mkdir -p "$INSTALL_FIXTURE_ROOT/repos/mshddev/sonacli/releases" mkdir -p "$INSTALL_FIXTURE_ROOT/download/$TEST_VERSION" @@ -65,8 +74,23 @@ else ) fi -export INSTALL_SERVER_PORT_FILE="$INSTALL_FIXTURE_ROOT/server.port" -export INSTALL_SERVER_LOG="$INSTALL_FIXTURE_ROOT/server.log" +cat >"$EXPECTED_STDOUT_FILE" <"$EXPECTED_STDERR_FILE" +printf '%s\n' "fake sonacli $TEST_VERSION" >"$EXPECTED_VERIFY_STDOUT_FILE" + +cat >"$EXPECTED_FILE_LIST" <"$INSTALL_SERVER_LOG" 2>&1 & import functools @@ -84,57 +108,57 @@ with socketserver.TCPServer(("127.0.0.1", 0), handler) as httpd: httpd.serve_forever() PY -export INSTALL_SERVER_PID=$! +INSTALL_SERVER_PID=$! while [ ! -s "$INSTALL_SERVER_PORT_FILE" ]; do sleep 1 done -export INSTALL_SERVER_PORT="$(cat "$INSTALL_SERVER_PORT_FILE")" -``` - -## Case INSTALL-SCRIPT-01 +INSTALL_SERVER_PORT="$(cat "$INSTALL_SERVER_PORT_FILE")" -Install the latest release from the local fixture server. - -```sh PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ SONACLI_INSTALL_API_BASE="http://127.0.0.1:$INSTALL_SERVER_PORT" \ SONACLI_INSTALL_DOWNLOAD_BASE="http://127.0.0.1:$INSTALL_SERVER_PORT/download" \ -sh ./install.sh --install-dir "$INSTALL_BIN_DIR" >"$INSTALL_STDOUT_FILE" 2>"$INSTALL_STDERR_FILE" -``` +sh ./install.sh --install-dir "$INSTALL_BIN_DIR" >"$STDOUT_FILE" 2>"$STDERR_FILE" +EXIT_CODE=$? -Expected: +"$INSTALL_BIN_DIR/sonacli" >"$VERIFY_STDOUT_FILE" 2>"$VERIFY_STDERR_FILE" +VERIFY_EXIT_CODE=$? -- Exit code `0` -- `"$INSTALL_BIN_DIR/sonacli"` exists and is executable -- stdout reports the installed path -- stderr is empty +find "$HOME" -print | LC_ALL=C sort >"$ACTUAL_FILE_LIST" +``` -Verify the installed binary: +#### Verify ```sh -"$INSTALL_BIN_DIR/sonacli" +test "$EXIT_CODE" -eq 0 +test "$VERIFY_EXIT_CODE" -eq 0 +cmp -s "$STDOUT_FILE" "$EXPECTED_STDOUT_FILE" +cmp -s "$STDERR_FILE" "$EXPECTED_STDERR_FILE" +cmp -s "$VERIFY_STDOUT_FILE" "$EXPECTED_VERIFY_STDOUT_FILE" +test ! -s "$VERIFY_STDERR_FILE" +cmp -s "$ACTUAL_FILE_LIST" "$EXPECTED_FILE_LIST" ``` -Expected stdout: +If any assertion fails, capture the mismatch: -```text -fake sonacli v9.8.7 +```sh +printf 'install_exit=%s\n' "$EXIT_CODE" +printf 'verify_exit=%s\n' "$VERIFY_EXIT_CODE" +diff -u "$EXPECTED_STDOUT_FILE" "$STDOUT_FILE" +diff -u "$EXPECTED_STDERR_FILE" "$STDERR_FILE" +diff -u "$EXPECTED_VERIFY_STDOUT_FILE" "$VERIFY_STDOUT_FILE" +cat "$VERIFY_STDERR_FILE" +diff -u "$EXPECTED_FILE_LIST" "$ACTUAL_FILE_LIST" ``` -## Cleanup +Remove case-specific files after verification: ```sh kill "$INSTALL_SERVER_PID" wait "$INSTALL_SERVER_PID" 2>/dev/null || true -rm -f "$INSTALL_STDOUT_FILE" "$INSTALL_STDERR_FILE" -rm -rf "$INSTALL_FIXTURE_ROOT" "$TEST_HOME" -if [ -n "${ORIGINAL_HOME:-}" ]; then - export HOME="$ORIGINAL_HOME" -else - unset HOME -fi -unset ORIGINAL_HOME TEST_HOME INSTALL_FIXTURE_ROOT INSTALL_STDOUT_FILE INSTALL_STDERR_FILE INSTALL_BIN_DIR -unset TEST_VERSION TEST_GOOS TEST_GOARCH TEST_ASSET_BASENAME INSTALL_SERVER_PORT_FILE INSTALL_SERVER_LOG -unset INSTALL_SERVER_PID INSTALL_SERVER_PORT +rm -rf "$INSTALL_FIXTURE_ROOT" +rm -f "$STDOUT_FILE" "$STDERR_FILE" "$VERIFY_STDOUT_FILE" "$VERIFY_STDERR_FILE" +rm -f "$EXPECTED_STDOUT_FILE" "$EXPECTED_STDERR_FILE" "$EXPECTED_VERIFY_STDOUT_FILE" +rm -f "$ACTUAL_FILE_LIST" "$EXPECTED_FILE_LIST" +```