From 954bc7fa8d96a00b92de1c6c5a471c2424ab23b5 Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Sat, 25 Jul 2026 12:06:59 -0400 Subject: [PATCH 1/4] Add n2k uninstall command --- .github/workflows/test.yaml | 20 +++++ README.md | 13 ++++ cmd/n2k/cli.go | 27 +++++-- cmd/n2k/main_test.go | 2 + cmd/n2k/uninstall.go | 110 +++++++++++++++++++++++++++ cmd/n2k/uninstall_test.go | 140 +++++++++++++++++++++++++++++++++++ cmd/n2k/uninstall_unix.go | 9 +++ cmd/n2k/uninstall_windows.go | 72 ++++++++++++++++++ 8 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 cmd/n2k/uninstall.go create mode 100644 cmd/n2k/uninstall_test.go create mode 100644 cmd/n2k/uninstall_unix.go create mode 100644 cmd/n2k/uninstall_windows.go diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 778f29a..cc7b50e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -37,6 +37,26 @@ jobs: fi "$install_dir/$binary" version + - name: Test uninstall + shell: bash + run: | + uninstall_dir="$RUNNER_TEMP/n2k-uninstall" + mkdir -p "$uninstall_dir" + binary=n2k + if [ "$RUNNER_OS" = Windows ]; then + binary=n2k.exe + fi + go build -trimpath -o "$uninstall_dir/$binary" ./cmd/n2k + HOME="$RUNNER_TEMP/n2k-uninstall-home" "$uninstall_dir/$binary" uninstall + for attempt in $(seq 1 100); do + if [ ! -e "$uninstall_dir/$binary" ]; then + exit 0 + fi + sleep 0.1 + done + echo "n2k uninstall did not remove $uninstall_dir/$binary" >&2 + exit 1 + race: runs-on: ubuntu-latest diff --git a/README.md b/README.md index bae2098..ce02284 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ n2k pgn list | jq 'select(.complete == true)' | `n2k devices` | Actively discover a writable bus or passively inventory devices observed in a capture/UDP stream. | | `n2k pgn` | Query or list typed PGN metadata, fields, ranges, and confidence. | | `n2k update` | Check for a release and update through Homebrew, Go, or a verified release binary. | +| `n2k uninstall` | Remove n2k and its update-check cache from the machine. | Run `n2k help ` for organized flags, defaults, allowed values, and examples. The purpose-built parser enforces canonical `--long-flags`, supports @@ -252,6 +253,18 @@ without the confirmation prompt, or `N2K_NO_UPDATE_CHECK=1` to disable the automatic check. Scriptable commands never perform implicit network requests or updates. +### Uninstall + +Remove n2k and its update-check cache: + +```bash +n2k uninstall +``` + +Homebrew installations are removed through Homebrew. Go and downloaded-binary +installations remove the currently running executable directly. On Windows, +final cleanup finishes immediately after the command exits. + ### Shell completion Enable completion for the current shell with one of: diff --git a/cmd/n2k/cli.go b/cmd/n2k/cli.go index 07756ee..ff8f990 100644 --- a/cmd/n2k/cli.go +++ b/cmd/n2k/cli.go @@ -74,18 +74,20 @@ func (parsed parsedCommand) durationValue(name string) (time.Duration, error) { } type cli struct { - in io.Reader - out io.Writer - errOut io.Writer - updater updaterService + in io.Reader + out io.Writer + errOut io.Writer + updater updaterService + uninstaller uninstallerService } func newCLI(in io.Reader, out, errOut io.Writer) *cli { return &cli{ - in: in, - out: out, - errOut: errOut, - updater: newUpdaterService(), + in: in, + out: out, + errOut: errOut, + updater: newUpdaterService(), + uninstaller: newUninstallerService(), } } @@ -226,6 +228,8 @@ func (app *cli) runParsed(ctx context.Context, parsed parsedCommand) error { return writeVersion(app.out) case "update": return app.runUpdate(ctx, parsed) + case "uninstall": + return app.runUninstall(ctx) default: return fmt.Errorf("command %q is not executable", parsed.spec.name) } @@ -539,6 +543,12 @@ func commandSpecs() []commandSpec { }, }, }, + { + name: "uninstall", + summary: "Remove n2k from this machine", + usage: "uninstall", + maxArgs: 0, + }, } } @@ -600,6 +610,7 @@ func writeRootHelp(out io.Writer) error { _, _ = fmt.Fprintln(writer, " • Use n2k help for flags, defaults, choices, and examples.") _, _ = fmt.Fprintln(writer, " • Enable shell completion with n2k completion .") _, _ = fmt.Fprintln(writer, " • Check and install releases with n2k update.") + _, _ = fmt.Fprintln(writer, " • Remove n2k and its update cache with n2k uninstall.") _, _ = fmt.Fprintln(writer) _, _ = fmt.Fprintln(writer, "Examples:") _, _ = fmt.Fprintln(writer, " n2k sniff --tcp 192.168.4.1:1457") diff --git a/cmd/n2k/main_test.go b/cmd/n2k/main_test.go index b107537..7655877 100644 --- a/cmd/n2k/main_test.go +++ b/cmd/n2k/main_test.go @@ -47,6 +47,7 @@ func TestRootHelpIsDiscoverableWithoutATerminal(t *testing.T) { require.Contains(t, stdout, "interactive command center") require.Contains(t, stdout, "completion") require.Contains(t, stdout, "devices") + require.Contains(t, stdout, "uninstall") require.Contains(t, stdout, "validate") } @@ -172,6 +173,7 @@ func TestDynamicCompletionUnderstandsCommandsFlagsValuesAndPGNs(t *testing.T) { {name: "pgn", args: []string{"__complete", "pgn", "12725"}, want: "127250\tVessel Heading"}, {name: "devices action", args: []string{"__complete", "devices", "li"}, want: "list\tInventory observed devices"}, {name: "update method", args: []string{"__complete", "update", "--method", "h"}, want: "homebrew\tupgrade the Homebrew cask"}, + {name: "uninstall", args: []string{"__complete", "unin"}, want: "uninstall\tRemove n2k"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/cmd/n2k/uninstall.go b/cmd/n2k/uninstall.go new file mode 100644 index 0000000..796522a --- /dev/null +++ b/cmd/n2k/uninstall.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +type uninstallStatus struct { + Path string + CleanupAfterExit bool + Warnings []error +} + +type uninstallerService interface { + Uninstall(context.Context, io.Reader, io.Writer, io.Writer) (uninstallStatus, error) +} + +type machineUninstaller struct { + executablePath func() (string, error) + runCommand updateCommandRunner + removeExecutable func(string) (bool, error) + cacheDirectory func() (string, error) + removeAll func(string) error + removeFile func(string) error +} + +func newUninstallerService() uninstallerService { + return &machineUninstaller{ + executablePath: resolvedExecutablePath, + runCommand: runUpdateCommand, + removeExecutable: removeRunningExecutable, + cacheDirectory: n2kCacheDirectory, + removeAll: os.RemoveAll, + removeFile: os.Remove, + } +} + +func (service *machineUninstaller) Uninstall( + ctx context.Context, + stdin io.Reader, + stdout, stderr io.Writer, +) (uninstallStatus, error) { + path, err := service.executablePath() + if err != nil { + return uninstallStatus{}, fmt.Errorf("locating n2k executable: %w", err) + } + status := uninstallStatus{Path: path} + + if isHomebrewPath(path) { + if err := service.runCommand( + ctx, + "brew", + []string{"uninstall", "--cask", homebrewCask}, + nil, + stdin, + stdout, + stderr, + ); err != nil { + return uninstallStatus{}, fmt.Errorf("uninstalling with Homebrew: %w", err) + } + } else { + cleanupAfterExit, err := service.removeExecutable(path) + if err != nil { + return uninstallStatus{}, fmt.Errorf("removing %s: %w", path, err) + } + status.CleanupAfterExit = cleanupAfterExit + if err := service.removeFile(path + ".old"); err != nil && !os.IsNotExist(err) { + status.Warnings = append(status.Warnings, fmt.Errorf("removing old executable backup: %w", err)) + } + } + + cacheDirectory, err := service.cacheDirectory() + if err != nil { + status.Warnings = append(status.Warnings, fmt.Errorf("locating n2k cache: %w", err)) + } else if err := service.removeAll(cacheDirectory); err != nil { + status.Warnings = append(status.Warnings, fmt.Errorf("removing n2k cache: %w", err)) + } + return status, nil +} + +func n2kCacheDirectory() (string, error) { + cache, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(cache, "n2k"), nil +} + +func (app *cli) runUninstall(ctx context.Context) error { + status, err := app.uninstaller.Uninstall(ctx, app.in, app.out, app.errOut) + if err != nil { + return err + } + for _, warning := range status.Warnings { + _, _ = fmt.Fprintf(app.errOut, "n2k uninstall: warning: %v\n", warning) + } + if status.CleanupAfterExit { + _, err = fmt.Fprintf( + app.out, + "Uninstalled n2k from %s. Final cleanup will finish after this process exits.\n", + status.Path, + ) + return err + } + _, err = fmt.Fprintf(app.out, "Uninstalled n2k from %s.\n", status.Path) + return err +} diff --git a/cmd/n2k/uninstall_test.go b/cmd/n2k/uninstall_test.go new file mode 100644 index 0000000..5cb45cb --- /dev/null +++ b/cmd/n2k/uninstall_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMachineUninstallerRemovesDirectInstallAndCache(t *testing.T) { + root := t.TempDir() + executable := filepath.Join(root, executableName(runtime.GOOS)) + cacheDirectory := filepath.Join(root, "cache", "n2k") + require.NoError(t, os.MkdirAll(cacheDirectory, 0o700)) + require.NoError(t, os.WriteFile(executable, []byte("n2k"), 0o755)) + require.NoError(t, os.WriteFile(executable+".old", []byte("old n2k"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(cacheDirectory, "update-check.json"), + []byte("{}"), + 0o600, + )) + + service := &machineUninstaller{ + executablePath: func() (string, error) { return executable, nil }, + runCommand: func( + context.Context, + string, + []string, + []string, + io.Reader, + io.Writer, + io.Writer, + ) error { + return errors.New("Homebrew must not run for a direct install") + }, + removeExecutable: func(path string) (bool, error) { + return false, os.Remove(path) + }, + cacheDirectory: func() (string, error) { return cacheDirectory, nil }, + removeAll: os.RemoveAll, + removeFile: os.Remove, + } + + status, err := service.Uninstall( + context.Background(), + strings.NewReader(""), + io.Discard, + io.Discard, + ) + require.NoError(t, err) + require.Equal(t, executable, status.Path) + require.False(t, status.CleanupAfterExit) + require.NoFileExists(t, executable) + require.NoFileExists(t, executable+".old") + require.NoDirExists(t, cacheDirectory) +} + +func TestMachineUninstallerDelegatesHomebrewInstall(t *testing.T) { + cacheDirectory := filepath.Join(t.TempDir(), "cache", "n2k") + require.NoError(t, os.MkdirAll(cacheDirectory, 0o700)) + + var commandName string + var commandArgs []string + service := &machineUninstaller{ + executablePath: func() (string, error) { + return "/opt/homebrew/Caskroom/n2k/1.2.3/n2k", nil + }, + runCommand: func( + _ context.Context, + name string, + args []string, + environment []string, + _ io.Reader, + _, _ io.Writer, + ) error { + commandName = name + commandArgs = args + require.Empty(t, environment) + return nil + }, + removeExecutable: func(string) (bool, error) { + return false, errors.New("Homebrew must own executable removal") + }, + cacheDirectory: func() (string, error) { return cacheDirectory, nil }, + removeAll: os.RemoveAll, + removeFile: os.Remove, + } + + status, err := service.Uninstall(context.Background(), nil, io.Discard, io.Discard) + require.NoError(t, err) + require.Equal(t, "/opt/homebrew/Caskroom/n2k/1.2.3/n2k", status.Path) + require.Equal(t, "brew", commandName) + require.Equal(t, []string{"uninstall", "--cask", homebrewCask}, commandArgs) + require.NoDirExists(t, cacheDirectory) +} + +func TestUninstallCommandReportsDeferredCleanupAndWarnings(t *testing.T) { + var stdout strings.Builder + var stderr strings.Builder + app := newCLI(strings.NewReader(""), &stdout, &stderr) + app.uninstaller = &fakeUninstaller{ + status: uninstallStatus{ + Path: `C:\Tools\n2k.exe`, + CleanupAfterExit: true, + Warnings: []error{errors.New("cache is locked")}, + }, + } + + require.NoError(t, app.ExecuteContext(context.Background(), []string{"uninstall"})) + require.Contains(t, stdout.String(), `Uninstalled n2k from C:\Tools\n2k.exe`) + require.Contains(t, stdout.String(), "cleanup will finish after this process exits") + require.Contains(t, stderr.String(), "warning: cache is locked") +} + +func TestUninstallHelpDoesNotRunUninstaller(t *testing.T) { + stdout, _, err := executeCommand(context.Background(), "uninstall", "--help") + require.NoError(t, err) + require.Contains(t, stdout, "Usage:\n n2k uninstall") + require.Contains(t, stdout, "Remove n2k from this machine") +} + +type fakeUninstaller struct { + status uninstallStatus + err error +} + +func (service *fakeUninstaller) Uninstall( + context.Context, + io.Reader, + io.Writer, + io.Writer, +) (uninstallStatus, error) { + return service.status, service.err +} diff --git a/cmd/n2k/uninstall_unix.go b/cmd/n2k/uninstall_unix.go new file mode 100644 index 0000000..cb482c4 --- /dev/null +++ b/cmd/n2k/uninstall_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package main + +import "os" + +func removeRunningExecutable(path string) (bool, error) { + return false, os.Remove(path) +} diff --git a/cmd/n2k/uninstall_windows.go b/cmd/n2k/uninstall_windows.go new file mode 100644 index 0000000..ea5e631 --- /dev/null +++ b/cmd/n2k/uninstall_windows.go @@ -0,0 +1,72 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "os/exec" + "time" +) + +const windowsUninstallScript = `@echo off +for /L %%i in (1,1,120) do ( + del /F /Q "%~1" >NUL 2>&1 + if not exist "%~1" goto done + ping -n 2 127.0.0.1 >NUL +) +exit /B 1 +:done +del /F /Q "%~f0" >NUL 2>&1 +` + +func removeRunningExecutable(path string) (bool, error) { + if err := os.Remove(path); err == nil { + return false, nil + } + + pendingPath := fmt.Sprintf("%s.uninstall-%d-%d", path, os.Getpid(), time.Now().UnixNano()) + renamed := os.Rename(path, pendingPath) == nil + if !renamed { + pendingPath = path + } + + script, err := os.CreateTemp("", "n2k-uninstall-*.cmd") + if err != nil { + if renamed { + _ = os.Rename(pendingPath, path) + } + return false, fmt.Errorf("creating cleanup helper: %w", err) + } + scriptPath := script.Name() + if _, err := script.WriteString(windowsUninstallScript); err != nil { + _ = script.Close() + _ = os.Remove(scriptPath) + if renamed { + _ = os.Rename(pendingPath, path) + } + return false, fmt.Errorf("writing cleanup helper: %w", err) + } + if err := script.Close(); err != nil { + _ = os.Remove(scriptPath) + if renamed { + _ = os.Rename(pendingPath, path) + } + return false, fmt.Errorf("closing cleanup helper: %w", err) + } + + command := exec.Command("cmd.exe", "/D", "/Q", "/C", scriptPath, pendingPath) // #nosec G204 -- cmd.exe and the generated helper are fixed; only the current executable path is passed as data. + if err := command.Start(); err != nil { + _ = os.Remove(scriptPath) + if renamed { + if rollbackErr := os.Rename(pendingPath, path); rollbackErr != nil { + return false, fmt.Errorf("starting cleanup helper: %w (rollback also failed: %v)", err, rollbackErr) + } + } + return false, fmt.Errorf("starting cleanup helper: %w", err) + } + if command.Process != nil { + _ = command.Process.Release() + } + return true, nil +} From 56910a2a137e693ddfc8c9e08102be9acf949044 Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Sat, 25 Jul 2026 18:56:29 -0400 Subject: [PATCH 2/4] Bump minor version to 1.1.0 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 3eefcb9..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 From bed84d069face490b74025502f26a04518253e40 Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Sat, 25 Jul 2026 19:20:51 -0400 Subject: [PATCH 3/4] Upgrade to Go 1.26.5 --- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 12 ++++++------ README.md | 2 +- go.mod | 2 +- justfile | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d4b80bd..1fa8d80 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -70,7 +70,7 @@ jobs: if: ${{ steps.version.outputs.skip != 'true' }} uses: actions/setup-go@v7 with: - go-version: 1.25.x + go-version: 1.26.x check-latest: true - name: Run goreleaser diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cc7b50e..2853383 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -20,7 +20,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: 1.25.x + go-version: 1.26.x check-latest: true - name: Test @@ -67,7 +67,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: 1.25.x + go-version: 1.26.x check-latest: true - name: Race detector @@ -83,7 +83,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: 1.25.x + go-version: 1.26.x check-latest: true - name: golangci-lint @@ -101,19 +101,19 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: 1.25.x + go-version: 1.26.x check-latest: true - name: Run govulncheck env: - GOTOOLCHAIN: go1.25.12 + GOTOOLCHAIN: go1.26.5 run: | go install golang.org/x/vuln/cmd/govulncheck@v1.5.0 govulncheck ./... - name: Run gosec env: - GOTOOLCHAIN: go1.25.12 + GOTOOLCHAIN: go1.26.5 run: | go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1 gosec -exclude-dir=.claude -exclude=G115 ./... diff --git a/README.md b/README.md index ce02284..90800cc 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ brew upgrade --cask open-ships/tap/n2k ### Go -With Go 1.25.8 or newer: +With Go 1.26.5 or newer: ```bash go install github.com/open-ships/n2k-cli/cmd/n2k@latest diff --git a/go.mod b/go.mod index d035e95..fbb847b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/open-ships/n2k-cli -go 1.25.8 +go 1.26.5 require ( charm.land/bubbles/v2 v2.1.1 diff --git a/justfile b/justfile index 43df9fc..e9cd7e2 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,5 @@ golangci_lint_version := "v2.12.0" -secure_go_toolchain := "go1.25.12" +secure_go_toolchain := "go1.26.5" govulncheck_version := "v1.5.0" gosec_version := "v2.27.1" From e429100ee17e1f58a698b374fd5e35669dac9db9 Mon Sep 17 00:00:00 2001 From: Jake Thomas Date: Sat, 25 Jul 2026 19:23:26 -0400 Subject: [PATCH 4/4] Refresh project README --- README.md | 124 ++++-------------------------------------------------- 1 file changed, 9 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 90800cc..96427fa 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,20 @@ -# n2k-cli +# [NMEA 2000](https://www.nmea.org/nmea-2000.html) for Humans and Agents [![CI](https://github.com/open-ships/n2k-cli/actions/workflows/test.yaml/badge.svg)](https://github.com/open-ships/n2k-cli/actions/workflows/test.yaml) [![Release](https://img.shields.io/github/v/release/open-ships/n2k-cli)](https://github.com/open-ships/n2k-cli/releases) -**Turn raw NMEA 2000 traffic into typed, searchable, scriptable data.** -Marine-network problems often begin as opaque CAN frames: which device sent -this message, what does the payload mean, and can the failure be reproduced? -`n2k` makes that traffic useful without hiding the wire. It combines a guided -Bubble Tea command center with deterministic CLI commands for decoding, -recording, replaying, validating, filtering, and device discovery. +Decode, record, replay, validate, filter, and discover devices - all from a nicely packaged TUI. -The CLI is powered by the typed -[`open-ships/n2k`](https://github.com/open-ships/n2k) Go library. +Powered by the [`open-ships/n2k`](https://github.com/open-ships/n2k) Go library. -## Install +## Installation -Every installation provides the same `n2k` command. - -### One-line installer - -Copy and run: ```bash curl -fsSL https://raw.githubusercontent.com/open-ships/n2k-cli/main/install.sh | sh ``` -The installer detects the platform, downloads the matching release, verifies -its SHA-256 checksum, and installs `n2k` into `~/.local/bin` without `sudo`. If -that directory is not already on `PATH`, it prints the exact command to add it. - -| Operating system | Architectures | Shell | -|------------------|---------------|-------| -| macOS | Intel (`amd64`), Apple Silicon (`arm64`) | `sh`, `bash`, or `zsh` | -| Linux | `amd64`, `arm64` | `sh`, `bash`, or `zsh` | -| Windows | `amd64`, `arm64` | Git Bash, MSYS2, or Cygwin | - -WSL is detected as Linux. To choose another destination or pin a release: - -```bash -curl -fsSL https://raw.githubusercontent.com/open-ships/n2k-cli/main/install.sh \ - | N2K_INSTALL_DIR="$HOME/bin" N2K_VERSION=v1.0.2 sh -``` ### Homebrew @@ -58,85 +31,25 @@ Homebrew upgrades are also available directly: brew upgrade --cask open-ships/tap/n2k ``` -### Go - -With Go 1.26.5 or newer: - -```bash -go install github.com/open-ships/n2k-cli/cmd/n2k@latest -n2k version -``` - -Go writes the binary to `GOBIN`, which defaults to `$(go env GOPATH)/bin`; -make sure that directory is on `PATH`: - -```bash -export PATH="$(go env GOPATH)/bin:$PATH" -``` - -If you set a custom `GOBIN`, `n2k update` preserves it and replaces that same -installation. - -### Release binaries - -Checksum-protected archives for macOS, Linux, and Windows are published on the -[`n2k-cli` releases page](https://github.com/open-ships/n2k-cli/releases). - -### Build from source - -For contributors or local development: - -```bash -git clone https://github.com/open-ships/n2k-cli -cd n2k-cli -just build # writes bin/n2k -./bin/n2k --help -just install # optional: installs n2k into Go's bin directory -``` - ## Why `n2k`? -### Guided when you are exploring - -Run `n2k` with no arguments. Search the workflow palette, choose a source, and -configure the operation with validated fields and autocomplete. Before -anything touches the network, `n2k` shows the equivalent copyable CLI command. - -![The n2k Bubble Tea command center showing searchable decode, record, replay, validate, device, schema, and update workflows](.github/tui.svg) -### Precise when you are automating +![The n2k command center showing searchable decode, record, replay, validate, device, schema, and update workflows](.github/tui.svg) -The same workflows have stable flags, meaningful exit codes, dynamic shell -completion, CEL filters, and JSON-lines output. Typed values remain paired with -their exact wire representation, so pipelines are convenient without losing -diagnostic fidelity. ![The n2k CLI decoding a sailing-vessel capture into typed JSON lines with PGNs and exact wire values](.github/demo.svg) ### One tool for the whole debugging loop -| Need | What `n2k` provides | +| Use Case | Why use `n2k cli` | |------|---------------------| | Inspect traffic now | Decode SocketCAN, USB-CAN, TCP, UDP, candump, and gzip captures. | -| Reproduce a problem | Record owned observations, then replay with original or disabled timing. | -| Reduce the firehose | Filter messages with CEL and select typed, text, JSON, or unknown-PGN output. | +| Reproduce a problem | Record owned observations, replay with original or disabled timing. | +| Reduce the firehose | Filter messages using [Common Expression Language](https://cel.dev/) and select typed, text, JSON, or unknown-PGN output. | | Find devices | Actively discover a writable network or passively inventory a saved capture. | | Measure decoder coverage | Validate a source, count undecodable PGNs, and fail CI in strict mode. | | Understand a PGN | Autocomplete every known PGN and inspect fields, units, ranges, and confidence. | -| Keep tooling current | Update through Homebrew, Go, or checksum-verified release binaries. | -## Quick Start — No Boat Required - -The repository includes a privacy-scrubbed, six-second sailing-vessel capture, -so you can try the decoder without CAN hardware: - -```bash -git clone --depth 1 https://github.com/open-ships/n2k-cli -cd n2k-cli -n2k sniff --file testdata/sample.log --output text -``` - -Frames containing vessel position, routes, or identity were removed. ## Rich terminal workflows @@ -159,14 +72,6 @@ The command center provides: - Terminal-aware colors, contextual key help, cancellation, and a full-screen alternate buffer that leaves the shell clean. -For screen readers or terminals that cannot redraw reliably, use accessible -prompts: - -```bash -n2k tui --accessible -# Or persist the preference: -export N2K_ACCESSIBLE=1 -``` ### Scriptable commands @@ -232,13 +137,6 @@ Check for and install the latest release: n2k update ``` -The updater preserves the installation method: - -- Homebrew installations run `brew upgrade --cask open-ships/tap/n2k`. -- Go installations run `go install` against the exact latest release tag. -- Downloaded release binaries are replaced atomically after their archive - matches the release's `checksums.txt`. - Useful controls: ```bash @@ -261,10 +159,6 @@ Remove n2k and its update-check cache: n2k uninstall ``` -Homebrew installations are removed through Homebrew. Go and downloaded-binary -installations remove the currently running executable directly. On Windows, -final cleanup finishes immediately after the command exits. - ### Shell completion Enable completion for the current shell with one of: @@ -300,7 +194,7 @@ decode errors. ## Development -Go 1.25.8 or newer and `just` are required. +Go 1.26.5 or newer and `just` are required. ```bash just setup # first checkout only