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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- Generated from private documentation source. Do not edit directly. Source SHA256: ba46d6ed5eb52d766e45733ec7480d618099cf0af61a3bb59904a5c4473386f8 -->
<!-- Generated from private documentation source. Do not edit directly. Source SHA256: 4ab6296c3bc52c098a9eb78120110d5b4a33e6e8ab1e7c0f0e997964020c2763 -->

# spritesmith

Expand All @@ -8,7 +8,7 @@ The client requires an API service and an account token. It does not contain a l

## Install

With Go 1.27.2 or later, install to `~/.local/bin` (add it to your PATH if necessary):
Install a prebuilt Linux or macOS binary (x86_64 or arm64) into `~/.local/bin` (add it to your PATH if necessary). A Go compiler is not required:

```sh
curl -fsSL https://spritesmith.ai/install | sh
Expand Down Expand Up @@ -38,6 +38,12 @@ Generation runs as a server job. The client waits for completion and downloads t

The default API is `http://127.0.0.1:8080`; configure your hosted service explicitly. Default output is `spritesmith.png`, or `spritesmith.svg` for SVG downloads.

Source builds remain available for development with a compatible Go toolchain:

```sh
go install github.com/aviorstudio/spritesmith/cmd/spritesmith@latest
```

## Development

Install the [komizo release](https://github.com/nicodes/komizo#install) matching `komizo.lock`.
Expand Down
122 changes: 91 additions & 31 deletions cmd/spritesmith/installer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
package main

import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
Expand All @@ -14,54 +19,109 @@ import (
func TestCurlInstaller(t *testing.T) {
_, file, _, _ := runtime.Caller(0)
script := filepath.Join(filepath.Dir(file), "../../install.sh")
for _, fail := range []bool{false, true} {
name := "success"
if fail {
name = "failed build preserves existing executable"
}
t.Run(name, func(t *testing.T) {
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, fake := filepath.Join(root, "bin"), filepath.Join(root, "commands")
for _, dir := range []string{bin, fake} {
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)
}
}
if err := os.WriteFile(filepath.Join(bin, "spritesmith"), []byte("previous executable\n"), 0700); err != nil {
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 = "spritesmith"
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)
}
goCommand := `#!/bin/sh
set -eu
[ "$1" = install ]
[ "$2" = 'github.com/aviorstudio/spritesmith/cmd/spritesmith@latest' ]
printf 'new executable\n' > "$GOBIN/spritesmith"
if [ "${INSTALL_BUILD_FAIL:-0}" = 1 ]; then exit 42; fi
`
if err := os.WriteFile(filepath.Join(fake, "go"), []byte(goCommand), 0700); err != nil {
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/spritesmith/releases/latest) printf 'https://github.com/aviorstudio/spritesmith/releases/tag/v1.2.3' ;;
https://github.com/aviorstudio/spritesmith/releases/download/v1.2.3/checksums.txt) cp "$INSTALL_FIXTURE/checksums" "$output" ;;
https://github.com/aviorstudio/spritesmith/releases/download/v1.2.3/spritesmith_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"))
if fail {
cmd.Env = append(cmd.Env, "INSTALL_BUILD_FAIL=1")
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 fail == (err == nil) {
t.Fatalf("unexpected result: %v\n%s", err, output)
if scenario == "valid" && err != nil {
t.Fatalf("install: %v\n%s", err, output)
}
body, err := os.ReadFile(filepath.Join(bin, "spritesmith"))
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 := "new executable\n"
if fail {
want = "previous executable\n"
}
if string(body) != want {
t.Fatalf("unexpected executable: %q", body)
want := old
if scenario == "valid" {
want = payload
}
if !fail && !strings.Contains(string(output), "Installed spritesmith") {
t.Fatalf("missing install receipt: %s", output)
if !bytes.Equal(got, want) {
t.Fatalf("unexpected executable after %s: %q", scenario, got)
}
})
}
Expand Down
76 changes: 61 additions & 15 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
#!/bin/sh
# Install spritesmith using the Go module's normal verification and build process.
# Install a published spritesmith release into a user-owned directory.
set -eu

tool=spritesmith
module=github.com/aviorstudio/spritesmith/cmd/spritesmith
repo_url=https://github.com/aviorstudio/spritesmith
release=latest
install_dir=${INSTALL_DIR:-"$HOME/.local/bin"}
work_dir=
Expand All @@ -17,6 +17,7 @@ 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)
Expand All @@ -27,31 +28,76 @@ while [ "$#" -gt 0 ]; do
esac
shift 2 ;;
--help|-h)
printf 'Install %s with Go: [--version VERSION|latest] [--install-dir /absolute/path]\nDefaults: latest Go module, ~/.local/bin. Requires Go.\n' "$tool"
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
command -v go >/dev/null 2>&1 || fail 'Install Go first, then run this command again.'
case "$release" in
latest) ;;
v[0-9]*) ;;
[0-9]*) release=v$release ;;
*) fail 'Use latest or a Go module version such as v1.2.3.' ;;
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.'
mkdir "$work_dir/bin"
printf 'Building %s from %s@%s...\n' "$tool" "$module" "$release"
GOBIN="$work_dir/bin" go install "$module@$release" || fail 'Go installation failed; the installed binary was not changed.'
[ -f "$work_dir/bin/$tool" ] && [ ! -L "$work_dir/bin/$tool" ] || fail 'Go did not produce a regular executable.'
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/bin/$tool" "$stage_dir/$tool"
cp "$work_dir/$tool" "$stage_dir/$tool"
chmod 755 "$stage_dir/$tool"
mv -f "$stage_dir/$tool" "$install_dir/$tool"
printf 'Installed %s at %s/%s\n' "$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" ;;
Expand Down