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
8 changes: 6 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
## Setup

```sh
just dev-setup # enables the repository git hooks
just setup # builds, installs to ~/.local/bin, reports missing host deps
just dev # runs the TUI against a scratch STOAT_HOME
```

`just setup` also installs the git hooks. A repository with a `stoat.toml`
declares its own VMs; run `stoat up` in it to build them.
Run `just dev-setup` once per clone. The `commit-msg` hook it enables strips
tool-attribution trailers, and the `pre-commit` hook runs the fast checks.

A repository with a `stoat.toml` declares its own VMs. Run `stoat up` in it to
build them.

Go 1.26 (pinned in `go.mod`), `just`, and for anything that boots a VM:
KVM, `qemu-system-x86_64`, `qemu-img`, `ssh`. `stoat doctor` lists what is
Expand Down
36 changes: 36 additions & 0 deletions cmd/installer/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package main

import (
"flag"
"fmt"
"io"
)

// exitUsage is the exit code for a bad command line, which separates it from
// an installation that started and failed.
const exitUsage = 2

// parseFlags reads the installer's command line. It returns whether to run
// without the terminal UI. An unknown flag, a misplaced flag, or a positional
// argument is an error: the previous parser read os.Args[1] only, so
// `--no-tty` in any other position silently started the interactive UI.
func parseFlags(args []string, out io.Writer) (noTTY bool, err error) {
flags := flag.NewFlagSet("installer", flag.ContinueOnError)
flags.SetOutput(out)
flags.Usage = func() {
fmt.Fprintln(out, "usage: go run ./cmd/installer [--no-tty]")
fmt.Fprintln(out, "\nBuilds stoat from this clone and installs it to $PREFIX,")
fmt.Fprintln(out, "or to ~/.local/bin when PREFIX is unset.")
flags.PrintDefaults()
}
headless := flags.Bool("no-tty", false, "install without the terminal UI, for CI and scripts")
if err := flags.Parse(args); err != nil {
return false, err
}
if flags.NArg() > 0 {
fmt.Fprintln(out, "unexpected argument:", flags.Arg(0))
flags.Usage()
return false, fmt.Errorf("unexpected argument: %s", flags.Arg(0))
}
return *headless, nil
}
50 changes: 50 additions & 0 deletions cmd/installer/flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"io"
"strings"
"testing"
)

func TestParseFlags(t *testing.T) {
cases := []struct {
name string
args []string
noTTY bool
wantErr bool
}{
{name: "no arguments", args: nil},
{name: "single dash", args: []string{"-no-tty"}, noTTY: true},
{name: "double dash", args: []string{"--no-tty"}, noTTY: true},
{name: "unknown flag", args: []string{"--headless"}, wantErr: true},
{name: "positional argument", args: []string{"install"}, wantErr: true},
{name: "flag after argument", args: []string{"install", "--no-tty"}, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
noTTY, err := parseFlags(tc.args, io.Discard)
if tc.wantErr {
if err == nil {
t.Fatalf("parseFlags(%q) = nil error, want an error", tc.args)
}
return
}
if err != nil {
t.Fatalf("parseFlags(%q): %v", tc.args, err)
}
if noTTY != tc.noTTY {
t.Errorf("parseFlags(%q) noTTY = %v, want %v", tc.args, noTTY, tc.noTTY)
}
})
}
}

func TestParseFlagsReportsTheBadArgument(t *testing.T) {
var out strings.Builder
if _, err := parseFlags([]string{"--headles"}, &out); err == nil {
t.Fatal("parseFlags accepted a misspelled flag")
}
if !strings.Contains(out.String(), "no-tty") {
t.Errorf("usage does not name the real flag:\n%s", out.String())
}
}
13 changes: 6 additions & 7 deletions cmd/installer/main_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import (
)

func run() int {
noTTY, err := parseFlags(os.Args[1:], os.Stderr)
if err != nil {
return exitUsage
}

repoDir, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "cannot determine the working directory:", err)
Expand All @@ -25,8 +30,7 @@ func run() int {
return 1
}

// ponytail: --no-tty is the only flag; full flag parsing if more needed
if len(os.Args) > 1 && os.Args[1] == "--no-tty" {
if noTTY {
return runHeadless(repoDir, home)
}

Expand Down Expand Up @@ -78,11 +82,6 @@ func runHeadless(repoDir, home string) int {
return 1
}

if err := installer.InstallData(repoDir, home); err != nil {
fmt.Fprintln(os.Stderr, "error setting up ~/.stoat:", err)
return 1
}

fmt.Printf("done: stoat %s at %s\n", version, binPath)

if !installer.OnPath(dir, os.Getenv("PATH")) {
Expand Down
5 changes: 4 additions & 1 deletion cmd/installer/main_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import (
// on other hosts so users can run read-only diagnostics, while native VM
// operations remain unqualified there.
func run() int {
fmt.Fprintln(os.Stderr, "stoat command is buildable for diagnostics on "+runtime.GOOS+"/"+runtime.GOARCH+", but native VM operations are not qualified there; source installation remains Linux-only.")
host := runtime.GOOS + "/" + runtime.GOARCH
fmt.Fprintln(os.Stderr, "The guided installer runs on Linux only. This host is "+host+".")
fmt.Fprintln(os.Stderr, "Run `just install` to build the diagnostic binary here.")
fmt.Fprintln(os.Stderr, "Native VM operations are not qualified on "+host+".")
return 1
}
52 changes: 0 additions & 52 deletions internal/installer/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,58 +62,6 @@ func (e *BuildError) Error() string {

func (e *BuildError) Unwrap() error { return e.Err }

// InstallData creates ~/.stoat subdirectories and copies bundled recipes from
// the repo. Safe to call on reinstall: existing user files are not overwritten.
func InstallData(repoDir, home string) error {
root := filepath.Join(home, ".stoat")
for _, d := range []string{"recipes", "isos", "logs"} {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
return err
}
}
// Copy bundled recipes from repo, skipping any the user already has.
srcDir := filepath.Join(repoDir, "internal", "recipes")
destDir := filepath.Join(root, "recipes")
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".sh") {
continue
}
dst := filepath.Join(destDir, name)
if _, err := os.Stat(dst); err == nil {
continue // ponytail: don't clobber user edits
}
if err := copyFile(filepath.Join(srcDir, name), dst); err != nil {
return err
}
}
return nil
}

func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer func() { _ = in.Close() }()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}

// Install copies srcPath into destDir as an executable, creating destDir if
// needed.
func Install(srcPath, destDir string) (string, error) {
Expand Down
Loading
Loading