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
13 changes: 13 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Summary

<!-- What does this PR do and why? -->

## Changes

-

## Test plan

- [ ] Unit tests pass (`uv run pytest`)
- [ ] Linter passes (`uv run ruff check .`)
- [ ] Manually tested with `dualentry <command>`
113 changes: 113 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: Release

on:
release:
types: [published]

permissions:
contents: write

jobs:
build:
strategy:
matrix:
include:
- os: macos-14
target: macos-arm64
- os: macos-13
target: macos-x86_64
- os: ubuntu-latest
target: linux-x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
with:
python-version: "3.12"
- name: Stamp version from tag
run: |
VERSION="${GITHUB_REF_NAME#v}"
sed -i.bak "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
sed -i.bak "s/^__version__ = .*/__version__ = \"$VERSION\"/" src/dualentry_cli/__init__.py
rm -f pyproject.toml.bak src/dualentry_cli/__init__.py.bak
- run: uv sync --dev
- run: uv run python scripts/build.py
- name: Rename binary
run: mv dist/dualentry dist/dualentry-${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: dualentry-${{ matrix.target }}
path: dist/dualentry-${{ matrix.target }}

upload:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Upload binaries to release
env:
GH_TOKEN: ${{ github.token }}
run: |
for f in artifacts/*; do
gh release upload "${{ github.event.release.tag_name }}" "$f" --repo "${{ github.repository }}"
done

update-tap:
needs: upload
runs-on: ubuntu-latest
steps:
- name: Update Homebrew tap
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
BASE_URL="https://github.com/${{ github.repository }}/releases/download/${{ github.event.release.tag_name }}"

SHA_ARM64=$(curl -sL "$BASE_URL/dualentry-macos-arm64" | shasum -a 256 | cut -d' ' -f1)
SHA_X86_64=$(curl -sL "$BASE_URL/dualentry-macos-x86_64" | shasum -a 256 | cut -d' ' -f1)
SHA_LINUX=$(curl -sL "$BASE_URL/dualentry-linux-x86_64" | shasum -a 256 | cut -d' ' -f1)

cat > /tmp/dualentry.rb << FORMULA
class Dualentry < Formula
desc "DualEntry accounting CLI"
homepage "https://github.com/${{ github.repository }}"
version "$VERSION"

on_macos do
if Hardware::CPU.arm?
url "$BASE_URL/dualentry-macos-arm64"
sha256 "$SHA_ARM64"
else
url "$BASE_URL/dualentry-macos-x86_64"
sha256 "$SHA_X86_64"
end
end

on_linux do
url "$BASE_URL/dualentry-linux-x86_64"
sha256 "$SHA_LINUX"
end

def install
binary = Dir["dualentry-*"].first || "dualentry"
bin.install binary => "dualentry"
end

test do
assert_match "dualentry-cli", shell_output("#{bin}/dualentry --version")
end
end
FORMULA

git clone "https://x-access-token:${TAP_TOKEN}@github.com/dualentry/homebrew-tap.git" /tmp/tap
mkdir -p /tmp/tap/Formula
cp /tmp/dualentry.rb /tmp/tap/Formula/dualentry.rb
cd /tmp/tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/dualentry.rb
git commit -m "Update dualentry to $VERSION"
git push
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist/
build/
.eggs/
uv.lock
*.spec
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changelog

## [0.1.0] - 2026-03-31

- OAuth browser login with API key storage in system keychain
- List, get, create, and update for all transaction types
- Table and JSON output formats
- Pagination, search, and date/status filtering
- Homebrew tap and install script distribution
- Auto-update checker
70 changes: 49 additions & 21 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,28 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
#!/bin/sh
set -e

# DualEntry CLI installer
# Usage: curl -sSL <raw-url>/install.sh | bash
REPO="dualentry/dualentry-cli"
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"

REPO="git+https://github.com/dualentry/dualentry-cli.git"
TOOL_NAME="dualentry-cli"
get_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "x86_64" ;;
arm64|aarch64) echo "arm64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
}

echo "Installing DualEntry CLI..."
get_os() {
case "$(uname -s)" in
Darwin) echo "macos" ;;
Linux) echo "linux" ;;
*) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
esac
}

# Prefer uv, fall back to pipx
if command -v uv &>/dev/null; then
echo "Using uv..."
uv tool install "$REPO"
elif command -v pipx &>/dev/null; then
echo "Using pipx..."
pipx install "$REPO"
OS=$(get_os)
ARCH=$(get_arch)
TARGET="${OS}-${ARCH}"

if [ "$OS" = "linux" ] && [ "$ARCH" = "arm64" ]; then
echo "Linux arm64 is not currently supported." >&2
exit 1
fi

echo "Detecting latest release..."
LATEST=$(curl -sI "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/.*tag\///' | tr -d '\r')

if [ -z "$LATEST" ]; then
echo "Failed to detect latest release." >&2
exit 1
fi

URL="https://github.com/${REPO}/releases/download/${LATEST}/dualentry-${TARGET}"

echo "Downloading dualentry ${LATEST} for ${TARGET}..."
curl -fSL "$URL" -o /tmp/dualentry

chmod +x /tmp/dualentry
mkdir -p "$INSTALL_DIR"

if [ -w "$INSTALL_DIR" ]; then
mv /tmp/dualentry "$INSTALL_DIR/dualentry"
else
echo "Error: requires uv or pipx"
echo ""
echo "Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh"
echo "Install pipx: brew install pipx && pipx ensurepath"
exit 1
echo "Installing to ${INSTALL_DIR} (requires sudo)..."
sudo mv /tmp/dualentry "$INSTALL_DIR/dualentry"
fi

echo ""
echo "Installed! Run: dualentry auth login"
echo "Installed dualentry to ${INSTALL_DIR}/dualentry"
echo "Run 'dualentry --help' to get started."
13 changes: 3 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,6 @@ packages = ["src/dualentry_cli"]
[tool.pytest.ini_options]
testpaths = ["tests"]

[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=6.0",
"pytest-mock>=3.14",
"respx>=0.21",
"ruff>=0.11.11",
"pre-commit>=4.2",
]

[dependency-groups]
dev = [
"pytest>=8.0",
Expand All @@ -41,6 +31,7 @@ dev = [
"respx>=0.21",
"ruff>=0.11.11",
"pre-commit>=4.2",
"pyinstaller>=6.0",
]

# ── Ruff ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -120,12 +111,14 @@ ignore = [
"T201",
"PLW0603",
"PLW2901",
"PLR0912",
"PLR0915",
"F841",
"SIM105",
]

[tool.ruff.lint.per-file-ignores]
"scripts/*" = ["INP001", "S603"]
"tests/*" = [
"S101",
"S105",
Expand Down
48 changes: 48 additions & 0 deletions scripts/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Build standalone binary for DualEntry CLI using PyInstaller."""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent

BUILD_INFO = ROOT / "src" / "dualentry_cli" / "_build_info.py"

PROD_BUILD_INFO = '''\
"""Build-time configuration — generated by CI."""

BUILD_MODE = "prod"
DEFAULT_API_URL = "https://api.dualentry.com"
'''


def main():
original = BUILD_INFO.read_text()
try:
BUILD_INFO.write_text(PROD_BUILD_INFO)

subprocess.run(
[
sys.executable,
"-m",
"PyInstaller",
"--onefile",
"--name",
"dualentry",
"--strip",
"--noconfirm",
str(ROOT / "src" / "dualentry_cli" / "main.py"),
],
cwd=str(ROOT),
check=True,
)

print(f"\nBinary built: {ROOT / 'dist' / 'dualentry'}")
finally:
BUILD_INFO.write_text(original)


if __name__ == "__main__":
main()
4 changes: 4 additions & 0 deletions src/dualentry_cli/_build_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Build-time configuration."""

BUILD_MODE = "prod"
DEFAULT_API_URL = "https://api.dualentry.com"
Loading
Loading