From ae596692ade5e0553ef5841895f9fa550b8b5fed Mon Sep 17 00:00:00 2001 From: AmrikSD Date: Mon, 5 Oct 2026 17:52:23 +0100 Subject: [PATCH 1/3] feat(onboard): TUI that clones my repos onto a new machine --- README.md | 1 + go.MODULE.bazel | 2 +- go.mod | 25 + go.sum | 43 ++ projects/onboard/cmd/onboard/BUILD.bazel | 19 + projects/onboard/cmd/onboard/main.go | 111 +++++ projects/onboard/internal/github/BUILD.bazel | 14 + projects/onboard/internal/github/github.go | 65 +++ .../onboard/internal/github/github_test.go | 49 ++ projects/onboard/internal/picker/BUILD.bazel | 14 + projects/onboard/internal/picker/picker.go | 188 ++++++++ .../onboard/internal/picker/picker_test.go | 140 ++++++ projects/onboard/internal/tui/BUILD.bazel | 28 ++ projects/onboard/internal/tui/errors_test.go | 5 + projects/onboard/internal/tui/tui.go | 443 ++++++++++++++++++ projects/onboard/internal/tui/tui_test.go | 73 +++ .../onboard/internal/workspace/BUILD.bazel | 14 + .../onboard/internal/workspace/workspace.go | 61 +++ .../internal/workspace/workspace_test.go | 45 ++ 19 files changed, 1339 insertions(+), 1 deletion(-) create mode 100644 projects/onboard/cmd/onboard/BUILD.bazel create mode 100644 projects/onboard/cmd/onboard/main.go create mode 100644 projects/onboard/internal/github/BUILD.bazel create mode 100644 projects/onboard/internal/github/github.go create mode 100644 projects/onboard/internal/github/github_test.go create mode 100644 projects/onboard/internal/picker/BUILD.bazel create mode 100644 projects/onboard/internal/picker/picker.go create mode 100644 projects/onboard/internal/picker/picker_test.go create mode 100644 projects/onboard/internal/tui/BUILD.bazel create mode 100644 projects/onboard/internal/tui/errors_test.go create mode 100644 projects/onboard/internal/tui/tui.go create mode 100644 projects/onboard/internal/tui/tui_test.go create mode 100644 projects/onboard/internal/workspace/BUILD.bazel create mode 100644 projects/onboard/internal/workspace/workspace.go create mode 100644 projects/onboard/internal/workspace/workspace_test.go diff --git a/README.md b/README.md index 9c15828d..499f0cd1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ learning is the point. | [amrik.co.uk](./projects/amrik.co.uk) | Personal site and blog | Jekyll | | [Rolodex](./projects/Rolodex) | Document indexer, inspired by Paperless-ngx | Java, Postgres | | [vibe](./projects/vibe) | CLI that starts work on a Jira ticket | Go | +| [onboard](./projects/onboard) | TUI that clones my repos onto a new machine | Go | | [csv-to-pdf](./projects/csv-to-pdf) | Turns scuffed CSVs into decent looking PDFs | HTML, JS | | [Leetcode](./projects/Leetcode) | Practice problems | Go, Java | diff --git a/go.MODULE.bazel b/go.MODULE.bazel index 65abb270..fb9e7921 100644 --- a/go.MODULE.bazel +++ b/go.MODULE.bazel @@ -11,4 +11,4 @@ go_sdk.host() go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps") go_deps.from_file(go_mod = "//:go.mod") -use_repo(go_deps) +use_repo(go_deps, "com_github_charmbracelet_bubbletea", "com_github_charmbracelet_lipgloss") diff --git a/go.mod b/go.mod index 3f433c18..450e84a7 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,28 @@ module github.com/amriksd/code go 1.24.0 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum index e69de29b..47896390 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/projects/onboard/cmd/onboard/BUILD.bazel b/projects/onboard/cmd/onboard/BUILD.bazel new file mode 100644 index 00000000..6ec74866 --- /dev/null +++ b/projects/onboard/cmd/onboard/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_go//go:def.bzl", "go_binary", "go_library") + +go_library( + name = "onboard_lib", + srcs = ["main.go"], + importpath = "github.com/amriksd/code/projects/onboard/cmd/onboard", + visibility = ["//visibility:private"], + deps = [ + "//projects/onboard/internal/github", + "//projects/onboard/internal/tui", + "//projects/onboard/internal/workspace", + ], +) + +go_binary( + name = "onboard", + embed = [":onboard_lib"], + visibility = ["//visibility:public"], +) diff --git a/projects/onboard/cmd/onboard/main.go b/projects/onboard/cmd/onboard/main.go new file mode 100644 index 00000000..926589c8 --- /dev/null +++ b/projects/onboard/cmd/onboard/main.go @@ -0,0 +1,111 @@ +// Command onboard clones your GitHub repositories onto a new machine. It shows +// your account and organisations with a tick box each, and clones whatever is +// ticked into //. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/amriksd/code/projects/onboard/internal/github" + "github.com/amriksd/code/projects/onboard/internal/tui" + "github.com/amriksd/code/projects/onboard/internal/workspace" +) + +func main() { + os.Exit(run()) +} + +func run() int { + home, _ := os.UserHomeDir() + dir := flag.String("dir", filepath.Join(home, "code"), "directory to clone into, as //") + owner := flag.String("owner", "", "comma separated owners to offer, instead of your account and all your organisations") + yes := flag.Bool("yes", false, "skip the picker and clone everything: the -owner list, or your own account") + flag.Parse() + + if _, err := github.GH("auth", "status"); err != nil { + fmt.Fprintln(os.Stderr, "Not signed in to GitHub. Run: gh auth login") + return 1 + } + + var owners []string + if *owner != "" { + owners = strings.Split(*owner, ",") + } + + if *yes { + return cloneAll(*dir, owners) + } + + model, err := tui.Run(tui.Config{Root: *dir, Owners: owners, GH: github.GH}) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + failed := model.Failed() + fmt.Printf("Cloned %d of %d into %s\n", len(model.Results)-len(failed), len(model.Results), *dir) + for _, r := range failed { + fmt.Printf(" %s/%s: %v\n", r.Repo.Owner, r.Repo.Name, r.Err) + } + if len(failed) > 0 { + return 1 + } + return 0 +} + +func cloneAll(dir string, owners []string) int { + if len(owners) == 0 { + all, err := github.Owners(github.GH) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + owners = all[:1] + } + + var wg sync.WaitGroup + var mu sync.Mutex + failed := 0 + slots := make(chan struct{}, 6) + for _, owner := range owners { + repos, err := github.Repos(github.GH, owner) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + for _, repo := range repos { + if workspace.Cloned(dir, owner, repo.Name) { + continue + } + wg.Add(1) + slots <- struct{}{} + go func() { + defer wg.Done() + err := workspace.Clone(dir, owner, repo.Name, repo.SSHURL) + <-slots + mu.Lock() + defer mu.Unlock() + if err != nil { + failed++ + fmt.Printf("failed %s/%s: %v\n", owner, repo.Name, err) + return + } + fmt.Printf("cloned %s/%s\n", owner, repo.Name) + }() + } + } + wg.Wait() + + if err := workspace.Register(dir, owners); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if failed > 0 { + return 1 + } + return 0 +} diff --git a/projects/onboard/internal/github/BUILD.bazel b/projects/onboard/internal/github/BUILD.bazel new file mode 100644 index 00000000..fea9c35f --- /dev/null +++ b/projects/onboard/internal/github/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "github", + srcs = ["github.go"], + importpath = "github.com/amriksd/code/projects/onboard/internal/github", + visibility = ["//projects/onboard:__subpackages__"], +) + +go_test( + name = "github_test", + srcs = ["github_test.go"], + embed = [":github"], +) diff --git a/projects/onboard/internal/github/github.go b/projects/onboard/internal/github/github.go new file mode 100644 index 00000000..e21a6c3a --- /dev/null +++ b/projects/onboard/internal/github/github.go @@ -0,0 +1,65 @@ +// Package github lists the owners and repositories the signed-in user can +// clone, by shelling out to the gh CLI. +package github + +import ( + "encoding/json" + "fmt" + "os/exec" + "sort" + "strings" +) + +// Repo is a repository as gh reports it. +type Repo struct { + Name string `json:"name"` + SSHURL string `json:"sshUrl"` +} + +// Runner runs gh with the given arguments and returns its standard output. +type Runner func(args ...string) ([]byte, error) + +// GH runs the real gh CLI. +func GH(args ...string) ([]byte, error) { + out, err := exec.Command("gh", args...).Output() + if err != nil { + if exit, ok := err.(*exec.ExitError); ok && len(exit.Stderr) > 0 { + return nil, fmt.Errorf("gh %s: %s", args[0], strings.TrimSpace(string(exit.Stderr))) + } + return nil, fmt.Errorf("gh %s: %w", args[0], err) + } + return out, nil +} + +// Owners returns the signed-in user followed by the organisations they belong to. +func Owners(run Runner) ([]string, error) { + user, err := run("api", "user", "--jq", ".login") + if err != nil { + return nil, err + } + orgs, err := run("api", "user/orgs", "--paginate", "--jq", ".[].login") + if err != nil { + return nil, err + } + return append(strings.Fields(string(user)), strings.Fields(string(orgs))...), nil +} + +// Repos lists an owner's repositories by name, leaving out archived ones. +func Repos(run Runner, owner string) ([]Repo, error) { + out, err := run("repo", "list", owner, "--no-archived", "--limit", "5000", "--json", "name,sshUrl") + if err != nil { + return nil, err + } + return parseRepos(out) +} + +func parseRepos(out []byte) ([]Repo, error) { + var repos []Repo + if err := json.Unmarshal(out, &repos); err != nil { + return nil, fmt.Errorf("parsing gh repo list output: %w", err) + } + sort.Slice(repos, func(i, j int) bool { + return strings.ToLower(repos[i].Name) < strings.ToLower(repos[j].Name) + }) + return repos, nil +} diff --git a/projects/onboard/internal/github/github_test.go b/projects/onboard/internal/github/github_test.go new file mode 100644 index 00000000..c282b30b --- /dev/null +++ b/projects/onboard/internal/github/github_test.go @@ -0,0 +1,49 @@ +package github + +import ( + "reflect" + "testing" +) + +func TestOwnersListsTheUserBeforeTheirOrgs(t *testing.T) { + run := func(args ...string) ([]byte, error) { + if args[1] == "user" { + return []byte("amrik\n"), nil + } + return []byte("acme\nglobex\n"), nil + } + + got, err := Owners(run) + + want := []string{"amrik", "acme", "globex"} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("Owners() = %v, %v, want %v", got, err, want) + } +} + +func TestParseReposSortsByNameIgnoringCase(t *testing.T) { + out := []byte(`[ + {"name": "web", "sshUrl": "git@github.com:acme/web.git"}, + {"name": "API", "sshUrl": "git@github.com:acme/API.git"}, + {"name": "docs", "sshUrl": "git@github.com:acme/docs.git"} + ]`) + + got, err := parseRepos(out) + + want := []Repo{ + {Name: "API", SSHURL: "git@github.com:acme/API.git"}, + {Name: "docs", SSHURL: "git@github.com:acme/docs.git"}, + {Name: "web", SSHURL: "git@github.com:acme/web.git"}, + } + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("parseRepos() = %+v, %v, want %+v", got, err, want) + } +} + +func TestParseReposRejectsOutputThatIsNotJSON(t *testing.T) { + _, err := parseRepos([]byte("not json")) + + if err == nil { + t.Fatal("parseRepos() error = nil, want an error") + } +} diff --git a/projects/onboard/internal/picker/BUILD.bazel b/projects/onboard/internal/picker/BUILD.bazel new file mode 100644 index 00000000..4abce5aa --- /dev/null +++ b/projects/onboard/internal/picker/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "picker", + srcs = ["picker.go"], + importpath = "github.com/amriksd/code/projects/onboard/internal/picker", + visibility = ["//projects/onboard:__subpackages__"], +) + +go_test( + name = "picker_test", + srcs = ["picker_test.go"], + embed = [":picker"], +) diff --git a/projects/onboard/internal/picker/picker.go b/projects/onboard/internal/picker/picker.go new file mode 100644 index 00000000..dbe7716e --- /dev/null +++ b/projects/onboard/internal/picker/picker.go @@ -0,0 +1,188 @@ +// Package picker holds the selection state behind the repo picker: owners that +// expand into repos, where ticking an owner ticks every repo under it. +package picker + +// Repo is one repository under an owner. +type Repo struct { + Name string + SSHURL string + Cloned bool + Selected bool +} + +// Owner is a GitHub user or organisation and its repositories. +type Owner struct { + Login string + Repos []Repo + Expanded bool + Loading bool + Err error +} + +// State says how much of an owner is selected. +type State int + +const ( + None State = iota + Some + All +) + +// State reports how many of the owner's repos are selected. Repos that are +// already cloned cannot be selected, so they do not count either way. +func (o *Owner) State() State { + selected, selectable := 0, 0 + for _, r := range o.Repos { + if r.Cloned { + continue + } + selectable++ + if r.Selected { + selected++ + } + } + switch { + case selected == 0: + return None + case selected == selectable: + return All + default: + return Some + } +} + +// SetAll selects or clears every repo that is not already cloned. +func (o *Owner) SetAll(selected bool) { + for i := range o.Repos { + if !o.Repos[i].Cloned { + o.Repos[i].Selected = selected + } + } +} + +// Count returns how many repos are selected and how many are already cloned. +func (o *Owner) Count() (selected, cloned int) { + for _, r := range o.Repos { + if r.Cloned { + cloned++ + } else if r.Selected { + selected++ + } + } + return selected, cloned +} + +// Row is one visible line of the picker. Repo is -1 on an owner's own line. +type Row struct { + Owner int + Repo int +} + +// Selection is a repo chosen for cloning. +type Selection struct { + Owner string + Name string + SSHURL string +} + +// Picker is the list of owners plus the cursor position within the visible rows. +type Picker struct { + Owners []*Owner + Cursor int +} + +// Rows lists the visible lines: every owner, and the repos of expanded owners. +func (p *Picker) Rows() []Row { + var rows []Row + for i, o := range p.Owners { + rows = append(rows, Row{Owner: i, Repo: -1}) + if !o.Expanded { + continue + } + for j := range o.Repos { + rows = append(rows, Row{Owner: i, Repo: j}) + } + } + return rows +} + +// Move shifts the cursor by delta rows, stopping at either end. +func (p *Picker) Move(delta int) { + last := len(p.Rows()) - 1 + p.Cursor += delta + if p.Cursor > last { + p.Cursor = last + } + if p.Cursor < 0 { + p.Cursor = 0 + } +} + +// Toggle flips the row under the cursor. On an owner it selects every repo +// under it, or clears them if they were all selected already. +func (p *Picker) Toggle() { + row, ok := p.current() + if !ok { + return + } + owner := p.Owners[row.Owner] + if row.Repo == -1 { + owner.SetAll(owner.State() != All) + return + } + repo := &owner.Repos[row.Repo] + if !repo.Cloned { + repo.Selected = !repo.Selected + } +} + +// Expand shows the repos of the owner under the cursor. +func (p *Picker) Expand() { + if row, ok := p.current(); ok { + p.Owners[row.Owner].Expanded = true + } +} + +// Collapse hides the repos of the owner under the cursor. From a repo row it +// collapses the parent and moves the cursor up to it. +func (p *Picker) Collapse() { + row, ok := p.current() + if !ok { + return + } + p.Owners[row.Owner].Expanded = false + for i, r := range p.Rows() { + if r.Owner == row.Owner { + p.Cursor = i + return + } + } +} + +// SetAll selects or clears every repo of every owner. +func (p *Picker) SetAll(selected bool) { + for _, o := range p.Owners { + o.SetAll(selected) + } +} + +// Selected lists the chosen repos in display order. +func (p *Picker) Selected() []Selection { + var out []Selection + for _, o := range p.Owners { + for _, r := range o.Repos { + if r.Selected && !r.Cloned { + out = append(out, Selection{Owner: o.Login, Name: r.Name, SSHURL: r.SSHURL}) + } + } + } + return out +} + +func (p *Picker) current() (Row, bool) { + rows := p.Rows() + if p.Cursor < 0 || p.Cursor >= len(rows) { + return Row{}, false + } + return rows[p.Cursor], true +} diff --git a/projects/onboard/internal/picker/picker_test.go b/projects/onboard/internal/picker/picker_test.go new file mode 100644 index 00000000..4dae1c49 --- /dev/null +++ b/projects/onboard/internal/picker/picker_test.go @@ -0,0 +1,140 @@ +package picker + +import ( + "reflect" + "testing" +) + +func TestToggleOnOwnerSelectsEveryRepoUnderIt(t *testing.T) { + p := &Picker{Owners: []*Owner{ + {Login: "acme", Repos: []Repo{{Name: "api"}, {Name: "web"}}}, + }} + + p.Toggle() + + want := []Repo{{Name: "api", Selected: true}, {Name: "web", Selected: true}} + if !reflect.DeepEqual(p.Owners[0].Repos, want) { + t.Fatalf("repos = %+v, want %+v", p.Owners[0].Repos, want) + } +} + +func TestToggleOnFullySelectedOwnerClearsEveryRepoUnderIt(t *testing.T) { + p := &Picker{Owners: []*Owner{ + {Login: "acme", Repos: []Repo{{Name: "api", Selected: true}, {Name: "web", Selected: true}}}, + }} + + p.Toggle() + + want := []Repo{{Name: "api"}, {Name: "web"}} + if !reflect.DeepEqual(p.Owners[0].Repos, want) { + t.Fatalf("repos = %+v, want %+v", p.Owners[0].Repos, want) + } +} + +func TestToggleOnPartlySelectedOwnerSelectsTheRest(t *testing.T) { + p := &Picker{Owners: []*Owner{ + {Login: "acme", Repos: []Repo{{Name: "api", Selected: true}, {Name: "web"}}}, + }} + + p.Toggle() + + if got := p.Owners[0].State(); got != All { + t.Fatalf("State() = %v, want All", got) + } +} + +func TestToggleOnRepoFlipsOnlyThatRepo(t *testing.T) { + p := &Picker{ + Owners: []*Owner{{Login: "acme", Expanded: true, Repos: []Repo{{Name: "api"}, {Name: "web"}}}}, + Cursor: 2, + } + + p.Toggle() + + want := []Repo{{Name: "api"}, {Name: "web", Selected: true}} + if !reflect.DeepEqual(p.Owners[0].Repos, want) { + t.Fatalf("repos = %+v, want %+v", p.Owners[0].Repos, want) + } +} + +func TestClonedReposAreNeverSelected(t *testing.T) { + p := &Picker{Owners: []*Owner{ + {Login: "acme", Repos: []Repo{{Name: "api", Cloned: true}, {Name: "web"}}}, + }} + + p.Toggle() + + want := []Selection{{Owner: "acme", Name: "web"}} + if got := p.Selected(); !reflect.DeepEqual(got, want) { + t.Fatalf("Selected() = %+v, want %+v", got, want) + } +} + +func TestStateIgnoresClonedRepos(t *testing.T) { + o := &Owner{Repos: []Repo{{Name: "api", Cloned: true}, {Name: "web", Selected: true}}} + + if got := o.State(); got != All { + t.Fatalf("State() = %v, want All", got) + } +} + +func TestStateIsSomeWhenOnlyPartIsSelected(t *testing.T) { + o := &Owner{Repos: []Repo{{Name: "api", Selected: true}, {Name: "web"}}} + + if got := o.State(); got != Some { + t.Fatalf("State() = %v, want Some", got) + } +} + +func TestRowsOnlyIncludeReposOfExpandedOwners(t *testing.T) { + p := &Picker{Owners: []*Owner{ + {Login: "acme", Expanded: true, Repos: []Repo{{Name: "api"}, {Name: "web"}}}, + {Login: "globex", Repos: []Repo{{Name: "site"}}}, + }} + + want := []Row{{Owner: 0, Repo: -1}, {Owner: 0, Repo: 0}, {Owner: 0, Repo: 1}, {Owner: 1, Repo: -1}} + if got := p.Rows(); !reflect.DeepEqual(got, want) { + t.Fatalf("Rows() = %+v, want %+v", got, want) + } +} + +func TestMoveStopsAtTheLastRow(t *testing.T) { + p := &Picker{Owners: []*Owner{{Login: "acme"}, {Login: "globex"}}} + + p.Move(5) + + if p.Cursor != 1 { + t.Fatalf("Cursor = %d, want 1", p.Cursor) + } +} + +func TestCollapseFromRepoRowMovesCursorToItsOwner(t *testing.T) { + p := &Picker{ + Owners: []*Owner{ + {Login: "acme"}, + {Login: "globex", Expanded: true, Repos: []Repo{{Name: "site"}, {Name: "shop"}}}, + }, + Cursor: 3, + } + + p.Collapse() + + if p.Cursor != 1 || p.Owners[1].Expanded { + t.Fatalf("Cursor = %d, Expanded = %v, want 1 and false", p.Cursor, p.Owners[1].Expanded) + } +} + +func TestSelectedListsChosenReposInDisplayOrder(t *testing.T) { + p := &Picker{Owners: []*Owner{ + {Login: "acme", Repos: []Repo{{Name: "api", SSHURL: "git@github.com:acme/api.git", Selected: true}, {Name: "web"}}}, + {Login: "globex", Repos: []Repo{{Name: "site", SSHURL: "git@github.com:globex/site.git", Selected: true}}}, + }} + + want := []Selection{ + {Owner: "acme", Name: "api", SSHURL: "git@github.com:acme/api.git"}, + {Owner: "globex", Name: "site", SSHURL: "git@github.com:globex/site.git"}, + } + if got := p.Selected(); !reflect.DeepEqual(got, want) { + t.Fatalf("Selected() = %+v, want %+v", got, want) + } +} diff --git a/projects/onboard/internal/tui/BUILD.bazel b/projects/onboard/internal/tui/BUILD.bazel new file mode 100644 index 00000000..6063cd76 --- /dev/null +++ b/projects/onboard/internal/tui/BUILD.bazel @@ -0,0 +1,28 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "tui", + srcs = ["tui.go"], + importpath = "github.com/amriksd/code/projects/onboard/internal/tui", + visibility = ["//projects/onboard:__subpackages__"], + deps = [ + "//projects/onboard/internal/github", + "//projects/onboard/internal/picker", + "//projects/onboard/internal/workspace", + "@com_github_charmbracelet_bubbletea//:bubbletea", + "@com_github_charmbracelet_lipgloss//:lipgloss", + ], +) + +go_test( + name = "tui_test", + srcs = [ + "errors_test.go", + "tui_test.go", + ], + embed = [":tui"], + deps = [ + "//projects/onboard/internal/github", + "//projects/onboard/internal/picker", + ], +) diff --git a/projects/onboard/internal/tui/errors_test.go b/projects/onboard/internal/tui/errors_test.go new file mode 100644 index 00000000..af029808 --- /dev/null +++ b/projects/onboard/internal/tui/errors_test.go @@ -0,0 +1,5 @@ +package tui + +import "errors" + +var errTest = errors.New("repository not found") diff --git a/projects/onboard/internal/tui/tui.go b/projects/onboard/internal/tui/tui.go new file mode 100644 index 00000000..910c3fb0 --- /dev/null +++ b/projects/onboard/internal/tui/tui.go @@ -0,0 +1,443 @@ +// Package tui is the terminal UI: tick owners and repos, then watch them clone. +package tui + +import ( + "fmt" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/amriksd/code/projects/onboard/internal/github" + "github.com/amriksd/code/projects/onboard/internal/picker" + "github.com/amriksd/code/projects/onboard/internal/workspace" +) + +const workers = 6 + +var ( + title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("141")) + bold = lipgloss.NewStyle().Bold(true) + dim = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + good = lipgloss.NewStyle().Foreground(lipgloss.Color("84")) + bad = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + pointer = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) + spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +) + +// Config is what the UI needs from outside. +type Config struct { + // Root is the directory repos are cloned under, as //. + Root string + // Owners limits the picker to these owners, all ticked. When empty the + // signed-in user and their organisations are listed, with only the user ticked. + Owners []string + GH github.Runner + Clone func(root, owner, repo, url string) error +} + +// Result is the outcome of cloning one repo. +type Result struct { + Repo picker.Selection + Err error +} + +type stage int + +const ( + picking stage = iota + cloning + done +) + +type ( + ownersMsg struct { + logins []string + err error + } + reposMsg struct { + owner string + repos []github.Repo + err error + } + clonedMsg struct { + job int + err error + } + registeredMsg struct{ err error } + tickMsg struct{} +) + +// Model is the Bubble Tea model for the whole flow. +type Model struct { + cfg Config + picker *picker.Picker + stage stage + width int + height int + offset int + frame int + discovering bool + ticked map[string]bool + err error + + jobs []picker.Selection + next int + active map[int]bool + Results []Result + RegisterErr error +} + +// New builds the model in its initial, loading state. +func New(cfg Config) Model { + if cfg.Clone == nil { + cfg.Clone = workspace.Clone + } + return Model{cfg: cfg, picker: &picker.Picker{}, height: 24, width: 80, ticked: map[string]bool{}, active: map[int]bool{}} +} + +// Run shows the UI and returns the model as it was when the user left. +func Run(cfg Config) (Model, error) { + final, err := tea.NewProgram(New(cfg), tea.WithAltScreen()).Run() + if err != nil { + return Model{}, err + } + return final.(Model), nil +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(tick(), func() tea.Msg { + if len(m.cfg.Owners) > 0 { + return ownersMsg{logins: m.cfg.Owners} + } + logins, err := github.Owners(m.cfg.GH) + return ownersMsg{logins: logins, err: err} + }) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.scroll() + case tickMsg: + m.frame++ + return m, tick() + case ownersMsg: + return m.gotOwners(msg) + case reposMsg: + m.gotRepos(msg) + case clonedMsg: + return m.gotClone(msg) + case registeredMsg: + m.RegisterErr = msg.err + m.stage = done + case tea.KeyMsg: + return m.key(msg.String()) + } + return m, nil +} + +func (m Model) gotOwners(msg ownersMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.err = msg.err + return m, nil + } + var cmds []tea.Cmd + for i, login := range msg.logins { + m.picker.Owners = append(m.picker.Owners, &picker.Owner{Login: login, Loading: true}) + m.ticked[login] = len(m.cfg.Owners) > 0 || i == 0 + cmds = append(cmds, func() tea.Msg { + repos, err := github.Repos(m.cfg.GH, login) + return reposMsg{owner: login, repos: repos, err: err} + }) + } + return m, tea.Batch(cmds...) +} + +func (m *Model) gotRepos(msg reposMsg) { + for _, owner := range m.picker.Owners { + if owner.Login != msg.owner { + continue + } + owner.Loading = false + owner.Err = msg.err + for _, r := range msg.repos { + owner.Repos = append(owner.Repos, picker.Repo{ + Name: r.Name, + SSHURL: r.SSHURL, + Cloned: workspace.Cloned(m.cfg.Root, owner.Login, r.Name), + }) + } + if m.ticked[owner.Login] { + owner.SetAll(true) + } + } +} + +func (m Model) gotClone(msg clonedMsg) (tea.Model, tea.Cmd) { + delete(m.active, msg.job) + m.Results = append(m.Results, Result{Repo: m.jobs[msg.job], Err: msg.err}) + if m.next < len(m.jobs) { + cmd := m.clone(m.next) + m.next++ + return m, cmd + } + if len(m.Results) < len(m.jobs) { + return m, nil + } + return m, func() tea.Msg { + return registeredMsg{err: workspace.Register(m.cfg.Root, owners(m.jobs))} + } +} + +func (m Model) key(key string) (tea.Model, tea.Cmd) { + if key == "ctrl+c" { + return m, tea.Quit + } + switch m.stage { + case cloning: + return m, nil + case done: + if key == "q" || key == "enter" || key == "esc" { + return m, tea.Quit + } + return m, nil + } + + switch key { + case "q", "esc": + return m, tea.Quit + case "up", "k": + m.picker.Move(-1) + case "down", "j": + m.picker.Move(1) + case "pgup": + m.picker.Move(-m.page()) + case "pgdown": + m.picker.Move(m.page()) + case "home", "g": + m.picker.Move(-len(m.picker.Rows())) + case "end", "G": + m.picker.Move(len(m.picker.Rows())) + case " ", "x": + m.picker.Toggle() + case "right", "l", "tab": + m.picker.Expand() + case "left", "h": + m.picker.Collapse() + case "a": + m.picker.SetAll(true) + case "n": + m.picker.SetAll(false) + case "enter": + return m.start() + } + m.scroll() + return m, nil +} + +func (m Model) start() (tea.Model, tea.Cmd) { + m.jobs = m.picker.Selected() + if len(m.jobs) == 0 { + return m, nil + } + m.stage = cloning + var cmds []tea.Cmd + for m.next < len(m.jobs) && m.next < workers { + cmds = append(cmds, m.clone(m.next)) + m.next++ + } + return m, tea.Batch(cmds...) +} + +func (m Model) clone(job int) tea.Cmd { + m.active[job] = true + repo := m.jobs[job] + return func() tea.Msg { + return clonedMsg{job: job, err: m.cfg.Clone(m.cfg.Root, repo.Owner, repo.Name, repo.SSHURL)} + } +} + +func (m Model) page() int { + if rows := m.height - 7; rows > 3 { + return rows + } + return 3 +} + +func (m *Model) scroll() { + if m.picker.Cursor < m.offset { + m.offset = m.picker.Cursor + } + if m.picker.Cursor >= m.offset+m.page() { + m.offset = m.picker.Cursor - m.page() + 1 + } +} + +func (m Model) View() string { + switch { + case m.err != nil: + return fmt.Sprintf("\n %s\n\n %s\n", bad.Render(m.err.Error()), dim.Render("q quit")) + case m.stage == cloning: + return m.viewCloning() + case m.stage == done: + return m.viewDone() + default: + return m.viewPicking() + } +} + +func (m Model) viewPicking() string { + var b strings.Builder + fmt.Fprintf(&b, "\n %s %s\n\n", title.Render("Clone repositories into"), tilde(m.cfg.Root)) + + if len(m.picker.Owners) == 0 { + fmt.Fprintf(&b, " %s Looking up your GitHub account and organisations\n", m.spin()) + return b.String() + } + + rows := m.picker.Rows() + end := m.offset + m.page() + if end > len(rows) { + end = len(rows) + } + for i := m.offset; i < end; i++ { + cursor := " " + if i == m.picker.Cursor { + cursor = pointer.Render("❯ ") + } + b.WriteString(" " + cursor + m.viewRow(rows[i]) + "\n") + } + + fmt.Fprintf(&b, "\n %s\n %s\n", + bold.Render(fmt.Sprintf("%d to clone", len(m.picker.Selected()))), + dim.Render("space tick · → open · ← close · a all · n none · enter clone · q quit")) + return b.String() +} + +func (m Model) viewRow(row picker.Row) string { + owner := m.picker.Owners[row.Owner] + if row.Repo >= 0 { + repo := owner.Repos[row.Repo] + switch { + case repo.Cloned: + return " " + dim.Render(" ✓ "+repo.Name+" already cloned") + case repo.Selected: + return " " + good.Render("[x]") + " " + repo.Name + default: + return " [ ] " + repo.Name + } + } + + arrow := "▸" + if owner.Expanded { + arrow = "▾" + } + box := "[ ]" + switch owner.State() { + case picker.All: + box = good.Render("[x]") + case picker.Some: + box = good.Render("[-]") + } + line := fmt.Sprintf("%s %s %s", arrow, box, bold.Render(owner.Login)) + switch { + case owner.Loading: + return line + " " + dim.Render(m.spin()+" loading") + case owner.Err != nil: + return line + " " + bad.Render(owner.Err.Error()) + } + selected, cloned := owner.Count() + return line + " " + dim.Render(fmt.Sprintf("%d of %d ticked · %d already cloned", selected, len(owner.Repos)-cloned, cloned)) +} + +func (m Model) viewCloning() string { + var b strings.Builder + finished, total := len(m.Results), len(m.jobs) + fmt.Fprintf(&b, "\n %s\n\n", title.Render(fmt.Sprintf("Cloning %d of %d", finished, total))) + + const width = 40 + filled := width * finished / total + fmt.Fprintf(&b, " %s%s\n\n", good.Render(strings.Repeat("█", filled)), dim.Render(strings.Repeat("░", width-filled))) + + room := m.height - 8 - len(m.active) + if room < 3 { + room = 3 + } + first := 0 + if len(m.Results) > room { + first = len(m.Results) - room + } + for _, r := range m.Results[first:] { + b.WriteString(" " + viewResult(r) + "\n") + } + for job := range m.jobs { + if m.active[job] { + fmt.Fprintf(&b, " %s %s/%s\n", m.spin(), m.jobs[job].Owner, m.jobs[job].Name) + } + } + return b.String() +} + +func (m Model) viewDone() string { + var b strings.Builder + failed := m.Failed() + fmt.Fprintf(&b, "\n %s %s\n\n", title.Render(fmt.Sprintf("Cloned %d of %d into", len(m.Results)-len(failed), len(m.Results))), tilde(m.cfg.Root)) + for _, r := range failed { + b.WriteString(" " + viewResult(r) + "\n") + } + if m.RegisterErr != nil { + b.WriteString(" " + bad.Render(m.RegisterErr.Error()) + "\n") + } + b.WriteString("\n " + dim.Render("q quit") + "\n") + return b.String() +} + +// Failed lists the repos that could not be cloned. +func (m Model) Failed() []Result { + var failed []Result + for _, r := range m.Results { + if r.Err != nil { + failed = append(failed, r) + } + } + return failed +} + +func viewResult(r Result) string { + name := r.Repo.Owner + "/" + r.Repo.Name + if r.Err != nil { + return bad.Render("✗ "+name) + " " + dim.Render(r.Err.Error()) + } + return good.Render("✓") + " " + name +} + +func (m Model) spin() string { + return spinner[m.frame%len(spinner)] +} + +func tilde(path string) string { + if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(path, home) { + return "~" + strings.TrimPrefix(path, home) + } + return path +} + +func tick() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} }) +} + +func owners(jobs []picker.Selection) []string { + var out []string + seen := map[string]bool{} + for _, j := range jobs { + if !seen[j.Owner] { + seen[j.Owner] = true + out = append(out, j.Owner) + } + } + return out +} diff --git a/projects/onboard/internal/tui/tui_test.go b/projects/onboard/internal/tui/tui_test.go new file mode 100644 index 00000000..e2430d14 --- /dev/null +++ b/projects/onboard/internal/tui/tui_test.go @@ -0,0 +1,73 @@ +package tui + +import ( + "reflect" + "testing" + + "github.com/amriksd/code/projects/onboard/internal/github" + "github.com/amriksd/code/projects/onboard/internal/picker" +) + +func TestOnlyTheSignedInUserIsTickedByDefault(t *testing.T) { + m := New(Config{Root: t.TempDir()}) + + next, _ := m.gotOwners(ownersMsg{logins: []string{"amrik", "acme"}}) + m = next.(Model) + m.gotRepos(reposMsg{owner: "amrik", repos: []github.Repo{{Name: "dotfiles"}}}) + m.gotRepos(reposMsg{owner: "acme", repos: []github.Repo{{Name: "api"}}}) + + want := []picker.Selection{{Owner: "amrik", Name: "dotfiles"}} + if got := m.picker.Selected(); !reflect.DeepEqual(got, want) { + t.Fatalf("Selected() = %+v, want %+v", got, want) + } +} + +func TestOwnersGivenUpFrontAreAllTicked(t *testing.T) { + m := New(Config{Root: t.TempDir(), Owners: []string{"acme", "globex"}}) + + next, _ := m.gotOwners(ownersMsg{logins: []string{"acme", "globex"}}) + m = next.(Model) + m.gotRepos(reposMsg{owner: "acme", repos: []github.Repo{{Name: "api"}}}) + m.gotRepos(reposMsg{owner: "globex", repos: []github.Repo{{Name: "site"}}}) + + want := []picker.Selection{{Owner: "acme", Name: "api"}, {Owner: "globex", Name: "site"}} + if got := m.picker.Selected(); !reflect.DeepEqual(got, want) { + t.Fatalf("Selected() = %+v, want %+v", got, want) + } +} + +func TestEnterWithNothingTickedStaysOnThePicker(t *testing.T) { + m := New(Config{Root: t.TempDir()}) + m.picker.Owners = []*picker.Owner{{Login: "acme", Repos: []picker.Repo{{Name: "api"}}}} + + next, _ := m.key("enter") + + if got := next.(Model).stage; got != picking { + t.Fatalf("stage = %v, want picking", got) + } +} + +func TestEnterWithATickedRepoStartsCloning(t *testing.T) { + m := New(Config{Root: t.TempDir()}) + m.picker.Owners = []*picker.Owner{{Login: "acme", Repos: []picker.Repo{{Name: "api", Selected: true}}}} + + next, _ := m.key("enter") + + if got := next.(Model).stage; got != cloning { + t.Fatalf("stage = %v, want cloning", got) + } +} + +func TestAFailedCloneIsReportedAsFailed(t *testing.T) { + m := New(Config{Root: t.TempDir()}) + m.jobs = []picker.Selection{{Owner: "acme", Name: "api"}, {Owner: "acme", Name: "web"}} + m.next = 2 + + next, _ := m.gotClone(clonedMsg{job: 0, err: errTest}) + next, _ = next.(Model).gotClone(clonedMsg{job: 1}) + + want := []Result{{Repo: picker.Selection{Owner: "acme", Name: "api"}, Err: errTest}} + if got := next.(Model).Failed(); !reflect.DeepEqual(got, want) { + t.Fatalf("Failed() = %+v, want %+v", got, want) + } +} diff --git a/projects/onboard/internal/workspace/BUILD.bazel b/projects/onboard/internal/workspace/BUILD.bazel new file mode 100644 index 00000000..69c60299 --- /dev/null +++ b/projects/onboard/internal/workspace/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "workspace", + srcs = ["workspace.go"], + importpath = "github.com/amriksd/code/projects/onboard/internal/workspace", + visibility = ["//projects/onboard:__subpackages__"], +) + +go_test( + name = "workspace_test", + srcs = ["workspace_test.go"], + embed = [":workspace"], +) diff --git a/projects/onboard/internal/workspace/workspace.go b/projects/onboard/internal/workspace/workspace.go new file mode 100644 index 00000000..8ca02926 --- /dev/null +++ b/projects/onboard/internal/workspace/workspace.go @@ -0,0 +1,61 @@ +// Package workspace manages the local checkouts, laid out as //. +package workspace + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Path is where a repo is checked out. +func Path(root, owner, repo string) string { + return filepath.Join(root, owner, repo) +} + +// Cloned reports whether a checkout of the repo already exists. +func Cloned(root, owner, repo string) bool { + _, err := os.Stat(filepath.Join(Path(root, owner, repo), ".git")) + return err == nil +} + +// Clone checks the repo out into its place under root. +func Clone(root, owner, repo, url string) error { + dest := Path(root, owner, repo) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + cmd := exec.Command("git", "clone", "--quiet", url, dest) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if os.Getenv("GIT_SSH_COMMAND") == "" { + // A new machine has no known_hosts yet, and a prompt would hang behind the TUI. + cmd.Env = append(cmd.Env, "GIT_SSH_COMMAND=ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new") + } + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s", lastLine(string(out), err.Error())) + } + return nil +} + +// Register adds each owner's checkouts to gita, grouped by owner. It does +// nothing when gita is not installed. +func Register(root string, owners []string) error { + if _, err := exec.LookPath("gita"); err != nil { + return nil + } + for _, owner := range owners { + if out, err := exec.Command("gita", "add", "-a", filepath.Join(root, owner)).CombinedOutput(); err != nil { + return fmt.Errorf("gita add %s: %s", owner, lastLine(string(out), err.Error())) + } + } + return nil +} + +func lastLine(out, fallback string) string { + lines := strings.Split(strings.TrimSpace(out), "\n") + if last := strings.TrimSpace(lines[len(lines)-1]); last != "" { + return last + } + return fallback +} diff --git a/projects/onboard/internal/workspace/workspace_test.go b/projects/onboard/internal/workspace/workspace_test.go new file mode 100644 index 00000000..65f4324a --- /dev/null +++ b/projects/onboard/internal/workspace/workspace_test.go @@ -0,0 +1,45 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestClonedIsTrueWhenTheCheckoutHasAGitDir(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "acme", "api", ".git"), 0o755); err != nil { + t.Fatal(err) + } + + if !Cloned(root, "acme", "api") { + t.Fatal("Cloned() = false, want true") + } +} + +func TestClonedIsFalseForAPlainDirectory(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "acme", "api"), 0o755); err != nil { + t.Fatal(err) + } + + if Cloned(root, "acme", "api") { + t.Fatal("Cloned() = true, want false") + } +} + +func TestLastLineReturnsTheFinalLineOfGitOutput(t *testing.T) { + got := lastLine("Cloning into 'api'...\nfatal: repository not found\n", "exit status 128") + + if got != "fatal: repository not found" { + t.Fatalf("lastLine() = %q, want %q", got, "fatal: repository not found") + } +} + +func TestLastLineFallsBackWhenThereIsNoOutput(t *testing.T) { + got := lastLine("", "exit status 128") + + if got != "exit status 128" { + t.Fatalf("lastLine() = %q, want %q", got, "exit status 128") + } +} From 624d72d2e978a898e14b90ed6997a32a9b2d773e Mon Sep 17 00:00:00 2001 From: AmrikSD Date: Mon, 5 Oct 2026 17:55:59 +0100 Subject: [PATCH 2/3] feat(onboard): set the machine up before cloning, with a first-run script --- projects/onboard/README.md | 22 ++++ projects/onboard/cmd/onboard/main.go | 32 ++++-- projects/onboard/internal/machine/BUILD.bazel | 14 +++ projects/onboard/internal/machine/machine.go | 35 ++++++ .../onboard/internal/machine/machine_test.go | 42 +++++++ projects/onboard/internal/tui/BUILD.bazel | 1 + projects/onboard/internal/tui/tui.go | 105 +++++++++++++++++- projects/onboard/internal/tui/tui_test.go | 60 ++++++++++ .../onboard/internal/workspace/workspace.go | 12 +- projects/onboard/setup.sh | 18 +++ 10 files changed, 328 insertions(+), 13 deletions(-) create mode 100644 projects/onboard/README.md create mode 100644 projects/onboard/internal/machine/BUILD.bazel create mode 100644 projects/onboard/internal/machine/machine.go create mode 100644 projects/onboard/internal/machine/machine_test.go create mode 100755 projects/onboard/setup.sh diff --git a/projects/onboard/README.md b/projects/onboard/README.md new file mode 100644 index 00000000..49132701 --- /dev/null +++ b/projects/onboard/README.md @@ -0,0 +1,22 @@ +# onboard + +Sets up a new machine. + +```sh +git clone https://github.com/AmrikSD/code ~/code/AmrikSD/code +~/code/AmrikSD/code/projects/onboard/setup.sh +``` + +`setup.sh` installs Nix if it is missing, then builds and starts the onboarding flow: + +1. Signs in to GitHub with `gh` if needed. Pick SSH and let it upload a key. +2. Tools and config: clones the dotfiles repo into `~/.dotfiles` and runs its `bootstrap.sh`, which installs every tool and app and links the config. Tick "Work setup" to fetch the work-only config too. +3. Repositories: lists your account and organisations with a tick box each. Ticking an owner ticks every repo under it, opening one lets you pick repos individually. Whatever is ticked is cloned into `~/code//`. + +Every step is safe to re-run. Repos that are already cloned are skipped. + +On a machine that is already set up, run it directly: + +```sh +bazel run //projects/onboard/cmd/onboard +``` diff --git a/projects/onboard/cmd/onboard/main.go b/projects/onboard/cmd/onboard/main.go index 926589c8..13e92c14 100644 --- a/projects/onboard/cmd/onboard/main.go +++ b/projects/onboard/cmd/onboard/main.go @@ -1,12 +1,13 @@ -// Command onboard clones your GitHub repositories onto a new machine. It shows -// your account and organisations with a tick box each, and clones whatever is -// ticked into //. +// Command onboard sets up a new machine. It fetches the dotfiles repo and runs +// its bootstrap, then shows your GitHub account and organisations with a tick +// box each and clones whatever is ticked into //. package main import ( "flag" "fmt" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -24,11 +25,12 @@ func run() int { home, _ := os.UserHomeDir() dir := flag.String("dir", filepath.Join(home, "code"), "directory to clone into, as //") owner := flag.String("owner", "", "comma separated owners to offer, instead of your account and all your organisations") - yes := flag.Bool("yes", false, "skip the picker and clone everything: the -owner list, or your own account") + yes := flag.Bool("yes", false, "skip the setup and picker and clone everything: the -owner list, or your own account") + dotfiles := flag.String("dotfiles", "AmrikSD/.dotfiles", "dotfiles repo whose bootstrap.sh sets the machine up") + dotfilesDir := flag.String("dotfiles-dir", filepath.Join(home, ".dotfiles"), "where the dotfiles repo lives") flag.Parse() - if _, err := github.GH("auth", "status"); err != nil { - fmt.Fprintln(os.Stderr, "Not signed in to GitHub. Run: gh auth login") + if !signedIn() { return 1 } @@ -41,7 +43,7 @@ func run() int { return cloneAll(*dir, owners) } - model, err := tui.Run(tui.Config{Root: *dir, Owners: owners, GH: github.GH}) + model, err := tui.Run(tui.Config{Root: *dir, Owners: owners, GH: github.GH, Dotfiles: *dotfiles, DotfilesDir: *dotfilesDir}) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 @@ -57,6 +59,22 @@ func run() int { return 0 } +// signedIn makes sure gh is signed in, walking through gh's own login when it +// is not. SSH is asked for because repos are cloned over SSH, and gh offers to +// create and upload a key for a machine that has none. +func signedIn() bool { + if _, err := github.GH("auth", "status"); err == nil { + return true + } + login := exec.Command("gh", "auth", "login", "--git-protocol", "ssh", "--web") + login.Stdin, login.Stdout, login.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := login.Run(); err != nil { + fmt.Fprintln(os.Stderr, "Could not sign in to GitHub:", err) + return false + } + return true +} + func cloneAll(dir string, owners []string) int { if len(owners) == 0 { all, err := github.Owners(github.GH) diff --git a/projects/onboard/internal/machine/BUILD.bazel b/projects/onboard/internal/machine/BUILD.bazel new file mode 100644 index 00000000..4486c248 --- /dev/null +++ b/projects/onboard/internal/machine/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "machine", + srcs = ["machine.go"], + importpath = "github.com/amriksd/code/projects/onboard/internal/machine", + visibility = ["//projects/onboard:__subpackages__"], +) + +go_test( + name = "machine_test", + srcs = ["machine_test.go"], + embed = [":machine"], +) diff --git a/projects/onboard/internal/machine/machine.go b/projects/onboard/internal/machine/machine.go new file mode 100644 index 00000000..d8c1ca2e --- /dev/null +++ b/projects/onboard/internal/machine/machine.go @@ -0,0 +1,35 @@ +// Package machine sets the machine itself up: it fetches the dotfiles repo and +// runs its bootstrap, which installs tools and links config. +package machine + +import ( + "os" + "os/exec" + "path/filepath" +) + +const script = `set -euo pipefail +repo=$1 dir=$2 work=$3 +[ -d "$dir/.git" ] || gh repo clone "$repo" "$dir" +if [ "$work" = work ]; then git -C "$dir" submodule update --init; fi +"$dir/bootstrap.sh" +printf '\nPress enter to continue. ' +read -r _ +` + +// HasDotfiles reports whether the dotfiles repo is already checked out at dir. +func HasDotfiles(dir string) bool { + _, err := os.Stat(filepath.Join(dir, ".git")) + return err == nil +} + +// Bootstrap returns the command that clones repo into dir if needed and runs +// its bootstrap.sh. With work set it fetches the repo's submodules first, which +// is where the work-only config lives. +func Bootstrap(repo, dir string, work bool) *exec.Cmd { + mode := "personal" + if work { + mode = "work" + } + return exec.Command("bash", "-c", script, "bootstrap", repo, dir, mode) +} diff --git a/projects/onboard/internal/machine/machine_test.go b/projects/onboard/internal/machine/machine_test.go new file mode 100644 index 00000000..b4be3ec8 --- /dev/null +++ b/projects/onboard/internal/machine/machine_test.go @@ -0,0 +1,42 @@ +package machine + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestHasDotfilesIsTrueForACheckout(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + + if !HasDotfiles(dir) { + t.Fatal("HasDotfiles() = false, want true") + } +} + +func TestHasDotfilesIsFalseForAMissingDirectory(t *testing.T) { + if HasDotfiles(filepath.Join(t.TempDir(), "missing")) { + t.Fatal("HasDotfiles() = true, want false") + } +} + +func TestBootstrapPassesRepoDirAndWorkModeToTheScript(t *testing.T) { + cmd := Bootstrap("amrik/dotfiles", "/home/amrik/.dotfiles", true) + + want := []string{"bash", "-c", script, "bootstrap", "amrik/dotfiles", "/home/amrik/.dotfiles", "work"} + if !reflect.DeepEqual(cmd.Args, want) { + t.Fatalf("Args = %q, want %q", cmd.Args, want) + } +} + +func TestBootstrapWithoutWorkRunsInPersonalMode(t *testing.T) { + cmd := Bootstrap("amrik/dotfiles", "/home/amrik/.dotfiles", false) + + if got := cmd.Args[len(cmd.Args)-1]; got != "personal" { + t.Fatalf("mode = %q, want %q", got, "personal") + } +} diff --git a/projects/onboard/internal/tui/BUILD.bazel b/projects/onboard/internal/tui/BUILD.bazel index 6063cd76..78cd2a7a 100644 --- a/projects/onboard/internal/tui/BUILD.bazel +++ b/projects/onboard/internal/tui/BUILD.bazel @@ -7,6 +7,7 @@ go_library( visibility = ["//projects/onboard:__subpackages__"], deps = [ "//projects/onboard/internal/github", + "//projects/onboard/internal/machine", "//projects/onboard/internal/picker", "//projects/onboard/internal/workspace", "@com_github_charmbracelet_bubbletea//:bubbletea", diff --git a/projects/onboard/internal/tui/tui.go b/projects/onboard/internal/tui/tui.go index 910c3fb0..0ca897c6 100644 --- a/projects/onboard/internal/tui/tui.go +++ b/projects/onboard/internal/tui/tui.go @@ -1,9 +1,11 @@ -// Package tui is the terminal UI: tick owners and repos, then watch them clone. +// Package tui is the terminal UI: set the machine up, tick owners and repos, +// then watch them clone. package tui import ( "fmt" "os" + "os/exec" "strings" "time" @@ -11,6 +13,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/amriksd/code/projects/onboard/internal/github" + "github.com/amriksd/code/projects/onboard/internal/machine" "github.com/amriksd/code/projects/onboard/internal/picker" "github.com/amriksd/code/projects/onboard/internal/workspace" ) @@ -36,6 +39,12 @@ type Config struct { Owners []string GH github.Runner Clone func(root, owner, repo, url string) error + + // Dotfiles is the owner/name of the dotfiles repo and DotfilesDir is where + // it lives. Its bootstrap.sh installs tools and links config. + Dotfiles string + DotfilesDir string + Bootstrap func(repo, dir string, work bool) *exec.Cmd } // Result is the outcome of cloning one repo. @@ -47,7 +56,8 @@ type Result struct { type stage int const ( - picking stage = iota + setup stage = iota + picking cloning done ) @@ -67,6 +77,7 @@ type ( err error } registeredMsg struct{ err error } + bootstrapMsg struct{ err error } tickMsg struct{} ) @@ -83,6 +94,11 @@ type Model struct { ticked map[string]bool err error + setupCursor int + setupTools bool + setupWork bool + bootstrapErr error + jobs []picker.Selection next int active map[int]bool @@ -95,7 +111,18 @@ func New(cfg Config) Model { if cfg.Clone == nil { cfg.Clone = workspace.Clone } - return Model{cfg: cfg, picker: &picker.Picker{}, height: 24, width: 80, ticked: map[string]bool{}, active: map[int]bool{}} + if cfg.Bootstrap == nil { + cfg.Bootstrap = machine.Bootstrap + } + return Model{ + cfg: cfg, + picker: &picker.Picker{}, + height: 24, + width: 80, + ticked: map[string]bool{}, + active: map[int]bool{}, + setupTools: !machine.HasDotfiles(cfg.DotfilesDir), + } } // Run shows the UI and returns the model as it was when the user left. @@ -134,6 +161,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case registeredMsg: m.RegisterErr = msg.err m.stage = done + case bootstrapMsg: + m.bootstrapErr = msg.err + m.setupTools, m.setupWork = false, false + if msg.err == nil { + m.stage = picking + } case tea.KeyMsg: return m.key(msg.String()) } @@ -198,6 +231,8 @@ func (m Model) key(key string) (tea.Model, tea.Cmd) { return m, tea.Quit } switch m.stage { + case setup: + return m.keySetup(key) case cloning: return m, nil case done: @@ -239,6 +274,33 @@ func (m Model) key(key string) (tea.Model, tea.Cmd) { return m, nil } +func (m Model) keySetup(key string) (tea.Model, tea.Cmd) { + switch key { + case "q", "esc": + return m, tea.Quit + case "up", "k": + m.setupCursor = 0 + case "down", "j": + m.setupCursor = 1 + case " ", "x": + if m.setupCursor == 0 { + m.setupTools = !m.setupTools + m.setupWork = m.setupWork && m.setupTools + } else { + m.setupWork = !m.setupWork + m.setupTools = m.setupTools || m.setupWork + } + case "enter": + if !m.setupTools { + m.stage = picking + return m, nil + } + cmd := m.cfg.Bootstrap(m.cfg.Dotfiles, m.cfg.DotfilesDir, m.setupWork) + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return bootstrapMsg{err: err} }) + } + return m, nil +} + func (m Model) start() (tea.Model, tea.Cmd) { m.jobs = m.picker.Selected() if len(m.jobs) == 0 { @@ -281,6 +343,8 @@ func (m Model) View() string { switch { case m.err != nil: return fmt.Sprintf("\n %s\n\n %s\n", bad.Render(m.err.Error()), dim.Render("q quit")) + case m.stage == setup: + return m.viewSetup() case m.stage == cloning: return m.viewCloning() case m.stage == done: @@ -290,6 +354,41 @@ func (m Model) View() string { } } +func (m Model) viewSetup() string { + var b strings.Builder + fmt.Fprintf(&b, "\n %s\n\n", title.Render("Set up this machine")) + + tools := fmt.Sprintf("clones %s into %s and runs its bootstrap", m.cfg.Dotfiles, tilde(m.cfg.DotfilesDir)) + if machine.HasDotfiles(m.cfg.DotfilesDir) { + tools = fmt.Sprintf("%s is already here, tick to run its bootstrap again", tilde(m.cfg.DotfilesDir)) + } + rows := []struct { + ticked bool + name string + detail string + }{ + {m.setupTools, "Tools and config", tools}, + {m.setupWork, "Work setup ", "also fetches the work-only config"}, + } + for i, row := range rows { + cursor := " " + if i == m.setupCursor { + cursor = pointer.Render("❯ ") + } + box := "[ ]" + if row.ticked { + box = good.Render("[x]") + } + fmt.Fprintf(&b, " %s%s %s %s\n", cursor, box, bold.Render(row.name), dim.Render(row.detail)) + } + + if m.bootstrapErr != nil { + fmt.Fprintf(&b, "\n %s\n", bad.Render("The bootstrap failed: "+m.bootstrapErr.Error())) + } + fmt.Fprintf(&b, "\n %s\n", dim.Render("space tick · enter continue to repositories · q quit")) + return b.String() +} + func (m Model) viewPicking() string { var b strings.Builder fmt.Fprintf(&b, "\n %s %s\n\n", title.Render("Clone repositories into"), tilde(m.cfg.Root)) diff --git a/projects/onboard/internal/tui/tui_test.go b/projects/onboard/internal/tui/tui_test.go index e2430d14..789da609 100644 --- a/projects/onboard/internal/tui/tui_test.go +++ b/projects/onboard/internal/tui/tui_test.go @@ -1,6 +1,8 @@ package tui import ( + "os" + "path/filepath" "reflect" "testing" @@ -39,6 +41,7 @@ func TestOwnersGivenUpFrontAreAllTicked(t *testing.T) { func TestEnterWithNothingTickedStaysOnThePicker(t *testing.T) { m := New(Config{Root: t.TempDir()}) m.picker.Owners = []*picker.Owner{{Login: "acme", Repos: []picker.Repo{{Name: "api"}}}} + m.stage = picking next, _ := m.key("enter") @@ -50,6 +53,7 @@ func TestEnterWithNothingTickedStaysOnThePicker(t *testing.T) { func TestEnterWithATickedRepoStartsCloning(t *testing.T) { m := New(Config{Root: t.TempDir()}) m.picker.Owners = []*picker.Owner{{Login: "acme", Repos: []picker.Repo{{Name: "api", Selected: true}}}} + m.stage = picking next, _ := m.key("enter") @@ -71,3 +75,59 @@ func TestAFailedCloneIsReportedAsFailed(t *testing.T) { t.Fatalf("Failed() = %+v, want %+v", got, want) } } + +func TestToolsAreTickedWhenTheDotfilesAreMissing(t *testing.T) { + m := New(Config{DotfilesDir: filepath.Join(t.TempDir(), "missing")}) + + if !m.setupTools { + t.Fatal("setupTools = false, want true") + } +} + +func TestToolsAreNotTickedWhenTheDotfilesAreAlreadyThere(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + + m := New(Config{DotfilesDir: dir}) + + if m.setupTools { + t.Fatal("setupTools = true, want false") + } +} + +func TestTickingWorkSetupAlsoTicksTools(t *testing.T) { + m := New(Config{DotfilesDir: t.TempDir()}) + m.setupTools = false + m.setupCursor = 1 + + next, _ := m.key(" ") + + got := next.(Model) + if !got.setupTools || !got.setupWork { + t.Fatalf("setupTools = %v, setupWork = %v, want both true", got.setupTools, got.setupWork) + } +} + +func TestEnterOnSetupWithNothingTickedGoesToThePicker(t *testing.T) { + m := New(Config{DotfilesDir: t.TempDir()}) + m.setupTools = false + + next, _ := m.key("enter") + + if got := next.(Model).stage; got != picking { + t.Fatalf("stage = %v, want picking", got) + } +} + +func TestAFailedBootstrapStaysOnSetupWithTheError(t *testing.T) { + m := New(Config{DotfilesDir: t.TempDir()}) + + next, _ := m.Update(bootstrapMsg{err: errTest}) + + got := next.(Model) + if got.stage != setup || got.bootstrapErr != errTest { + t.Fatalf("stage = %v, bootstrapErr = %v, want setup and %v", got.stage, got.bootstrapErr, errTest) + } +} diff --git a/projects/onboard/internal/workspace/workspace.go b/projects/onboard/internal/workspace/workspace.go index 8ca02926..bb3be397 100644 --- a/projects/onboard/internal/workspace/workspace.go +++ b/projects/onboard/internal/workspace/workspace.go @@ -41,11 +41,17 @@ func Clone(root, owner, repo, url string) error { // Register adds each owner's checkouts to gita, grouped by owner. It does // nothing when gita is not installed. func Register(root string, owners []string) error { - if _, err := exec.LookPath("gita"); err != nil { - return nil + gita, err := exec.LookPath("gita") + if err != nil { + // Tools installed during this run are not on this process's PATH yet. + home, _ := os.UserHomeDir() + gita = filepath.Join(home, ".nix-profile", "bin", "gita") + if _, err := os.Stat(gita); err != nil { + return nil + } } for _, owner := range owners { - if out, err := exec.Command("gita", "add", "-a", filepath.Join(root, owner)).CombinedOutput(); err != nil { + if out, err := exec.Command(gita, "add", "-a", filepath.Join(root, owner)).CombinedOutput(); err != nil { return fmt.Errorf("gita add %s: %s", owner, lastLine(string(out), err.Error())) } } diff --git a/projects/onboard/setup.sh b/projects/onboard/setup.sh new file mode 100755 index 00000000..62478585 --- /dev/null +++ b/projects/onboard/setup.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# The first thing to run on a new machine. Installs Nix if it is missing, then +# starts the onboarding flow with the few tools it needs. +set -eo pipefail + +cd "$(dirname "$0")/../.." + +nix_profile=/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh +if ! command -v nix >/dev/null; then + if [ ! -e "$nix_profile" ]; then + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install + fi + # shellcheck disable=SC1090 + . "$nix_profile" +fi + +exec nix shell nixpkgs#bazelisk nixpkgs#gh nixpkgs#git --command \ + bazelisk run //projects/onboard/cmd/onboard -- "$@" From 38a6df651c35a7934b8de1548e9df6da236ffd82 Mon Sep 17 00:00:00 2001 From: AmrikSD Date: Mon, 5 Oct 2026 18:41:41 +0100 Subject: [PATCH 3/3] feat(onboard): update an existing dotfiles checkout, document setup --- projects/onboard/README.md | 28 ++++++++++++++++---- projects/onboard/internal/machine/machine.go | 10 ++++--- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/projects/onboard/README.md b/projects/onboard/README.md index 49132701..41067208 100644 --- a/projects/onboard/README.md +++ b/projects/onboard/README.md @@ -1,6 +1,8 @@ # onboard -Sets up a new machine. +Sets up a new machine from nothing. + +## Setup ```sh git clone https://github.com/AmrikSD/code ~/code/AmrikSD/code @@ -9,14 +11,30 @@ git clone https://github.com/AmrikSD/code ~/code/AmrikSD/code `setup.sh` installs Nix if it is missing, then builds and starts the onboarding flow: -1. Signs in to GitHub with `gh` if needed. Pick SSH and let it upload a key. -2. Tools and config: clones the dotfiles repo into `~/.dotfiles` and runs its `bootstrap.sh`, which installs every tool and app and links the config. Tick "Work setup" to fetch the work-only config too. +1. GitHub: signs in with `gh` if needed. Pick SSH and let it upload a key. +2. Tools and config: clones the dotfiles repo into `~/.dotfiles`, or updates it, and runs its `bootstrap.sh`. That installs every tool and app, links the config, adds the Claude Code hooks and imports the GPG signing key. Tick "Work setup" on a work machine to fetch the work-only config too. 3. Repositories: lists your account and organisations with a tick box each. Ticking an owner ticks every repo under it, opening one lets you pick repos individually. Whatever is ticked is cloned into `~/code//`. -Every step is safe to re-run. Repos that are already cloned are skipped. +Then open a new terminal. + +Before you start, on a Mac: install the 1Password app, sign in, and turn on "Integrate with 1Password CLI" in its Developer settings. The GPG key and the work secrets are read from it. Without it the setup still finishes, and re-running it later picks them up. -On a machine that is already set up, run it directly: +## Enjoy + +Every step is safe to re-run, and repos that are already cloned are skipped. To run it again on a machine that is set up: ```sh bazel run //projects/onboard/cmd/onboard ``` + +Day to day, tools are added, pinned and upgraded through the dotfiles repo. See `~/.dotfiles/NIX.md`. + +## WIP + +Known gaps, most useful first: + +- The Mac-only parts of the dotfiles bootstrap have not had a real first run: copying apps into `~/Applications/Nix Apps`, pointing gpg at the Nix pinentry, the `docker compose` link and the 1Password steps. +- `setup.sh` has only been read, not run, since running it installs Nix. +- Apple Silicon and Linux only. Current nixpkgs has dropped Intel Macs. +- On ARM Linux, Node 26 has no prebuilt package and fails to compile, which fails the tool install. Swap `nodejs_26` for `nodejs_24` in `~/.dotfiles/tools/tools.nix`. +- No search in the picker. Individual repos in a big organisation mean scrolling. diff --git a/projects/onboard/internal/machine/machine.go b/projects/onboard/internal/machine/machine.go index d8c1ca2e..c0cdfe86 100644 --- a/projects/onboard/internal/machine/machine.go +++ b/projects/onboard/internal/machine/machine.go @@ -10,7 +10,11 @@ import ( const script = `set -euo pipefail repo=$1 dir=$2 work=$3 -[ -d "$dir/.git" ] || gh repo clone "$repo" "$dir" +if [ -d "$dir/.git" ]; then + git -C "$dir" pull --ff-only || echo "Could not update $dir, carrying on with what is there." +else + gh repo clone "$repo" "$dir" +fi if [ "$work" = work ]; then git -C "$dir" submodule update --init; fi "$dir/bootstrap.sh" printf '\nPress enter to continue. ' @@ -23,8 +27,8 @@ func HasDotfiles(dir string) bool { return err == nil } -// Bootstrap returns the command that clones repo into dir if needed and runs -// its bootstrap.sh. With work set it fetches the repo's submodules first, which +// Bootstrap returns the command that clones repo into dir, or updates the +// checkout already there, and runs its bootstrap.sh. With work set it fetches the repo's submodules first, which // is where the work-only config lives. func Bootstrap(repo, dir string, work bool) *exec.Cmd { mode := "personal"