feat: add milestone 1 foundations #5

Merged
KofTwentyTwo merged 1 commits from feature/GH-1-foundations into develop 2026-05-26 20:02:35 +00:00
13 changed files with 1314 additions and 2 deletions

No files matched your search

+2 -2
View File
@@ -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 "<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 <path>; 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/<owner>/limen";
license = pkgs.lib.licenses.mit;
license = pkgs.lib.licenses.gpl3Only;
mainProgram = "limen";
};
};
Generated
+61
View File
@@ -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
}
+34
View File
@@ -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";
};
};
});
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/KofTwentyTwo/limen
go 1.22
+151
View File
@@ -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
}
+340
View File
@@ -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
}
+49
View File
@@ -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)
}
+41
View File
@@ -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)
}
})
}
}
+117
View File
@@ -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{}}
}
+257
View File
@@ -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)
}
}
+52
View File
@@ -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
}
+192
View File
@@ -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")
}
+15
View File
@@ -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"
}
]
}