diff --git a/CHANGELOG.md b/CHANGELOG.md index 9edabc0..87aecf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable user-facing changes to `sonacli` are documented in this file. ## [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 - Added a project changelog and documented release history for `sonacli`. @@ -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/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..8ef6490 --- /dev/null +++ b/tests/11-install-script.md @@ -0,0 +1,164 @@ +# 11 Install Script + +This case file defines end-to-end coverage for the root `install.sh` installer. + +## Additional Requirements + +- Run from the repository root. +- 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`. + +## Cases + +### INSTALL-SCRIPT-01 Install The Latest Release From A Local Fixture Server + +#### Execute + +```sh +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) TEST_GOOS="linux" ;; + Darwin) TEST_GOOS="darwin" ;; + *) echo "unsupported OS" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) TEST_GOARCH="amd64" ;; + arm64|aarch64) TEST_GOARCH="arm64" ;; + *) echo "unsupported architecture" >&2; exit 1 ;; +esac + +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 + +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 +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 + +INSTALL_SERVER_PID=$! + +while [ ! -s "$INSTALL_SERVER_PORT_FILE" ]; do + sleep 1 +done + +INSTALL_SERVER_PORT="$(cat "$INSTALL_SERVER_PORT_FILE")" + +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" >"$STDOUT_FILE" 2>"$STDERR_FILE" +EXIT_CODE=$? + +"$INSTALL_BIN_DIR/sonacli" >"$VERIFY_STDOUT_FILE" 2>"$VERIFY_STDERR_FILE" +VERIFY_EXIT_CODE=$? + +find "$HOME" -print | LC_ALL=C sort >"$ACTUAL_FILE_LIST" +``` + +#### Verify + +```sh +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" +``` + +If any assertion fails, capture the mismatch: + +```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" +``` + +Remove case-specific files after verification: + +```sh +kill "$INSTALL_SERVER_PID" +wait "$INSTALL_SERVER_PID" 2>/dev/null || true +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" +``` 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.