feat: add milestone 1 foundations #5
No files matched your search
+2
-2
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{}}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
Vendored
+15
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user