Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.4.0
v0.4.1
15 changes: 10 additions & 5 deletions cmd/park/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
154 changes: 154 additions & 0 deletions cmd/park/main_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
180 changes: 180 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading