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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion go.MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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")
25 changes: 25 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
43 changes: 43 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
40 changes: 40 additions & 0 deletions projects/onboard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# onboard

Sets up a new machine from nothing.

## Setup

```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. 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/<owner>/<repo>`.

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.

## 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.
19 changes: 19 additions & 0 deletions projects/onboard/cmd/onboard/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"],
)
129 changes: 129 additions & 0 deletions projects/onboard/cmd/onboard/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// 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 <dir>/<owner>/<repo>.
package main

import (
"flag"
"fmt"
"os"
"os/exec"
"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 <dir>/<owner>/<repo>")
owner := flag.String("owner", "", "comma separated owners to offer, instead of your account and all your organisations")
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 !signedIn() {
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, Dotfiles: *dotfiles, DotfilesDir: *dotfilesDir})
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
}

// 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)
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
}
14 changes: 14 additions & 0 deletions projects/onboard/internal/github/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"],
)
65 changes: 65 additions & 0 deletions projects/onboard/internal/github/github.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading