Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
<!-- Generated from private documentation source. Do not edit directly. Source SHA256: 2230568effdc3fa062e56fd1f29bdff2a901e2086acd330f287ff9f31dac1c83 -->
<!-- Generated from private documentation source. Do not edit directly. Source SHA256: 1a70da1c9844d181be16ad0f5639f235c5fcfa4b620c662ae8c6f9f6d410829e -->

# 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 <directory>` 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.

Expand Down
104 changes: 104 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions installer_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}