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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,20 @@ jobs:
uses: securego/gosec@master
with:
args: ./...

release-check:
name: Release config
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: GoReleaser check
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: check
31 changes: 31 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Release

on:
push:
tags:
- "v*"

permissions:
contents: write

jobs:
goreleaser:
name: GoReleaser
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/dist/
28 changes: 28 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2

builds:
- id: ship
main: ./cmd/ship
binary: ship
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w -X github.com/maxBRT/ship-cli/internal/version.Version={{.Version}}

# Archives and checksums use GoReleaser defaults (name includes version/os/arch;
# checksums file is {{ .ProjectName }}_{{ .Version }}_checksums.txt).

changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
33 changes: 22 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,21 @@ CLI that runs a sequential Ticket Run in your existing git checkout: confirm a s

**Status:** v0.0.1 (experimental).

## User guide

Open **[guide.html](guide.html)** in a browser for install, update, how a Run works, examples, flags, and troubleshooting.
## Install

```bash
xdg-open guide.html # or open guide.html from a file manager
curl -fsSL https://raw.githubusercontent.com/maxBRT/ship-cli/main/install.sh | bash
```

## Quick install (today)
The script detects linux/darwin × amd64/arm64, downloads the latest GitHub Release, verifies checksums, and installs `ship` to `~/.local/bin` (or a writable system bin). Windows release assets are on the same [Releases](https://github.com/maxBRT/ship-cli/releases) page for manual download.

## Update

```bash
git clone git@github.com:maxBRT/ship-cli.git
cd ship-cli
go build -o ~/.local/bin/ship ./cmd/ship
ship --help
ship update
```

Or: `go install github.com/maxBRT/ship-cli/cmd/ship@latest`
Downloads the latest GitHub Release, verifies checksums, and replaces the running binary in place. Check what you have with `ship --version`.

## Prerequisites

Expand All @@ -37,4 +34,18 @@ gh issue edit <n> --add-label "ship"
ship
```

See [guide.html](guide.html) for flags, env vars, and more examples. Domain language: [CONTEXT.md](CONTEXT.md).
Domain language: [CONTEXT.md](CONTEXT.md). Run `ship --help` for flags and commands.

## Install from source (Go developers)

```bash
go install github.com/maxBRT/ship-cli/cmd/ship@latest
```

Or clone and build locally:

```bash
git clone git@github.com:maxBRT/ship-cli.git
cd ship-cli
go build -o ~/.local/bin/ship ./cmd/ship
```
46 changes: 46 additions & 0 deletions cmd/ship/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"github.com/maxBRT/ship-cli/internal/run"
"github.com/maxBRT/ship-cli/internal/throbber"
"github.com/maxBRT/ship-cli/internal/ticket"
"github.com/maxBRT/ship-cli/internal/update"
"github.com/maxBRT/ship-cli/internal/version"
)

func main() {
Expand All @@ -24,9 +26,22 @@ func main() {
// Main is the testable CLI entrypoint. It parses Run configuration and drives
// the Run orchestrator over the checkout in dir.
func Main(args []string, stdout, stderr io.Writer, dir string) int {
return MainWith(args, stdout, stderr, dir, nil)
}

// MainWith is Main with an optional Updater. Nil uses the real GitHub Releases
// adapter for ship update.
func MainWith(args []string, stdout, stderr io.Writer, dir string, up update.Port) int {
if len(args) > 0 && args[0] == "init" {
return runInit(stderr, dir)
}
if len(args) > 0 && args[0] == "update" {
return runUpdate(stdout, stderr, up)
}
if wantsVersion(args) {
fmt.Fprintln(stdout, version.Version)
return 0
}
if wantsHelp(args) {
run.WriteUsage(stdout)
return 0
Expand Down Expand Up @@ -82,6 +97,28 @@ func Main(args []string, stdout, stderr io.Writer, dir string) int {
return 0
}

func runUpdate(stdout, stderr io.Writer, up update.Port) int {
if up == nil {
var err error
up, err = update.Default()
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
}
res, err := up.Update(context.Background())
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if res.AlreadyCurrent {
fmt.Fprintf(stdout, "ship is up to date (%s)\n", res.OldVersion)
return 0
}
fmt.Fprintf(stdout, "updated ship %s → %s\n", res.OldVersion, res.NewVersion)
return 0
}

func runInit(stderr io.Writer, dir string) int {
created, err := (run.Init{Out: stderr}).Config(dir)
if err != nil {
Expand All @@ -104,3 +141,12 @@ func wantsHelp(args []string) bool {
}
return false
}

func wantsVersion(args []string) bool {
for _, a := range args {
if a == "-version" || a == "--version" {
return true
}
}
return false
}
106 changes: 106 additions & 0 deletions cmd/ship/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,114 @@

import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/maxBRT/ship-cli/internal/update"
)

type fakeUpdater struct {
result update.Result
err error
called bool
}

func (f *fakeUpdater) Update(context.Context) (update.Result, error) {
f.called = true
return f.result, f.err
}

func TestMain_updateRoutesToUpdaterNotTicketRun(t *testing.T) {
up := &fakeUpdater{result: update.Result{
AlreadyCurrent: true,
OldVersion: "v1.0.0",
NewVersion: "v1.0.0",
}}
var stdout, stderr bytes.Buffer
code := MainWith([]string{"update"}, &stdout, &stderr, t.TempDir(), up)
if code != 0 {
t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String())
}
if !up.called {
t.Fatal("Updater.Update was not called; ship update must not start a Ticket Run")
}
}

func TestMain_updateAlreadyCurrentPrintsSuccess(t *testing.T) {
up := &fakeUpdater{result: update.Result{
AlreadyCurrent: true,
OldVersion: "v1.2.3",
NewVersion: "v1.2.3",
}}
var stdout, stderr bytes.Buffer
code := MainWith([]string{"update"}, &stdout, &stderr, t.TempDir(), up)
if code != 0 {
t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String())
}
got := stdout.String()
if !strings.Contains(got, "up to date") {
t.Errorf("stdout missing up-to-date message; got %q", got)
}
if !strings.Contains(got, "v1.2.3") {
t.Errorf("stdout missing version; got %q", got)
}
if stderr.Len() != 0 {
t.Errorf("want empty stderr, got %q", stderr.String())
}
}

func TestMain_updateNewerReleasePrintsOldAndNewVersions(t *testing.T) {
up := &fakeUpdater{result: update.Result{
OldVersion: "v1.0.0",
NewVersion: "v1.2.3",
}}
var stdout, stderr bytes.Buffer
code := MainWith([]string{"update"}, &stdout, &stderr, t.TempDir(), up)
if code != 0 {
t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String())
}
got := stdout.String()
if !strings.Contains(got, "v1.0.0") || !strings.Contains(got, "v1.2.3") {
t.Errorf("stdout should print old and new versions; got %q", got)
}
if stderr.Len() != 0 {
t.Errorf("want empty stderr, got %q", stderr.String())
}
}

func TestMain_updateFailureLeavesNonZeroAndStderr(t *testing.T) {
up := &fakeUpdater{err: errString("checksum mismatch")}
var stdout, stderr bytes.Buffer
code := MainWith([]string{"update"}, &stdout, &stderr, t.TempDir(), up)
if code == 0 {
t.Fatal("exit = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "checksum mismatch") {
t.Errorf("stderr should carry updater error; got %q", stderr.String())
}
}

type errString string

func (e errString) Error() string { return string(e) }

func TestMain_versionPrintsEmbeddedVersion(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Main([]string{"--version"}, &stdout, &stderr, t.TempDir())
if code != 0 {
t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("want empty stderr, got %q", stderr.String())
}
// Non-release builds report the known default "dev".
got := strings.TrimSpace(stdout.String())
if got != "dev" {
t.Errorf("version = %q, want %q", got, "dev")
func TestMain_helpDescribesAgentAsKind(t *testing.T) {

Check failure on line 112 in cmd/ship/main_test.go

View workflow job for this annotation

GitHub Actions / Test

expected '(', found TestMain_helpDescribesAgentAsKind

Check failure on line 112 in cmd/ship/main_test.go

View workflow job for this annotation

GitHub Actions / Lint

expected '(', found TestMain_helpDescribesAgentAsKind (compile)

Check failure on line 112 in cmd/ship/main_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestMain_helpDescribesAgentAsKind, expected (
var stdout, stderr bytes.Buffer
code := Main([]string{"--help"}, &stdout, &stderr, t.TempDir())
if code != 0 {
Expand All @@ -23,7 +124,7 @@
}
}

func TestMain_helpDocumentsDomainLanguage(t *testing.T) {

Check failure on line 127 in cmd/ship/main_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestMain_helpDocumentsDomainLanguage, expected (
var stdout, stderr bytes.Buffer
code := Main([]string{"--help"}, &stdout, &stderr, t.TempDir())
if code != 0 {
Expand All @@ -35,9 +136,14 @@
t.Errorf("help missing domain term %q; got:\n%s", term, out)
}
}
for _, term := range []string{"update", "version"} {
if !strings.Contains(out, term) {
t.Errorf("help missing %q; got:\n%s", term, out)
}
}
}

func TestMain_invalidFlagExitsNonZero(t *testing.T) {

Check failure on line 146 in cmd/ship/main_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestMain_invalidFlagExitsNonZero, expected (
dir := t.TempDir()
writeShipYAML(t, dir, `branch: ""
feature: ""
Expand All @@ -56,7 +162,7 @@
}
}

func TestMain_invalidTimeoutExitsNonZero(t *testing.T) {

Check failure on line 165 in cmd/ship/main_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestMain_invalidTimeoutExitsNonZero, expected (
dir := t.TempDir()
writeShipYAML(t, dir, `branch: ""
feature: ""
Expand All @@ -75,7 +181,7 @@
}
}

func TestMain_missingAgentBinaryExitsBeforeRun(t *testing.T) {

Check failure on line 184 in cmd/ship/main_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestMain_missingAgentBinaryExitsBeforeRun, expected (
dir := t.TempDir()
writeShipYAML(t, dir, `branch: ""
feature: ""
Expand All @@ -96,7 +202,7 @@
}
}

func writeShipYAML(t *testing.T, dir, content string) {

Check failure on line 205 in cmd/ship/main_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name writeShipYAML, expected ( (compile)
t.Helper()
path := filepath.Join(dir, ".ship", "config.yaml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
Expand Down
Loading
Loading