diff --git a/README.md b/README.md index 89dab49..c64348c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ park reclassify 1767786622-idea.md -c projects ## Install +Quick install (downloads the latest release to `/usr/local/bin`): + +```bash +curl -sSL https://raw.githubusercontent.com/polymorcodeus/park/main/install.sh | bash +``` + +Or install via Go: + ```bash go install github.com/polymorcodeus/park@latest ``` diff --git a/VERSION b/VERSION index fb7a04c..5aff472 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.4.0 +v0.4.1 diff --git a/cmd/park/main.go b/cmd/park/main.go index 364a6af..982773a 100644 --- a/cmd/park/main.go +++ b/cmd/park/main.go @@ -39,6 +39,15 @@ func buildVersion() string { } func Main() { + if err := newCommand().Run(context.Background(), os.Args); err != nil { + fmt.Fprintln(os.Stderr, styledError(err)) + os.Exit(1) + } +} + +// newCommand builds the park CLI command tree. It is extracted from Main so +// tests can drive dispatch without touching os.Args or process exit. +func newCommand() *cli.Command { var cfg *config.Config var ( @@ -53,7 +62,7 @@ func Main() { defaultRoot = config.DefaultRootPath() } - cmd := &cli.Command{ + return &cli.Command{ Name: "park", Usage: "IPAA: a parking lot for markdown notes (Inbox/Projects/Areas/Archive)", Version: buildVersion(), @@ -263,10 +272,6 @@ func Main() { }, } - if err := cmd.Run(context.Background(), os.Args); err != nil { - fmt.Fprintln(os.Stderr, styledError(err)) - os.Exit(1) - } } // styledExit wraps an error in the configured styled output and returns a diff --git a/cmd/park/main_test.go b/cmd/park/main_test.go new file mode 100644 index 0000000..00108c8 --- /dev/null +++ b/cmd/park/main_test.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/urfave/cli/v3" +) + +// runPark builds a fresh command rooted in a temp directory and runs it with +// the supplied args, capturing stdout and stderr. It overrides the +// ExitErrHandler so exit-code errors are returned instead of calling os.Exit. +func runPark(t *testing.T, root string, args ...string) (stdout, stderr string, err error) { + t.Helper() + + t.Setenv("PARK_ROOT", root) + t.Setenv("PARK_CONFIG", filepath.Join(root, "config")) + + cmd := newCommand() + var out, errOut strings.Builder + cmd.Writer = &out + cmd.ErrWriter = &errOut + cmd.ExitErrHandler = func(context.Context, *cli.Command, error) {} + + full := append([]string{"park"}, args...) + err = cmd.Run(context.Background(), full) + return out.String(), errOut.String(), err +} + +func exitCode(t *testing.T, err error) int { + t.Helper() + var c cli.ExitCoder + if !errors.As(err, &c) { + t.Fatalf("error %v does not implement cli.ExitCoder", err) + } + return c.ExitCode() +} + +func TestConfigCommand(t *testing.T) { + out, _, err := runPark(t, t.TempDir(), "config") + if err != nil { + t.Fatalf("config command error = %v", err) + } + for _, want := range []string{"default_category", "inbox", "projects", "areas", "archive"} { + if !strings.Contains(out, want) { + t.Errorf("config output missing %q:\n%s", want, out) + } + } +} + +func TestInitCommand(t *testing.T) { + root := t.TempDir() + out, _, err := runPark(t, root, "init") + if err != nil { + t.Fatalf("init command error = %v", err) + } + if !strings.Contains(out, "created park folders") { + t.Errorf("init output = %q, want created-park-folders message", out) + } + for _, name := range []string{"_inbox", "_projects", "_areas", "_archive"} { + info, err := os.Stat(filepath.Join(root, name)) + if err != nil { + t.Errorf("expected folder %q to exist: %v", name, err) + continue + } + if !info.IsDir() { + t.Errorf("expected %q to be a directory", name) + } + } +} + +func TestReclassifyMissingArgs(t *testing.T) { + _, _, err := runPark(t, t.TempDir(), "reclassify") + if err == nil { + t.Fatal("expected error for reclassify without a file argument") + } + if got := exitCode(t, err); got != 2 { + t.Errorf("exit code = %d, want 2", got) + } +} + +func TestReclassifyUnknownCategory(t *testing.T) { + _, _, err := runPark(t, t.TempDir(), "reclassify", "somefile.md", "--category", "bogus") + if err == nil { + t.Fatal("expected error for unknown category") + } + if got := exitCode(t, err); got != 2 { + t.Errorf("exit code = %d, want 2", got) + } + if !strings.Contains(err.Error(), "unknown category") { + t.Errorf("error = %q, want unknown-category message", err.Error()) + } +} + +func TestReclassifySameCategory(t *testing.T) { + root := t.TempDir() + inbox := filepath.Join(root, "_inbox") + if err := os.MkdirAll(inbox, 0o755); err != nil { + t.Fatalf("create inbox: %v", err) + } + notePath := filepath.Join(inbox, "existing.md") + content := "---\ncategory: inbox\ncreated: 2026-01-01\nsource: test\nsynopsis: test\n---\n\nbody\n" + if err := os.WriteFile(notePath, []byte(content), 0o644); err != nil { + t.Fatalf("write note: %v", err) + } + + _, _, err := runPark(t, root, "reclassify", "existing.md", "--category", "inbox") + if err == nil { + t.Fatal("expected error for same-category reclassify") + } + if got := exitCode(t, err); got != 1 { + t.Errorf("exit code = %d, want 1", got) + } + if !strings.Contains(err.Error(), "already in") { + t.Errorf("error = %q, want already-in message", err.Error()) + } +} + +func TestShowMissingArg(t *testing.T) { + _, _, err := runPark(t, t.TempDir(), "show") + if err == nil { + t.Fatal("expected error for show without a file argument") + } + if got := exitCode(t, err); got != 2 { + t.Errorf("exit code = %d, want 2", got) + } +} + +func TestShowMissingFile(t *testing.T) { + _, _, err := runPark(t, t.TempDir(), "show", "nonexistent.md") + if err == nil { + t.Fatal("expected error for show of a missing file") + } + if got := exitCode(t, err); got != 1 { + t.Errorf("exit code = %d, want 1", got) + } +} + +func TestStyledExit(t *testing.T) { + err := styledExit(errors.New("boom"), 7) + if err == nil { + t.Fatal("expected non-nil error") + } + if got := exitCode(t, err); got != 7 { + t.Errorf("exit code = %d, want 7", got) + } + if !strings.Contains(err.Error(), "boom") { + t.Errorf("error = %q, want boom message", err.Error()) + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e788aef --- /dev/null +++ b/install.sh @@ -0,0 +1,180 @@ +#!/bin/bash + +# park installer script +# Downloads and installs the latest release of park + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# GitHub repository +REPO="polymorcodeus/park" +INSTALL_DIR="/usr/local/bin" +BINARY_NAME="park" + +# Fallback version if redirect fails +FALLBACK_VERSION="v0.4.0" + +# Detect OS and architecture +detect_platform() { + local os arch + + # Detect OS + case "$(uname -s)" in + Linux) os="Linux" ;; + Darwin) os="Darwin" ;; + MINGW*|MSYS*|CYGWIN*) os="Windows" ;; + *) + echo -e "${RED}Error: Unsupported operating system $(uname -s)${NC}" + exit 1 + ;; + esac + + # Detect architecture + case "$(uname -m)" in + x86_64|amd64) arch="x86_64" ;; + arm64|aarch64) arch="arm64" ;; + *) + echo -e "${RED}Error: Unsupported architecture $(uname -m)${NC}" + exit 1 + ;; + esac + + echo "${os}_${arch}" +} + +# Get latest version by following redirect +get_latest_version() { + echo -e "${BLUE}Getting latest release version...${NC}" >&2 + + # Get redirect location from releases/latest + local redirect_url + redirect_url=$(curl -s -I "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/\r$//' | cut -d' ' -f2-) + + if [ -z "$redirect_url" ]; then + echo -e "${YELLOW}Could not get redirect URL, using fallback version ${FALLBACK_VERSION}${NC}" >&2 + echo "$FALLBACK_VERSION" + return 0 + fi + + # Extract version from redirect URL (format: https://github.com/user/repo/releases/tag/v1.2.3) + local version + version=$(echo "$redirect_url" | sed -E 's|.*/releases/tag/([^/]*)\s*$|\1|') + + if [ -z "$version" ] || [ "$version" = "$redirect_url" ]; then + echo -e "${YELLOW}Could not parse version from redirect URL: $redirect_url${NC}" >&2 + echo -e "${YELLOW}Using fallback version ${FALLBACK_VERSION}${NC}" >&2 + echo "$FALLBACK_VERSION" + return 0 + fi + + echo "$version" +} + +# Get version to install +get_version() { + # Allow override via environment variable + if [ -n "$PARK_VERSION" ]; then + echo "$PARK_VERSION" + elif [ -n "$1" ]; then + echo "$1" + else + get_latest_version + fi +} + +# Download and install +install_park() { + local platform version + + echo -e "${BLUE}Installing park...${NC}" + + platform=$(detect_platform) + version=$(get_version "$1") + + echo -e "${BLUE}Version: ${version}${NC}" + echo -e "${BLUE}Platform: ${platform}${NC}" + + # Download URL + local filename="park_${platform}.tar.gz" + local url="https://github.com/${REPO}/releases/download/${version}/${filename}" + + echo -e "${BLUE}Downloading ${url}...${NC}" + + # Create temporary directory + local tmp_dir=$(mktemp -d) + cd "$tmp_dir" + + # Download the binary + if ! curl -sL "$url" -o "$filename"; then + echo -e "${RED}Error: Failed to download ${url}${NC}" + echo -e "${YELLOW}Please check if the release exists at: https://github.com/${REPO}/releases/tag/${version}${NC}" + echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}" + exit 1 + fi + + # Check if we got an HTML error page instead of the binary + if file "$filename" 2>/dev/null | grep -q "HTML"; then + echo -e "${RED}Error: Downloaded file appears to be an HTML page (404 error)${NC}" + echo -e "${YELLOW}The release ${version} might not exist.${NC}" + echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}" + exit 1 + fi + + # Extract the binary + if ! tar -xzf "$filename"; then + echo -e "${RED}Error: Failed to extract ${filename}${NC}" + exit 1 + fi + + # Make binary executable + chmod +x "$BINARY_NAME" + + # Install to system directory + echo -e "${YELLOW}Installing to ${INSTALL_DIR} (requires sudo)...${NC}" + if ! sudo mv "$BINARY_NAME" "$INSTALL_DIR/"; then + echo -e "${RED}Error: Failed to install binary${NC}" + exit 1 + fi + + # Cleanup + cd - > /dev/null + rm -rf "$tmp_dir" + + echo -e "${GREEN}park installed successfully!${NC}" + echo -e "${GREEN}Run 'park --help' to get started.${NC}" + + # Test the installation + if command -v park >/dev/null 2>&1; then + echo -e "${GREEN}Installed version: $(park --version)${NC}" + fi +} + +# Check if running with --help +if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then + echo "park installer script" + echo "" + echo "Usage:" + echo " curl -sSL https://raw.githubusercontent.com/polymorcodeus/park/main/install.sh | bash" + echo " curl -sSL https://raw.githubusercontent.com/polymorcodeus/park/main/install.sh | bash -s v0.4.0" + echo " PARK_VERSION=v0.4.0 curl -sSL https://raw.githubusercontent.com/polymorcodeus/park/main/install.sh | bash" + echo "" + echo "This script will:" + echo " 1. Detect your OS and architecture" + echo " 2. Auto-detect the latest release by following GitHub redirects" + echo " 3. Download and install to /usr/local/bin (requires sudo)" + echo "" + echo "Environment variables:" + echo " PARK_VERSION - Specify version to install (e.g., v0.4.0)" + echo "" + echo "Manual installation: https://github.com/polymorcodeus/park/releases" + exit 0 +fi + +# Run the installer +install_park "$1" diff --git a/internal/model/form_test.go b/internal/model/form_test.go index 8db75fa..7ba2958 100644 --- a/internal/model/form_test.go +++ b/internal/model/form_test.go @@ -165,6 +165,63 @@ func TestNoteFormModelCategoryNavigation(t *testing.T) { } } +func TestNoteFormModelCancelKeys(t *testing.T) { + cfg := config.DefaultConfig(t.TempDir()) + m, err := NewNoteFormModel(cfg, note.Draft{Metadata: note.Metadata{Category: "inbox"}}) + if err != nil { + t.Fatalf("NewNoteFormModel() error = %v", err) + } + + cancelKeys := []tea.KeyPressMsg{ + {Code: tea.KeyEsc}, + {Code: 'c', Mod: tea.ModCtrl}, + } + for _, k := range cancelKeys { + updated, cmd := m.Update(k) + if _, ok := updated.(NoteFormModel); !ok { + t.Fatalf("unexpected model type for %v", k) + } + if cmd == nil { + t.Fatalf("expected quit command for %v", k) + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Errorf("expected QuitMsg for %v, got %T", k, cmd()) + } + } +} + +func TestNoteFormModelCategoryKeyBindings(t *testing.T) { + cfg := config.DefaultConfig(t.TempDir()) + + tests := []struct { + name string + key tea.KeyPressMsg + want string + }{ + {name: "left arrow", key: tea.KeyPressMsg{Code: tea.KeyLeft}, want: "archive"}, + {name: "h", key: keyPress('h'), want: "archive"}, + {name: "right arrow", key: tea.KeyPressMsg{Code: tea.KeyRight}, want: "projects"}, + {name: "l", key: keyPress('l'), want: "projects"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, err := NewNoteFormModel(cfg, note.Draft{Metadata: note.Metadata{Category: "inbox"}}) + if err != nil { + t.Fatalf("NewNoteFormModel() error = %v", err) + } + m.focusIndex = fieldIndex(m, fieldCategory) + updated, _ := m.Update(tt.key) + final, ok := updated.(NoteFormModel) + if !ok { + t.Fatalf("unexpected model type") + } + if final.categoryName() != tt.want { + t.Errorf("category after %s = %q, want %q", tt.name, final.categoryName(), tt.want) + } + }) + } +} + func TestNoteFormModelBodyCursorStartsAtTop(t *testing.T) { cfg := config.DefaultConfig(t.TempDir()) body := strings.Repeat("line\n", 20) diff --git a/internal/note/note_test.go b/internal/note/note_test.go index 51064d9..1de6f0f 100644 --- a/internal/note/note_test.go +++ b/internal/note/note_test.go @@ -525,6 +525,76 @@ func TestAdd(t *testing.T) { t.Errorf("form source = %q, want migration", out.Form.Source) } }) + + t.Run("stdin body without frontmatter and missing metadata returns form with body", func(t *testing.T) { + body := "# Piped Body\n\ntext\n" + d := Draft{Body: body} + out, err := Add(cfg, d) + if err != nil { + t.Fatalf("Add() error = %v", err) + } + if out.Form == nil { + t.Fatal("expected form outcome") + } + if out.Form.Body != body { + t.Errorf("form body = %q, want %q", out.Form.Body, body) + } + if out.Form.Category != "inbox" { + t.Errorf("form category = %q, want inbox", out.Form.Category) + } + if out.Form.Filename != "Piped Body" { + t.Errorf("form filename = %q, want %q", out.Form.Filename, "Piped Body") + } + }) + + t.Run("body with H1 and no filename derives filename", func(t *testing.T) { + d := Draft{ + Body: "# Derived Title\n\nbody\n", + Metadata: Metadata{Synopsis: "h1 derived", Source: "stdin", Category: "inbox"}, + } + out, err := Add(cfg, d) + if err != nil { + t.Fatalf("Add() error = %v", err) + } + if out.Form != nil { + t.Fatal("expected direct creation, got form") + } + if !strings.HasSuffix(out.Path, "derived-title.md") { + t.Errorf("path = %q, expected suffix derived-title.md", out.Path) + } + }) + + t.Run("body without H1 and no filename returns error", func(t *testing.T) { + d := Draft{ + Body: "just text without a heading\n", + Metadata: Metadata{Synopsis: "no h1", Source: "stdin", Category: "inbox"}, + } + _, err := Add(cfg, d) + if err == nil { + t.Fatal("expected error when no filename and no H1") + } + }) + + t.Run("body with frontmatter and explicit category flag overrides category", func(t *testing.T) { + d := Draft{ + Body: "---\ncategory: areas\nsource: chat\nsynopsis: fm-driven\ncreated: 2026-07-01\n---\n\n# Title\n\nbody\n", + Metadata: Metadata{Category: "archive"}, + } + out, err := Add(cfg, d) + if err != nil { + t.Fatalf("Add() error = %v", err) + } + if out.Form != nil { + t.Fatal("expected direct creation, got form") + } + got, err := Parse(out.Path) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if got.Category != "archive" || got.Source != "chat" || got.Synopsis != "fm-driven" { + t.Errorf("frontmatter mismatch: %+v", got.Metadata) + } + }) } func TestSlugify(t *testing.T) {