diff --git a/README.md b/README.md index 5663926..6494cbb 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ - + # termcade A terminal arcade with sandboxed WebAssembly games. It includes Asteroid, Tetris and Brickough. Use a truecolor terminal at least 80×24 cells. ```sh -go run github.com/aviorstudio/termcade@latest +curl -fsSL https://www.termca.de/install | sh +termcade termcade add aviorstudio/tetris termcade --help ``` -Prebuilt binaries are available from [Releases](https://github.com/aviorstudio/termcade/releases). `TERMCADE_PIXELS` selects `quad`, `sextant`, `half` or `ascii` rendering. The in-app Settings screen saves a preferred pixel style. +The installer supports Linux and macOS, verifies the release checksum and installs to `~/.local/bin`. Add that directory to your PATH if necessary. `--install-dir` and `--version` can be passed through `sh -s --`. Prebuilt binaries are also available from [Releases](https://github.com/aviorstudio/termcade/releases). `TERMCADE_PIXELS` selects `quad`, `sextant`, `half` or `ascii` rendering. The in-app Settings screen saves a preferred pixel style. To create a game, run `termcade dev new you/mygame`, then use `termcade dev build ` to build its package. The Go SDK is a separate module at `github.com/aviorstudio/termcade/sdk`; choose an exact `sdk/v*` tag from Releases. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..226110c --- /dev/null +++ b/install.sh @@ -0,0 +1,104 @@ +#!/bin/sh +# Install a published termcade release into a user-owned directory. +set -eu + +tool=termcade +repo_url=https://github.com/aviorstudio/termcade +release=latest +install_dir=${INSTALL_DIR:-"$HOME/.local/bin"} +work_dir= +stage_dir= +fail() { printf '%s: %s\n' "$tool" "$*" >&2; exit 1; } +cleanup() { + [ -z "$work_dir" ] || rm -rf "$work_dir" + [ -z "$stage_dir" ] || rm -rf "$stage_dir" +} +trap cleanup 0 +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +while [ "$#" -gt 0 ]; do + case "$1" in + --version|-v|--install-dir) + [ "$#" -ge 2 ] || fail "$1 needs a value." + case "$1" in + --version|-v) release=$2 ;; + --install-dir) install_dir=$2 ;; + esac + shift 2 ;; + --help|-h) + printf 'Install %s: [--version X.Y.Z|latest] [--install-dir /absolute/path]\nDefaults: latest release, ~/.local/bin.\n' "$tool" + exit 0 ;; + *) fail "Unknown option: $1" ;; + esac +done +case "$install_dir" in /*) ;; *) fail 'The install directory must be an absolute path.' ;; esac +for command in curl tar mktemp awk grep cut cp chmod mv; do + command -v "$command" >/dev/null 2>&1 || fail "Install $command first." +done + +if command -v sha256sum >/dev/null 2>&1; then + checksum_tool=sha256sum +elif command -v shasum >/dev/null 2>&1; then + checksum_tool=shasum +else + fail 'A SHA-256 utility (sha256sum or shasum) is required.' +fi +case "$(uname -s)" in + Linux) platform=Linux ;; + Darwin) platform=Darwin ;; + *) fail 'Supported operating systems: Linux and macOS.' ;; +esac +case "$(uname -m)" in + x86_64|amd64) arch=x86_64 ;; + arm64|aarch64) arch=arm64 ;; + *) fail 'Supported CPU architectures: x86_64 and arm64.' ;; +esac +if [ "$platform" = Darwin ] && [ "$arch" = x86_64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = 1 ]; then + arch=arm64 +fi +fetch() { + curl --fail --silent --show-error --location --retry 3 \ + --connect-timeout 10 --max-time 300 --proto '=https' --proto-redir '=https' "$@" +} +if [ "$release" = latest ]; then + latest_url=$(fetch --output /dev/null --write-out '%{url_effective}' "$repo_url/releases/latest") || fail 'Could not find the latest release.' + case "$latest_url" in + "$repo_url/releases/tag/"*) release=${latest_url##*/} ;; + *) fail 'No published release was found.' ;; + esac +fi +case "$release" in v*) ;; *) release=v$release ;; esac +printf '%s\n' "$release" | LC_ALL=C grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || fail 'Use a release version such as v1.2.3.' +asset=${tool}_${platform}_${arch}.tar.gz +base=$repo_url/releases/download/$release +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/$tool-install.XXXXXXXX") || fail 'Could not create a temporary directory.' +printf 'Installing %s %s (%s %s)...\n' "$tool" "$release" "$platform" "$arch" +fetch "$base/checksums.txt" --output "$work_dir/checksums.txt" || fail 'Could not download release checksums.' +fetch "$base/$asset" --output "$work_dir/$asset" || fail "Could not download $asset." +expected=$(awk -v name="$asset" '$2 == name || $2 == "*" name { count++; digest=$1 } END { if (count == 1) print digest }' "$work_dir/checksums.txt") +printf '%s\n' "$expected" | LC_ALL=C grep -Eq '^[a-f0-9]{64}$' || fail 'The archive needs exactly one valid release checksum.' +if [ "$checksum_tool" = sha256sum ]; then + actual=$(sha256sum "$work_dir/$asset" | awk '{print $1}') +else + actual=$(shasum -a 256 "$work_dir/$asset" | awk '{print $1}') +fi +[ "$actual" = "$expected" ] || fail 'Checksum verification failed; the installed binary was not changed.' + +# Extract only one regular executable, never the rest of the archive. +entry_type=$(tar -tvzf "$work_dir/$asset" "$tool" | cut -c1) +[ "$entry_type" = '-' ] || fail 'The archive must contain exactly one regular executable.' +tar --no-same-owner -xzf "$work_dir/$asset" -C "$work_dir" "$tool" || fail 'The executable could not be extracted.' +[ -f "$work_dir/$tool" ] && [ ! -L "$work_dir/$tool" ] || fail 'The archive does not contain a regular executable.' +mkdir -p "$install_dir" || fail 'Choose a writable install directory.' +[ ! -d "$install_dir/$tool" ] || fail 'The install destination is a directory.' +stage_dir=$(mktemp -d "$install_dir/.$tool-install.XXXXXXXX") || fail 'Could not stage the executable.' +cp "$work_dir/$tool" "$stage_dir/$tool" +chmod 755 "$stage_dir/$tool" +mv -f "$stage_dir/$tool" "$install_dir/$tool" +printf 'Installed %s %s at %s/%s\n' "$tool" "$release" "$install_dir" "$tool" +case ":$PATH:" in + *":$install_dir:"*) ;; + *) printf 'Add %s to your PATH to run %s by name.\n' "$install_dir" "$tool" ;; +esac diff --git a/installer_test.go b/installer_test.go new file mode 100644 index 0000000..57b70fa --- /dev/null +++ b/installer_test.go @@ -0,0 +1,128 @@ +//go:build !windows + +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestCurlInstaller(t *testing.T) { + _, file, _, _ := runtime.Caller(0) + script := filepath.Join(filepath.Dir(file), "install.sh") + for _, scenario := range []string{"valid", "checksum", "symlink", "duplicate", "missing", "attestation"} { + t.Run(scenario, func(t *testing.T) { + if scenario == "attestation" && "false" != "true" { + t.Skip("This product verifies release checksums.") + } + root := t.TempDir() + bin := filepath.Join(root, "bin") + fixture := filepath.Join(root, "fixture") + fake := filepath.Join(root, "commands") + for _, dir := range []string{bin, fixture, fake} { + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + } + write := func(path string, body []byte, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, body, mode); err != nil { + t.Fatal(err) + } + } + const tool = "termcade" + old := []byte("previous executable\n") + payload := []byte("#!/bin/sh\nprintf 'installed fixture\\n'\n") + write(filepath.Join(bin, tool), old, 0700) + var archive bytes.Buffer + gz := gzip.NewWriter(&archive) + tw := tar.NewWriter(gz) + count := 1 + if scenario == "duplicate" { + count = 2 + } + for i := 0; i < count; i++ { + header := &tar.Header{Name: tool, Mode: 0755, Uid: 1001, Gid: 1001, Size: int64(len(payload)), Typeflag: tar.TypeReg} + if scenario == "symlink" { + header.Typeflag, header.Linkname, header.Size = tar.TypeSymlink, "/outside", 0 + } + if scenario == "missing" { + header.Name = "another-file" + } + if err := tw.WriteHeader(header); err != nil { + t.Fatal(err) + } + if header.Size > 0 { + if _, err := tw.Write(payload); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + write(filepath.Join(fixture, "archive"), archive.Bytes(), 0600) + digest := fmt.Sprintf("%x", sha256.Sum256(archive.Bytes())) + if scenario == "checksum" { + digest = strings.Repeat("0", 64) + } + write(filepath.Join(fixture, "checksums"), []byte(digest+" "+tool+"_Linux_x86_64.tar.gz\n"), 0600) + write(filepath.Join(fake, "uname"), []byte("#!/bin/sh\ncase \"$1\" in -s) echo Linux;; -m) echo x86_64;; *) exit 1;; esac\n"), 0700) + write(filepath.Join(fake, "curl"), []byte(`#!/bin/sh +set -eu +output= +url= +while [ "$#" -gt 0 ]; do + case "$1" in + --output) output=$2; shift 2 ;; + --write-out|--retry|--connect-timeout|--max-time|--proto|--proto-redir) shift 2 ;; + https://*) url=$1; shift ;; + *) shift ;; + esac +done +case "$url" in + https://github.com/aviorstudio/termcade/releases/latest) printf 'https://github.com/aviorstudio/termcade/releases/tag/v1.2.3' ;; + https://github.com/aviorstudio/termcade/releases/download/v1.2.3/checksums.txt) cp "$INSTALL_FIXTURE/checksums" "$output" ;; + https://github.com/aviorstudio/termcade/releases/download/v1.2.3/termcade_Linux_x86_64.tar.gz) cp "$INSTALL_FIXTURE/archive" "$output" ;; + *) echo "unexpected download: $url" >&2; exit 1 ;; +esac +`), 0700) + write(filepath.Join(fake, "gh"), []byte("#!/bin/sh\n[ \"${INSTALL_ATTEST_FAIL:-0}\" != 1 ]\n"), 0700) + cmd := exec.Command("sh", script, "--install-dir", bin) + cmd.Env = append(os.Environ(), "PATH="+fake+string(os.PathListSeparator)+os.Getenv("PATH"), "INSTALL_FIXTURE="+fixture) + if scenario == "attestation" { + cmd.Env = append(cmd.Env, "INSTALL_ATTEST_FAIL=1") + } + output, err := cmd.CombinedOutput() + if scenario == "valid" && err != nil { + t.Fatalf("install: %v\n%s", err, output) + } + if scenario != "valid" && err == nil { + t.Fatalf("invalid release accepted: %s", output) + } + got, err := os.ReadFile(filepath.Join(bin, tool)) + if err != nil { + t.Fatal(err) + } + want := old + if scenario == "valid" { + want = payload + } + if !bytes.Equal(got, want) { + t.Fatalf("unexpected executable after %s: %q", scenario, got) + } + }) + } +}