From 56814beadfdeb35c3924f0df452b031002fa84c3 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 26 May 2026 14:52:50 -0500 Subject: [PATCH] feat: add milestone 1 foundations Closes #1 --- docs/DESIGN.md | 4 +- flake.lock | 61 ++++++ flake.nix | 34 ++++ go.mod | 3 + internal/config/config.go | 151 +++++++++++++++ internal/config/config_test.go | 340 +++++++++++++++++++++++++++++++++ internal/format/time.go | 49 +++++ internal/format/time_test.go | 41 ++++ internal/state/state.go | 117 ++++++++++++ internal/state/state_test.go | 257 +++++++++++++++++++++++++ main.go | 52 +++++ main_test.go | 192 +++++++++++++++++++ testdata/hosts.json | 15 ++ 13 files changed, 1314 insertions(+), 2 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/format/time.go create mode 100644 internal/format/time_test.go create mode 100644 internal/state/state.go create mode 100644 internal/state/state_test.go create mode 100644 main.go create mode 100644 main_test.go create mode 100644 testdata/hosts.json diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 81c4d7a..e5ef4d4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -353,7 +353,7 @@ Each `internal/*` package has a focused responsibility and a small API. The UI p - `hosts` must be an array (may be empty — `limen` still works with just localhost). - Each entry must have non-empty `name` and `hostname`. - Duplicate `name` values cause a fatal startup error with a clear message: `limen: duplicate host name "" in hosts.json`. -- The reserved name `localhost` must not appear in the config; if it does, fatal error: `limen: "localhost" is reserved and cannot be declared in hosts.json`. +- The reserved name `localhost` must not appear in the config; if it does, fatal error: `limen: "localhost" is reserved`. **Behavior when config is missing:** Print a hint to stderr (`limen: no hosts.json found at ; only localhost will be available`) and run with an empty host list. Stage 1 then shows just localhost. @@ -654,7 +654,7 @@ limen/ meta = { description = "Terminal launcher TUI for tmux + ssh"; homepage = "https://github.com//limen"; - license = pkgs.lib.licenses.mit; + license = pkgs.lib.licenses.gpl3Only; mainProgram = "limen"; }; }; diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..cb35fe4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1779694939, + "narHash": "sha256-Ly4j75O8ICaSQx3uxPnwk2x7PMF0XQvn5r0c3yBA7FI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f9d8b65950353691ab56561e7c73d2e1063d810b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ca85f68 --- /dev/null +++ b/flake.nix @@ -0,0 +1,34 @@ +{ + description = "limen — terminal launcher TUI"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages.default = pkgs.buildGoModule { + pname = "limen"; + version = "0.1.0"; + src = ./.; + vendorHash = null; + + ldflags = [ + "-s" + "-w" + ]; + + meta = { + description = "Terminal launcher TUI for tmux + ssh"; + homepage = "https://github.com/KofTwentyTwo/limen"; + license = pkgs.lib.licenses.gpl3Only; + mainProgram = "limen"; + }; + }; + }); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3864451 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/KofTwentyTwo/limen + +go 1.22 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..6b32e28 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,151 @@ +// Package config loads and validates limen's user-managed hosts.json file. +package config + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const ( + configDirName = "limen" + configFileName = "hosts.json" +) + +// Config is the top-level hosts.json document. +type Config struct { + Hosts []Host `json:"hosts"` +} + +// Host describes a remote SSH target declared by the user. +type Host struct { + Name string `json:"name"` + Hostname string `json:"hostname"` + User string `json:"user,omitempty"` + Port int `json:"port,omitempty"` + Description string `json:"description,omitempty"` +} + +// DefaultPath returns the design-specified hosts.json path. +func DefaultPath() string { + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + return filepath.Join(xdgConfigHome, configDirName, configFileName) + } + + home := os.Getenv("HOME") + return filepath.Join(home, ".config", configDirName, configFileName) +} + +// Load reads hosts.json from path. A missing file is non-fatal and leaves only +// the implicit localhost available to later application layers. +func Load(path string, stderr io.Writer) (Config, error) { + if path == "" { + path = DefaultPath() + } + if stderr == nil { + stderr = io.Discard + } + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(stderr, "limen: no hosts.json found at %s; only localhost will be available\n", path) + return Config{Hosts: []Host{}}, nil + } + return Config{}, fmt.Errorf("limen: cannot read %s: %v", path, err) + } + + cfg, err := parse(data, path) + if err != nil { + return Config{}, err + } + if err := validate(cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +func parse(data []byte, path string) (Config, error) { + var document map[string]json.RawMessage + if err := json.Unmarshal(data, &document); err != nil { + var syntaxError *json.SyntaxError + if errors.As(err, &syntaxError) { + return Config{}, parseError(path, data, err) + } + return Config{}, errors.New("limen: hosts must be an array in hosts.json") + } + + rawHosts := document["hosts"] + if len(rawHosts) == 0 || !bytes.HasPrefix(bytes.TrimSpace(rawHosts), []byte("[")) { + return Config{}, errors.New("limen: hosts must be an array in hosts.json") + } + + var hosts []Host + if err := json.Unmarshal(rawHosts, &hosts); err != nil { + return Config{}, fmt.Errorf("limen: invalid hosts.json: %v", err) + } + return Config{Hosts: hosts}, nil +} + +func parseError(path string, data []byte, err error) error { + var syntaxError *json.SyntaxError + line, column := 1, 1 + if errors.As(err, &syntaxError) { + line, column = lineColumn(data, syntaxError.Offset) + } else { + line, column = lineColumn(data, int64(len(data))) + } + + return fmt.Errorf("limen: parse error at %s:%d:%d: %v", path, line, column, err) +} + +func lineColumn(data []byte, offset int64) (int, int) { + if offset < 1 { + return 1, 1 + } + if offset > int64(len(data)) { + offset = int64(len(data)) + } + + line, column := 1, 1 + for i, b := range data { + if int64(i) >= offset-1 { + break + } + if b == '\n' { + line++ + column = 1 + continue + } + column++ + } + return line, column +} + +func validate(cfg Config) error { + seenNames := make(map[string]struct{}, len(cfg.Hosts)) + + for i, host := range cfg.Hosts { + number := i + 1 + if strings.TrimSpace(host.Name) == "" { + return fmt.Errorf(`limen: host #%d missing required field "name"`, number) + } + if strings.TrimSpace(host.Hostname) == "" { + return fmt.Errorf(`limen: host #%d missing required field "hostname"`, number) + } + if host.Name == "localhost" { + return errors.New(`limen: "localhost" is reserved`) + } + if _, exists := seenNames[host.Name]; exists { + return fmt.Errorf(`limen: duplicate host name "%s" in hosts.json`, host.Name) + } + seenNames[host.Name] = struct{}{} + } + + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d6f15d6 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,340 @@ +package config + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestLoadReadsValidHostsFile(t *testing.T) { + path := writeConfig(t, `{ + "hosts": [ + { + "name": "prod", + "hostname": "prod-server-01.example.com", + "user": "deploy", + "port": 2222, + "description": "Production application servers." + }, + { + "name": "builder", + "hostname": "builder.lan" + } + ] + }`) + + var stderr bytes.Buffer + cfg, err := Load(path, &stderr) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if stderr.Len() != 0 { + t.Fatalf("Load wrote unexpected warning: %q", stderr.String()) + } + + if len(cfg.Hosts) != 2 { + t.Fatalf("len(cfg.Hosts) = %d, want 2", len(cfg.Hosts)) + } + + prod := cfg.Hosts[0] + if prod.Name != "prod" || + prod.Hostname != "prod-server-01.example.com" || + prod.User != "deploy" || + prod.Port != 2222 || + prod.Description != "Production application servers." { + t.Fatalf("first host = %#v", prod) + } + + builder := cfg.Hosts[1] + if builder.Name != "builder" || builder.Hostname != "builder.lan" { + t.Fatalf("second host = %#v", builder) + } +} + +func TestLoadAllowsEmptyHostsArray(t *testing.T) { + path := writeConfig(t, `{"hosts": []}`) + + var stderr bytes.Buffer + cfg, err := Load(path, &stderr) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if len(cfg.Hosts) != 0 { + t.Fatalf("len(cfg.Hosts) = %d, want 0", len(cfg.Hosts)) + } +} + +func TestLoadWithDefaultPath(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("HOME", t.TempDir()) + + path := filepath.Join(configHome, "limen", "hosts.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("create config dir: %v", err) + } + if err := os.WriteFile(path, []byte(`{"hosts": [{"name": "prod", "hostname": "prod.example.com"}]}`), 0o600); err != nil { + t.Fatalf("write config fixture: %v", err) + } + + var stderr bytes.Buffer + cfg, err := Load("", &stderr) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if len(cfg.Hosts) != 1 || cfg.Hosts[0].Name != "prod" { + t.Fatalf("hosts = %#v, want prod host", cfg.Hosts) + } +} + +func TestLoadMissingConfigWarnsAndReturnsEmptyHosts(t *testing.T) { + path := filepath.Join(t.TempDir(), "hosts.json") + + var stderr bytes.Buffer + cfg, err := Load(path, &stderr) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if len(cfg.Hosts) != 0 { + t.Fatalf("len(cfg.Hosts) = %d, want 0", len(cfg.Hosts)) + } + + want := "limen: no hosts.json found at " + path + "; only localhost will be available\n" + if stderr.String() != want { + t.Fatalf("stderr = %q, want %q", stderr.String(), want) + } +} + +func TestLoadMissingConfigWithNilStderrReturnsEmptyHosts(t *testing.T) { + path := filepath.Join(t.TempDir(), "hosts.json") + + cfg, err := Load(path, nil) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if len(cfg.Hosts) != 0 { + t.Fatalf("len(cfg.Hosts) = %d, want 0", len(cfg.Hosts)) + } +} + +func TestLoadUnreadableConfigIsFatal(t *testing.T) { + path := filepath.Join(t.TempDir(), "hosts.json") + + _, err := os.Stat(filepath.Join(path, "missing-parent")) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("test setup expected missing nested path, got %v", err) + } + + var stderr bytes.Buffer + _, err = Load(path, &stderr) + if err != nil { + t.Fatalf("missing file should warn, got error: %v", err) + } + + dirAsFile := t.TempDir() + _, err = Load(dirAsFile, &stderr) + if err == nil { + t.Fatal("Load returned nil error for unreadable directory path") + } + + wantPrefix := "limen: cannot read " + dirAsFile + ": " + if got := err.Error(); len(got) < len(wantPrefix) || got[:len(wantPrefix)] != wantPrefix { + t.Fatalf("error = %q, want prefix %q", got, wantPrefix) + } +} + +func TestLoadMalformedJSONIncludesPathLineColumnAndMessage(t *testing.T) { + path := writeConfig(t, "{\n \"hosts\": [\n") + + var stderr bytes.Buffer + _, err := Load(path, &stderr) + if err == nil { + t.Fatal("Load returned nil error") + } + + want := "limen: parse error at " + path + ":2:13: unexpected end of JSON input" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + +func TestLoadInvalidTopLevelShapeIncludesPathLineColumnAndMessage(t *testing.T) { + path := writeConfig(t, `[]`) + + var stderr bytes.Buffer + _, err := Load(path, &stderr) + if err == nil { + t.Fatal("Load returned nil error") + } + + want := "limen: hosts must be an array in hosts.json" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + +func TestLoadRejectsMissingHostsProperty(t *testing.T) { + path := writeConfig(t, `{}`) + + var stderr bytes.Buffer + _, err := Load(path, &stderr) + if err == nil { + t.Fatal("Load returned nil error") + } + + want := "limen: hosts must be an array in hosts.json" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + +func TestLoadRejectsHostsWhenNotArray(t *testing.T) { + path := writeConfig(t, `{"hosts": {"name": "prod"}}`) + + var stderr bytes.Buffer + _, err := Load(path, &stderr) + if err == nil { + t.Fatal("Load returned nil error") + } + + want := "limen: hosts must be an array in hosts.json" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + +func TestLoadRejectsInvalidHostFieldTypes(t *testing.T) { + path := writeConfig(t, `{"hosts": [{"name": "prod", "hostname": "prod.example.com", "port": "22"}]}`) + + var stderr bytes.Buffer + _, err := Load(path, &stderr) + if err == nil { + t.Fatal("Load returned nil error") + } + + want := "limen: invalid hosts.json: json: cannot unmarshal string into Go struct field Host.port of type int" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + +func TestLoadRejectsDuplicateHostNames(t *testing.T) { + path := writeConfig(t, `{ + "hosts": [ + {"name": "prod", "hostname": "prod-a.example.com"}, + {"name": "prod", "hostname": "prod-b.example.com"} + ] + }`) + + var stderr bytes.Buffer + _, err := Load(path, &stderr) + if err == nil { + t.Fatal("Load returned nil error") + } + + want := `limen: duplicate host name "prod" in hosts.json` + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + +func TestLoadRejectsReservedLocalhostName(t *testing.T) { + path := writeConfig(t, `{ + "hosts": [ + {"name": "localhost", "hostname": "127.0.0.1"} + ] + }`) + + var stderr bytes.Buffer + _, err := Load(path, &stderr) + if err == nil { + t.Fatal("Load returned nil error") + } + + want := `limen: "localhost" is reserved` + if err.Error() != want { + t.Fatalf("error = %q, want %q", err.Error(), want) + } +} + +func TestLoadRejectsMissingRequiredFields(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + { + name: "missing name", + body: `{"hosts": [{"hostname": "prod.example.com"}]}`, + want: `limen: host #1 missing required field "name"`, + }, + { + name: "missing hostname", + body: `{"hosts": [{"name": "prod"}]}`, + want: `limen: host #1 missing required field "hostname"`, + }, + { + name: "blank name", + body: `{"hosts": [{"name": " ", "hostname": "prod.example.com"}]}`, + want: `limen: host #1 missing required field "name"`, + }, + { + name: "blank hostname", + body: `{"hosts": [{"name": "prod", "hostname": "\t"}]}`, + want: `limen: host #1 missing required field "hostname"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeConfig(t, tt.body) + + var stderr bytes.Buffer + _, err := Load(path, &stderr) + if err == nil { + t.Fatal("Load returned nil error") + } + + if err.Error() != tt.want { + t.Fatalf("error = %q, want %q", err.Error(), tt.want) + } + }) + } +} + +func TestDefaultPathUsesXDGConfigHome(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config") + t.Setenv("HOME", "/tmp/home") + + want := filepath.Join("/tmp/xdg-config", "limen", "hosts.json") + if got := DefaultPath(); got != want { + t.Fatalf("DefaultPath() = %q, want %q", got, want) + } +} + +func TestDefaultPathFallsBackToHomeConfig(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", "/tmp/home") + + want := filepath.Join("/tmp/home", ".config", "limen", "hosts.json") + if got := DefaultPath(); got != want { + t.Fatalf("DefaultPath() = %q, want %q", got, want) + } +} + +func writeConfig(t *testing.T, body string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "hosts.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write config fixture: %v", err) + } + return path +} diff --git a/internal/format/time.go b/internal/format/time.go new file mode 100644 index 0000000..a7b7292 --- /dev/null +++ b/internal/format/time.go @@ -0,0 +1,49 @@ +// Package format contains presentation helpers shared by non-UI layers. +package format + +import ( + "fmt" + "time" +) + +const day = 24 * time.Hour + +// RelativeTime formats a timestamp using the brackets defined in DESIGN.md. +func RelativeTime(when time.Time, now time.Time) string { + if when.IsZero() { + return "never" + } + + delta := now.Sub(when) + if delta < time.Minute { + return "just now" + } + if delta < time.Hour { + minutes := int(delta / time.Minute) + return plural(minutes, "minute") + } + if delta < day { + hours := int(delta / time.Hour) + return plural(hours, "hour") + } + if delta < 2*day { + return "yesterday" + } + if delta < 7*day { + days := int(delta / day) + return plural(days, "day") + } + if delta < 28*day { + weeks := int(delta / (7 * day)) + return plural(weeks, "week") + } + + return "on " + when.Format("Mon Jan 2") +} + +func plural(count int, unit string) string { + if count == 1 { + return fmt.Sprintf("1 %s ago", unit) + } + return fmt.Sprintf("%d %ss ago", count, unit) +} diff --git a/internal/format/time_test.go b/internal/format/time_test.go new file mode 100644 index 0000000..8be0036 --- /dev/null +++ b/internal/format/time_test.go @@ -0,0 +1,41 @@ +package format + +import ( + "testing" + "time" +) + +func TestRelativeTimeFormatsDesignBrackets(t *testing.T) { + now := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC) + + tests := []struct { + name string + when time.Time + want string + }{ + {name: "absent", when: time.Time{}, want: "never"}, + {name: "future", when: now.Add(5 * time.Second), want: "just now"}, + {name: "under sixty seconds", when: now.Add(-59 * time.Second), want: "just now"}, + {name: "one minute", when: now.Add(-1 * time.Minute), want: "1 minute ago"}, + {name: "plural minutes", when: now.Add(-5 * time.Minute), want: "5 minutes ago"}, + {name: "fifty nine minutes", when: now.Add(-59 * time.Minute), want: "59 minutes ago"}, + {name: "one hour", when: now.Add(-1 * time.Hour), want: "1 hour ago"}, + {name: "plural hours", when: now.Add(-5 * time.Hour), want: "5 hours ago"}, + {name: "twenty three hours", when: now.Add(-23 * time.Hour), want: "23 hours ago"}, + {name: "yesterday at one day", when: now.Add(-24 * time.Hour), want: "yesterday"}, + {name: "yesterday before two days", when: now.Add(-47 * time.Hour), want: "yesterday"}, + {name: "two days", when: now.Add(-48 * time.Hour), want: "2 days ago"}, + {name: "six days", when: now.Add(-6 * 24 * time.Hour), want: "6 days ago"}, + {name: "one week", when: now.Add(-7 * 24 * time.Hour), want: "1 week ago"}, + {name: "plural weeks", when: now.Add(-21 * 24 * time.Hour), want: "3 weeks ago"}, + {name: "four weeks", when: now.Add(-28 * 24 * time.Hour), want: "on Tue Apr 28"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RelativeTime(tt.when, now); got != tt.want { + t.Fatalf("RelativeTime() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..1a286b2 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,117 @@ +// Package state persists advisory last-attached timestamps. +package state + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "time" +) + +const ( + stateDirName = "limen" + stateFileName = "state.json" +) + +// State is the top-level state.json document. +type State struct { + LastAttached map[string]time.Time `json:"lastAttached"` +} + +// DefaultPath returns the design-specified state.json path. +func DefaultPath() string { + if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" { + return filepath.Join(xdgDataHome, stateDirName, stateFileName) + } + + home := os.Getenv("HOME") + return filepath.Join(home, ".local", "share", stateDirName, stateFileName) +} + +// Load reads state.json. State is advisory, so any read or decode failure +// returns an empty state instead of blocking startup. +func Load(path string) State { + if path == "" { + path = DefaultPath() + } + + data, err := os.ReadFile(path) + if err != nil { + return empty() + } + + var st State + if err := json.Unmarshal(data, &st); err != nil { + return empty() + } + if st.LastAttached == nil { + st.LastAttached = map[string]time.Time{} + } + return st +} + +// Save writes state.json atomically using a temporary file in the same +// directory, then renaming it into place. +func Save(path string, st State) error { + if path == "" { + path = DefaultPath() + } + + if st.LastAttached == nil { + st.LastAttached = map[string]time.Time{} + } + + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + var data bytes.Buffer + encoder := json.NewEncoder(&data) + encoder.SetIndent("", " ") + if err := encoder.Encode(st); err != nil { + return err + } + + tmpPath := path + ".tmp" + if err := writePrivateFile(tmpPath, data.Bytes()); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Chmod(path, 0o600); err != nil { + return err + } + + return nil +} + +func writePrivateFile(path string, data []byte) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + + _, writeErr := file.Write(data) + closeErr := file.Close() + if writeErr != nil { + _ = os.Remove(path) + return writeErr + } + if closeErr != nil { + _ = os.Remove(path) + return closeErr + } + if err := os.Chmod(path, 0o600); err != nil { + _ = os.Remove(path) + return err + } + + return nil +} + +func empty() State { + return State{LastAttached: map[string]time.Time{}} +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..52e8727 --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,257 @@ +package state + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadMissingFileReturnsEmptyState(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + + st := Load(path) + + if len(st.LastAttached) != 0 { + t.Fatalf("len(st.LastAttached) = %d, want 0", len(st.LastAttached)) + } +} + +func TestLoadMalformedFileReturnsEmptyState(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte(`{"lastAttached":`), 0o600); err != nil { + t.Fatalf("write malformed state: %v", err) + } + + st := Load(path) + + if len(st.LastAttached) != 0 { + t.Fatalf("len(st.LastAttached) = %d, want 0", len(st.LastAttached)) + } +} + +func TestLoadInvalidTimestampReturnsEmptyState(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte(`{"lastAttached":{"prod":"not-a-time"}}`), 0o600); err != nil { + t.Fatalf("write invalid state: %v", err) + } + + st := Load(path) + + if len(st.LastAttached) != 0 { + t.Fatalf("len(st.LastAttached) = %d, want 0", len(st.LastAttached)) + } +} + +func TestLoadWithNullLastAttachedReturnsEmptyState(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte(`{"lastAttached":null}`), 0o600); err != nil { + t.Fatalf("write null state: %v", err) + } + + st := Load(path) + + if len(st.LastAttached) != 0 { + t.Fatalf("len(st.LastAttached) = %d, want 0", len(st.LastAttached)) + } +} + +func TestLoadWithDefaultPath(t *testing.T) { + dataHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("HOME", t.TempDir()) + + path := filepath.Join(dataHome, "limen", "state.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("create state dir: %v", err) + } + if err := os.WriteFile(path, []byte(`{"lastAttached":{"prod":"2026-05-26T13:45:00Z"}}`), 0o600); err != nil { + t.Fatalf("write state fixture: %v", err) + } + + st := Load("") + + want := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC) + if got := st.LastAttached["prod"]; !got.Equal(want) { + t.Fatalf("prod timestamp = %s, want %s", got, want) + } +} + +func TestSaveRoundTripsState(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "state.json") + attachedAt := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC) + + err := Save(path, State{ + LastAttached: map[string]time.Time{ + "localhost": attachedAt, + "prod": attachedAt.Add(-2 * time.Hour), + }, + }) + if err != nil { + t.Fatalf("Save returned error: %v", err) + } + + st := Load(path) + if got := st.LastAttached["localhost"]; !got.Equal(attachedAt) { + t.Fatalf("localhost timestamp = %s, want %s", got, attachedAt) + } + if got := st.LastAttached["prod"]; !got.Equal(attachedAt.Add(-2 * time.Hour)) { + t.Fatalf("prod timestamp = %s, want %s", got, attachedAt.Add(-2*time.Hour)) + } +} + +func TestSaveWithDefaultPath(t *testing.T) { + dataHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("HOME", t.TempDir()) + + attachedAt := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC) + if err := Save("", State{LastAttached: map[string]time.Time{"prod": attachedAt}}); err != nil { + t.Fatalf("Save returned error: %v", err) + } + + path := filepath.Join(dataHome, "limen", "state.json") + st := Load(path) + if got := st.LastAttached["prod"]; !got.Equal(attachedAt) { + t.Fatalf("prod timestamp = %s, want %s", got, attachedAt) + } +} + +func TestSaveReturnsErrorWhenParentPathIsFile(t *testing.T) { + parent := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(parent, []byte("x"), 0o600); err != nil { + t.Fatalf("write parent fixture: %v", err) + } + + err := Save(filepath.Join(parent, "state.json"), State{}) + + if err == nil { + t.Fatal("Save returned nil error") + } +} + +func TestSaveReturnsErrorWhenTemporaryPathIsDirectory(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + if err := os.Mkdir(filepath.Join(dir, "state.json.tmp"), 0o700); err != nil { + t.Fatalf("create temporary directory fixture: %v", err) + } + + err := Save(path, State{}) + + if err == nil { + t.Fatal("Save returned nil error") + } +} + +func TestSaveReturnsErrorWhenTargetPathIsDirectory(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create target directory fixture: %v", err) + } + + err := Save(path, State{}) + + if err == nil { + t.Fatal("Save returned nil error") + } + if _, statErr := os.Stat(filepath.Join(dir, "state.json.tmp")); !os.IsNotExist(statErr) { + t.Fatalf("temporary file cleanup error = %v, want not exist", statErr) + } +} + +func TestSaveCreatesPrivateDirectoryAndFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "limen", "state.json") + + if err := Save(path, State{}); err != nil { + t.Fatalf("Save returned error: %v", err) + } + + dirInfo, err := os.Stat(filepath.Dir(path)) + if err != nil { + t.Fatalf("stat state dir: %v", err) + } + if got := dirInfo.Mode().Perm(); got != 0o700 { + t.Fatalf("state dir mode = %o, want 700", got) + } + + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("stat state file: %v", err) + } + if got := fileInfo.Mode().Perm(); got != 0o600 { + t.Fatalf("state file mode = %o, want 600", got) + } +} + +func TestSaveReplacesStaleTemporaryFileWithPrivateMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + tmp := filepath.Join(dir, "state.json.tmp") + if err := os.WriteFile(tmp, []byte("stale"), 0o644); err != nil { + t.Fatalf("write stale temporary file: %v", err) + } + + if err := Save(path, State{}); err != nil { + t.Fatalf("Save returned error: %v", err) + } + + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("stat state file: %v", err) + } + if got := fileInfo.Mode().Perm(); got != 0o600 { + t.Fatalf("state file mode = %o, want 600", got) + } +} + +func TestSaveUsesTemporaryFileInSameDirectoryThenRenames(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + tmp := filepath.Join(dir, "state.json.tmp") + + if err := Save(path, State{}); err != nil { + t.Fatalf("Save returned error: %v", err) + } + + if _, err := os.Stat(tmp); !os.IsNotExist(err) { + t.Fatalf("temporary file still exists, err = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read state file: %v", err) + } + + var decoded struct { + LastAttached map[string]time.Time `json:"lastAttached"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("state file is not valid JSON: %v", err) + } + if decoded.LastAttached == nil { + t.Fatal("lastAttached decoded as nil, want empty object") + } +} + +func TestDefaultPathUsesXDGDataHome(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/tmp/xdg-data") + t.Setenv("HOME", "/tmp/home") + + want := filepath.Join("/tmp/xdg-data", "limen", "state.json") + if got := DefaultPath(); got != want { + t.Fatalf("DefaultPath() = %q, want %q", got, want) + } +} + +func TestDefaultPathFallsBackToHomeLocalShare(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "") + t.Setenv("HOME", "/tmp/home") + + want := filepath.Join("/tmp/home", ".local", "share", "limen", "state.json") + if got := DefaultPath(); got != want { + t.Fatalf("DefaultPath() = %q, want %q", got, want) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..17b2e99 --- /dev/null +++ b/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/KofTwentyTwo/limen/internal/config" + "github.com/KofTwentyTwo/limen/internal/state" +) + +type output struct { + Config config.Config `json:"config"` + State state.State `json:"state"` +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout io.Writer, stderr io.Writer) int { + flags := flag.NewFlagSet("limen", flag.ContinueOnError) + flags.SetOutput(stderr) + + configPath := flags.String("config", config.DefaultPath(), "path to hosts.json") + statePath := flags.String("state", state.DefaultPath(), "path to state.json") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintf(stderr, "limen: unexpected argument %q\n", flags.Arg(0)) + return 2 + } + + cfg, err := config.Load(*configPath, stderr) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + + st := state.Load(*statePath) + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(output{Config: cfg, State: st}); err != nil { + fmt.Fprintf(stderr, "limen: cannot write output: %v\n", err) + return 1 + } + + return 0 +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..cd3b8f8 --- /dev/null +++ b/main_test.go @@ -0,0 +1,192 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestRunPrintsConfigAndStateAsJSON(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "hosts.json") + statePath := filepath.Join(dir, "state.json") + + if err := os.WriteFile(configPath, []byte(`{ + "hosts": [ + {"name": "prod", "hostname": "prod.example.com", "user": "deploy"} + ] + }`), 0o600); err != nil { + t.Fatalf("write config fixture: %v", err) + } + + if err := os.WriteFile(statePath, []byte(`{ + "lastAttached": { + "prod": "2026-05-26T13:45:00Z" + } + }`), 0o600); err != nil { + t.Fatalf("write state fixture: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := run([]string{"--config", configPath, "--state", statePath}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("run exit code = %d, want 0; stderr = %q", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + var got struct { + Config struct { + Hosts []struct { + Name string `json:"name"` + Hostname string `json:"hostname"` + User string `json:"user,omitempty"` + } `json:"hosts"` + } `json:"config"` + State struct { + LastAttached map[string]time.Time `json:"lastAttached"` + } `json:"state"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) + } + + if len(got.Config.Hosts) != 1 { + t.Fatalf("len(hosts) = %d, want 1", len(got.Config.Hosts)) + } + if got.Config.Hosts[0].Name != "prod" || got.Config.Hosts[0].Hostname != "prod.example.com" || got.Config.Hosts[0].User != "deploy" { + t.Fatalf("host = %#v", got.Config.Hosts[0]) + } + + wantTime := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC) + if gotTime := got.State.LastAttached["prod"]; !gotTime.Equal(wantTime) { + t.Fatalf("state timestamp = %s, want %s", gotTime, wantTime) + } +} + +func TestRunWarnsForMissingConfigAndPrintsEmptyHosts(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "hosts.json") + statePath := filepath.Join(t.TempDir(), "state.json") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := run([]string{"--config", configPath, "--state", statePath}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("run exit code = %d, want 0; stderr = %q", code, stderr.String()) + } + + wantWarning := "limen: no hosts.json found at " + configPath + "; only localhost will be available\n" + if stderr.String() != wantWarning { + t.Fatalf("stderr = %q, want %q", stderr.String(), wantWarning) + } + + var got struct { + Config struct { + Hosts []struct{} `json:"hosts"` + } `json:"config"` + State struct { + LastAttached map[string]time.Time `json:"lastAttached"` + } `json:"state"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) + } + if len(got.Config.Hosts) != 0 { + t.Fatalf("len(hosts) = %d, want 0", len(got.Config.Hosts)) + } + if len(got.State.LastAttached) != 0 { + t.Fatalf("len(lastAttached) = %d, want 0", len(got.State.LastAttached)) + } +} + +func TestRunReturnsFailureForConfigErrors(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "hosts.json") + if err := os.WriteFile(configPath, []byte(`{"hosts":[{"name":"localhost","hostname":"127.0.0.1"}]}`), 0o600); err != nil { + t.Fatalf("write config fixture: %v", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := run([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("run exit code = %d, want 1", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + + want := "limen: \"localhost\" is reserved\n" + if stderr.String() != want { + t.Fatalf("stderr = %q, want %q", stderr.String(), want) + } +} + +func TestRunReturnsUsageFailureForFlagErrors(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := run([]string{"--config"}, &stdout, &stderr) + + if code != 2 { + t.Fatalf("run exit code = %d, want 2", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if got := stderr.String(); got == "" { + t.Fatal("stderr is empty, want flag error") + } +} + +func TestRunReturnsUsageFailureForUnexpectedArguments(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := run([]string{"extra"}, &stdout, &stderr) + + if code != 2 { + t.Fatalf("run exit code = %d, want 2", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + + want := "limen: unexpected argument \"extra\"\n" + if stderr.String() != want { + t.Fatalf("stderr = %q, want %q", stderr.String(), want) + } +} + +func TestRunReturnsFailureWhenJSONOutputCannotBeWritten(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "hosts.json") + statePath := filepath.Join(t.TempDir(), "state.json") + + if err := os.WriteFile(configPath, []byte(`{"hosts":[]}`), 0o600); err != nil { + t.Fatalf("write config fixture: %v", err) + } + + var stderr bytes.Buffer + code := run([]string{"--config", configPath, "--state", statePath}, failingWriter{}, &stderr) + + if code != 1 { + t.Fatalf("run exit code = %d, want 1", code) + } + + want := "limen: cannot write output: write failed\n" + if stderr.String() != want { + t.Fatalf("stderr = %q, want %q", stderr.String(), want) + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { + return 0, errors.New("write failed") +} diff --git a/testdata/hosts.json b/testdata/hosts.json new file mode 100644 index 0000000..2b9b0e8 --- /dev/null +++ b/testdata/hosts.json @@ -0,0 +1,15 @@ +{ + "hosts": [ + { + "name": "prod", + "hostname": "prod-server-01.example.com", + "user": "deploy", + "port": 22, + "description": "Production application servers. Deploys via fastlane." + }, + { + "name": "builder", + "hostname": "builder.lan" + } + ] +} -- 2.54.0