Skip to content
Open
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 .agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ The binary's interface is the product. Keep it small and stable.
- `agent-init list-flavors` — print available flavors with descriptions.
- `agent-init list-trackers` — print available trackers with descriptions.
- `agent-init version` — print version info (commit + build date, embedded via `-ldflags`).
- `agent-init upgrade [--check] [--dry-run] [--force]` — update the binary in place from the latest GitHub release. Verifies SHA-256 against the published `checksums.txt` and fails closed on mismatch. See [`docs/cli.md`](../docs/cli.md#upgrade).

### Releases

Expand Down
24 changes: 15 additions & 9 deletions .agent/CODEBASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._
| |-- iac.md
| `-- project-management.md
|-- go.mod
|-- go.sum
|-- internal
| |-- cli
| | |-- cli.go
Expand Down Expand Up @@ -60,6 +61,11 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._
| | |-- term_darwin.go
| | |-- term_linux.go
| | `-- term_other.go
| |-- selfupdate
| | |-- github.go
| | |-- github_test.go
| | |-- selfupdate.go
| | `-- selfupdate_test.go
| |-- testflags
| | `-- testflags.go
| `-- trackers
Expand Down Expand Up @@ -89,9 +95,9 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._
## Public API surface

### Go
internal/cli/cli.go:66:type Version struct {
internal/cli/cli.go:152:type App struct {
internal/cli/cli.go:160:func New(out, errOut io.Writer, version Version) App {
internal/cli/cli.go:67:type Version struct {
internal/cli/cli.go:170:type App struct {
internal/cli/cli.go:178:func New(out, errOut io.Writer, version Version) App {
internal/cli/cli_test.go:15:func TestListFlavors(t *testing.T) {
internal/cli/cli_test.go:28:func TestInitLegacyTargetArgument(t *testing.T) {
internal/cli/cli_test.go:47:func TestInitLegacyTargetArgumentWithoutFlag(t *testing.T) {
Expand Down Expand Up @@ -131,8 +137,11 @@ internal/cli/cli_test.go:769:func TestFlaglessSubcommandHelp(t *testing.T) {
internal/cli/cli_test.go:787:func TestUnknownCommandErrorPointsAtHelp(t *testing.T) {
internal/cli/cli_test.go:799:func TestUnknownFlavorErrorPointsAtHelp(t *testing.T) {
internal/cli/cli_test.go:815:func TestHelpFlagsMatchDocs(t *testing.T) {
internal/cli/cli_test.go:842:func TestVersion(t *testing.T) {
internal/cli/cli_test.go:855:func TestVersionDefaultsToDev(t *testing.T) {
internal/cli/cli_test.go:842:func TestUpgradeHelp(t *testing.T) {
internal/cli/cli_test.go:859:func TestUpgradeDevBuildRefusedWithoutForce(t *testing.T) {
internal/cli/cli_test.go:871:func TestUpgradeRejectsPositionalArgs(t *testing.T) {
internal/cli/cli_test.go:880:func TestVersion(t *testing.T) {
internal/cli/cli_test.go:893:func TestVersionDefaultsToDev(t *testing.T) {
internal/flavors/claudecowork/flavor.go:11:func Templates() embed.FS {
internal/flavors/claudecowork/flavor.go:18:func ExecutablePaths() []string {
internal/flavors/claudecowork/flavor.go:25:func NextSteps(target string) string {
Expand Down Expand Up @@ -186,14 +195,11 @@ internal/gitignore/gitignore_test.go:128:func TestEnsureHiddenCreatesAndAppends(
internal/gitignore/gitignore_test.go:174:func TestEnsureHiddenIsIdempotent(t *testing.T) {
internal/gitignore/gitignore_test.go:196:func TestEnsureHiddenReplacesStaleBlockInPlace(t *testing.T) {
internal/gitignore/gitignore_test.go:234:func TestEnsureHiddenWritesNoGitignore(t *testing.T) {
internal/scaffold/color_test.go:9:func TestColorDisabledForNonTTYOutputs(t *testing.T) {
internal/scaffold/color_test.go:26:func TestColorDisabledByEnvironment(t *testing.T) {
internal/scaffold/color_test.go:50:func TestColorEnabledForTerminalFile(t *testing.T) {

## Stats

```
Total tracked files: 350
Total tracked files: 355
```

<!-- HAND-WRITTEN BELOW — EDIT FREELY -->
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,13 @@ agent-init add-tracker <tracker> <target-dir>
agent-init list-flavors
agent-init list-trackers
agent-init version
agent-init upgrade [--check] [--dry-run] [--force]
```

Defaults: flavor `fullstack`, target `.`.

`agent-init upgrade` replaces the binary in place with the latest GitHub release, verifying its checksum first. There is no automatic update check — run `agent-init upgrade --check` to see if a newer version exists. See [`docs/cli.md`](./docs/cli.md#upgrade).

Run `agent-init --help` for the subcommand list, and `agent-init <command> --help` for a command's flags and examples. See [`docs/cli.md`](./docs/cli.md) for the full reference.

Examples. Each block states what the command writes and what it leaves alone.
Expand Down
31 changes: 30 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# CLI

`agent-init` is a small CLI with five subcommands. Source: [internal/cli/cli.go](../internal/cli/cli.go).
`agent-init` is a small CLI with six subcommands. Source: [internal/cli/cli.go](../internal/cli/cli.go).

```
agent-init init [flavor] [target-dir]
agent-init add-tracker <tracker> <target-dir>
agent-init list-flavors
agent-init list-trackers
agent-init version
agent-init upgrade [--check] [--dry-run] [--force]
```

If no subcommand is given, the binary defaults to `init` with the default flavor. So `agent-init` and `agent-init init` are equivalent.
Expand Down Expand Up @@ -131,6 +132,34 @@ In dev builds (`go run ./cmd/agent-init version`), prints
`version=dev commit=dev buildDate=unknown` — the ldflags only apply to release
builds.

## `upgrade`

Updates `agent-init` in place to the latest GitHub release. There is **no
automatic background check**: a release is only contacted when you run this
command, so normal invocations make no network calls.

```bash
agent-init upgrade # install the latest release, replacing this binary
agent-init upgrade --check # only report whether a newer version exists
agent-init upgrade --dry-run # download and verify, but do not replace the binary
```

### Flags

| Flag | Effect |
|------|--------|
| `--check` | Report whether a newer release exists and exit, without downloading or installing anything. |
| `--dry-run` | Download the latest archive and verify its checksum, but stop before replacing the binary. |
| `--force` | Install the latest release even when the current version is already newest. Also required to upgrade a dev build, which has no release version to compare against. |

### Behavior

- Makes a network call to GitHub's releases API and downloads the OS/arch-specific asset plus `checksums.txt`. Honors `GITHUB_TOKEN` / `GH_TOKEN` to lift the anonymous rate limit.
- Verifies the archive's SHA-256 against the published checksum and swaps the binary in place. **Fails closed on checksum mismatch** — the existing binary is left untouched.
- Requires write permission on the binary's install directory. A root-owned install path (e.g. `/usr/local/bin`) cannot be upgraded without elevated access; `upgrade` reports the error rather than escalating itself.
- A dev build (`version=dev`) cannot be compared to a release; `upgrade` refuses unless `--force` is passed.
- Source: [internal/selfupdate/selfupdate.go](../internal/selfupdate/selfupdate.go) (verify + replace), [internal/selfupdate/github.go](../internal/selfupdate/github.go) (releases client), [cli.go:runUpgrade](../internal/cli/cli.go). The release asset names this matches are cut by [.github/workflows/release.yml](../.github/workflows/release.yml).

## Help

The binary documents its own usage. Help text is generated from a single data
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ module github.com/Lillevang/agent-init
go 1.26

toolchain go1.26.3

require golang.org/x/mod v0.37.0
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
72 changes: 72 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/Lillevang/agent-init/internal/gitconfig"
"github.com/Lillevang/agent-init/internal/gitignore"
"github.com/Lillevang/agent-init/internal/scaffold"
"github.com/Lillevang/agent-init/internal/selfupdate"
"github.com/Lillevang/agent-init/internal/trackers"
)

Expand Down Expand Up @@ -138,8 +139,25 @@ var commands = []commandHelp{
summary: "print version info (version + commit + build date)",
usage: "agent-init version",
},
{
name: "upgrade",
summary: "download and install the latest release, replacing this binary",
usage: "agent-init upgrade [--check] [--dry-run] [--force]",
flags: []flagHelp{
{"--check", "report whether a newer release exists without downloading or installing"},
{"--dry-run", "download and verify the latest release but do not replace the binary"},
{"--force", "install the latest release even if already up to date (also required to upgrade a dev build)"},
},
examples: []string{
"agent-init upgrade # install the latest release",
"agent-init upgrade --check # only report whether a newer version exists",
},
},
}

// upgradeRepo is the GitHub repository self-upgrade pulls releases from.
const upgradeRepo = selfupdate.DefaultRepo

func lookupCommand(name string) (commandHelp, bool) {
for _, c := range commands {
if c.name == name {
Expand Down Expand Up @@ -188,6 +206,8 @@ func (a App) Run(ctx context.Context, args []string) error {
return a.runAddTracker(ctx, args[1:])
case "version":
return a.runVersion(args[1:])
case "upgrade":
return a.runUpgrade(ctx, args[1:])
case "help":
// `help <subcommand>` prints that subcommand's help; bare help is
// the top-level overview. (`-h` / `--help` are caught earlier.)
Expand Down Expand Up @@ -619,6 +639,58 @@ func (a App) runVersion(args []string) error {
return nil
}

// runUpgrade checks GitHub for a newer release and, unless --check is set,
// downloads it, verifies its SHA-256 against the release checksums, and replaces
// the running binary. There is no automatic background check: the user opts in
// by running this command. A dev build (version=dev) can't be compared to a
// release, so upgrading one requires --force.
func (a App) runUpgrade(ctx context.Context, args []string) error {
if wantsHelp(args) {
cmd, _ := lookupCommand("upgrade")
a.printCommandHelp(cmd)
return nil
}
flags := a.newFlagSet("upgrade")
check := flags.Bool("check", false, "report whether a newer release exists without installing")
dryRun := flags.Bool("dry-run", false, "download and verify but do not replace the binary")
force := flags.Bool("force", false, "install even if up to date; required to upgrade a dev build")
if err := flags.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("usage: agent-init upgrade [--check] [--dry-run] [--force]\nRun 'agent-init upgrade --help' for usage")
}

updater := selfupdate.NewUpdater(selfupdate.NewGitHubSource(upgradeRepo), a.out)
current := a.version.Version

if *check {
res, err := updater.Check(ctx, current)
if err != nil {
return err
}
if res.NewerAvailable {
_, _ = fmt.Fprintf(a.out, "A newer version is available: %s (current %s).\nRun 'agent-init upgrade' to install it.\n", res.Latest, current)
} else {
_, _ = fmt.Fprintf(a.out, "agent-init is up to date (%s).\n", current)
}
return nil
}

if current == "dev" && !*force {
return fmt.Errorf("refusing to upgrade a dev build (version=dev): a dev build has no release version to compare against; pass --force to install the latest release anyway")
}

return updater.Upgrade(ctx, selfupdate.UpgradeOptions{
Current: current,
Force: *force,
DryRun: *dryRun,
})
}

// printHelp renders the top-level overview from the commands table so the
// subcommand list never drifts from what the binary actually dispatches.
func (a App) printHelp() {
Expand Down
38 changes: 38 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,44 @@ func TestHelpFlagsMatchDocs(t *testing.T) {
}
}

func TestUpgradeHelp(t *testing.T) {
t.Parallel()
var out bytes.Buffer
app := cli.New(&out, &bytes.Buffer{}, cli.Version{})
if err := app.Run(context.Background(), []string{"upgrade", "--help"}); err != nil {
t.Fatalf("Run(upgrade --help) error = %v", err)
}
for _, want := range []string{"upgrade", "--check", "--dry-run", "--force", "Examples"} {
if !strings.Contains(out.String(), want) {
t.Errorf("upgrade --help missing %q:\n%s", want, out.String())
}
}
}

// A dev build has no release version to compare against, so `upgrade` without
// --force must refuse before making any network call. This keeps the test
// hermetic.
func TestUpgradeDevBuildRefusedWithoutForce(t *testing.T) {
t.Parallel()
app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{Version: "dev"})
err := app.Run(context.Background(), []string{"upgrade"})
if err == nil {
t.Fatal("Run(upgrade) on dev build = nil, want error")
}
if !strings.Contains(err.Error(), "--force") {
t.Errorf("dev-build refusal should mention --force; got: %v", err)
}
}

func TestUpgradeRejectsPositionalArgs(t *testing.T) {
t.Parallel()
app := cli.New(&bytes.Buffer{}, &bytes.Buffer{}, cli.Version{Version: "v1.0.0"})
err := app.Run(context.Background(), []string{"upgrade", "extra-arg"})
if err == nil {
t.Fatal("Run(upgrade extra-arg) = nil, want usage error")
}
}

func TestVersion(t *testing.T) {
t.Parallel()
var out bytes.Buffer
Expand Down
Loading
Loading