diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6a5bfe8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: release + +on: + workflow_dispatch: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + package: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + + - name: Build release package + run: make release-package + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: flow-python-release + path: | + dist/flow-*-python.tar.gz + dist/flow-*-python.tar.gz.sha256 + if-no-files-found: error + + - name: Publish release assets + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" dist/flow-*-python.tar.gz dist/flow-*-python.tar.gz.sha256 --clobber + else + gh release create "$TAG" dist/flow-*-python.tar.gz dist/flow-*-python.tar.gz.sha256 --title "$TAG" --generate-notes + fi diff --git a/Makefile b/Makefile index 19ea22e..b8fb31b 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,11 @@ BUILD_DIR := build SPEC_FILE := flow.spec BINARY := $(DIST_DIR)/flow INSTALL_DIR ?= $(HOME)/.local/bin +VERSION := $(shell sed -n 's/^__version__ = "\(.*\)"/\1/p' src/flow/__init__.py) +RELEASE_ROOT := $(BUILD_DIR)/flow-$(VERSION)-python +RELEASE_ARCHIVE := $(DIST_DIR)/flow-$(VERSION)-python.tar.gz -.PHONY: help deps test test-e2e check package build binary install install-local check-binary clean distclean +.PHONY: help deps test test-e2e check package release-package build binary install install-local check-binary clean distclean help: @printf "Targets:\n" @@ -16,6 +19,7 @@ help: @printf " make test-e2e Run Docker/Podman e2e tests (FLOW_RUN_E2E=1)\n" @printf " make check Run unit tests and CLI smoke check\n" @printf " make package Build wheel and sdist into dist/\n" + @printf " make release-package Build installable Python release tarball\n" @printf " make build Build standalone binary at dist/flow\n" @printf " make install Build and install binary to ~/.local/bin/flow\n" @printf " make check-binary Run dist/flow --help\n" @@ -38,6 +42,16 @@ check: test package: deps $(UV) build +release-package: deps + rm -rf "$(RELEASE_ROOT)" "$(RELEASE_ARCHIVE)" "$(RELEASE_ARCHIVE).sha256" + mkdir -p "$(RELEASE_ROOT)/wheelhouse" + $(UV) build + cp dist/flow-$(VERSION)-py3-none-any.whl "$(RELEASE_ROOT)/wheelhouse/" + cp packaging/install.sh "$(RELEASE_ROOT)/install.sh" + chmod 755 "$(RELEASE_ROOT)/install.sh" + tar -czf "$(RELEASE_ARCHIVE)" -C "$(BUILD_DIR)" "flow-$(VERSION)-python" + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$(RELEASE_ARCHIVE)" > "$(RELEASE_ARCHIVE).sha256"; else shasum -a 256 "$(RELEASE_ARCHIVE)" > "$(RELEASE_ARCHIVE).sha256"; fi + build binary: deps $(UV) run pyinstaller --noconfirm --clean --onefile --name flow --paths "$(SRC_DIR)" "$(ENTRYPOINT)" diff --git a/README.md b/README.md index 0a655cd..14a7e7c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,32 @@ completion. ## Quick Start -From the repository: +Install from a release tarball (no `uv`, no `pipx`): + +```bash +VERSION=0.1.0 +REPO=OWNER/REPO + +curl -fsSL "https://github.com/${REPO}/releases/download/v${VERSION}/flow-${VERSION}-python.tar.gz" \ + -o "/tmp/flow-${VERSION}-python.tar.gz" +curl -fsSL "https://github.com/${REPO}/releases/download/v${VERSION}/flow-${VERSION}-python.tar.gz.sha256" \ + -o "/tmp/flow-${VERSION}-python.tar.gz.sha256" +sha256sum -c "/tmp/flow-${VERSION}-python.tar.gz.sha256" +tar -xzf "/tmp/flow-${VERSION}-python.tar.gz" -C /tmp +/tmp/flow-${VERSION}-python/install.sh +``` + +Alternative one-shot path with `curl` and shell: + +```bash +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT +curl -fsSL "https://github.com/${REPO}/releases/download/v${VERSION}/flow-${VERSION}-python.tar.gz" \ + | tar -xz -C "$tmpdir" +sh "${tmpdir}/flow-${VERSION}-python/install.sh" +``` + +From the repository (development): ```bash uv sync --locked --extra dev --extra build @@ -23,13 +48,6 @@ flow setup show linux-work flow setup run linux-work --dry-run ``` -Install the CLI as a local uv tool: - -```bash -uv tool install --force . -flow --help -``` - Build a standalone binary: ```bash @@ -39,8 +57,42 @@ make install # installs dist/flow to ~/.local/bin/flow ``` `build/`, `dist/`, and `flow.spec` are generated by packaging/binary builds and -are ignored. `.venv/` is the uv-managed local environment. `venv/` is legacy -local clutter and should not be used. +are ignored. `.venv/` and `venv/` are local virtual environments and are also +ignored. + +## Distribution + +The primary release artifact is a Python tarball published from GitHub Releases: + +```text +flow--python.tar.gz +flow--python.tar.gz.sha256 +``` + +The tarball includes `install.sh` plus the `flow` wheel. End users need +`python3` with `venv`; they do not need `uv` or `pipx`. The installer uses +`pip` inside the isolated venv to install `flow` and resolve Python +dependencies. + +Default install locations: + +- app environment: `~/.local/share/flow/venv` +- command shim: `~/.local/bin/flow` + +Overrides: + +```bash +PYTHON=/path/to/python3 FLOW_INSTALL_ROOT=~/.flow FLOW_BIN_DIR=~/bin ./install.sh +``` + +Release build: + +```bash +make release-package +``` + +Native one-file binaries remain optional convenience artifacts. They are +platform-specific; the Python release tarball is the portable default. ## Command Surface @@ -71,7 +123,6 @@ flow setup run [PROFILE|--profile NAME] [--dry-run] [--var KEY=VALUE] # Remote targets flow remote list flow remote enter TARGET [--user USER] [--namespace NAME] [--platform NAME] [--session NAME] [--no-tmux] [--dry-run] -flow enter TARGET # Dev containers flow dev create NAME --image IMAGE [--project PATH] [--dry-run] @@ -87,7 +138,7 @@ flow dev list flow projects check [--fetch] flow projects fetch flow projects summary -flow sync +flow projects sync # Shell completion flow completion zsh @@ -104,9 +155,8 @@ Aliases: - `dotfiles` -> `dot` - `dotfiles repos` -> `dotfiles repo` - `packages` -> `package`, `pkg` -- `projects` -> `project`; `flow sync` -> `flow projects check --fetch` +- `projects` -> `project` - `setup` -> `bootstrap`, `provision` -- `remote enter` -> `enter` - `dev attach` -> `dev connect` - `dev remove` -> `dev rm` @@ -240,6 +290,7 @@ make test # unit tests, excluding e2e make test-e2e # requires Docker or healthy Podman make check # tests plus CLI smoke checks make package # wheel and sdist in dist/ +make release-package # installable GitHub Release tarball make build # PyInstaller binary in dist/flow make clean # remove build/test artifacts make distclean # also remove .venv/ and venv/ diff --git a/docs/architecture.md b/docs/architecture.md index 2edb701..480499b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,10 +68,8 @@ flow dotfiles repos list|status|pull|push flow packages install|remove|list flow setup run|show|list flow remote enter|list -flow enter flow dev create|attach|connect|exec|enter|stop|remove|rm|respawn|list -flow projects check|fetch|summary -flow sync +flow projects check|fetch|summary|sync flow completion zsh|install-zsh ``` diff --git a/packaging/install.sh b/packaging/install.sh new file mode 100644 index 0000000..b3bd21f --- /dev/null +++ b/packaging/install.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env sh +set -eu + +PYTHON_BIN="${PYTHON:-python3}" +INSTALL_ROOT="${FLOW_INSTALL_ROOT:-$HOME/.local/share/flow}" +BIN_DIR="${FLOW_BIN_DIR:-$HOME/.local/bin}" +VENV_DIR="$INSTALL_ROOT/venv" + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +WHEELHOUSE="$SCRIPT_DIR/wheelhouse" + +if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then + echo "python3 is required. Set PYTHON=/path/to/python3 to override." >&2 + exit 1 +fi + +if ! "$PYTHON_BIN" -m venv --help >/dev/null 2>&1; then + echo "python3 was built without the venv module. Install Python's venv package." >&2 + exit 1 +fi + +if ! "$PYTHON_BIN" -m pip --version >/dev/null 2>&1; then + echo "python3 pip module is required. Install pip for the selected python." >&2 + exit 1 +fi + +if [ ! -d "$WHEELHOUSE" ]; then + echo "wheelhouse not found: $WHEELHOUSE" >&2 + exit 1 +fi + +FLOW_WHEELS_COUNT=0 +FLOW_WHEEL="" +for candidate in "$WHEELHOUSE"/flow-*.whl; do + if [ -f "$candidate" ]; then + FLOW_WHEELS_COUNT=$((FLOW_WHEELS_COUNT + 1)) + if [ -z "$FLOW_WHEEL" ]; then + FLOW_WHEEL="$candidate" + fi + fi +done + +if [ "$FLOW_WHEELS_COUNT" -eq 0 ]; then + echo "flow wheel not found in $WHEELHOUSE" >&2 + exit 1 +fi + +if [ "$FLOW_WHEELS_COUNT" -gt 1 ]; then + echo "multiple flow wheels found in $WHEELHOUSE. Ensure exactly one release wheel is present." >&2 + exit 1 +fi + +mkdir -p "$INSTALL_ROOT" +"$PYTHON_BIN" -m venv "$VENV_DIR" + +if ! "$VENV_DIR/bin/python" -m pip --version >/dev/null 2>&1; then + echo "Created venv does not expose pip. Ensure Python's ensurepip is available." >&2 + exit 1 +fi + +"$VENV_DIR/bin/python" -m pip install --no-cache-dir --upgrade "$FLOW_WHEEL" + +mkdir -p "$BIN_DIR" +cat > "$BIN_DIR/flow" < str: "package": "packages", "pkg": "packages", "project": "projects", - "sync": "projects", } return aliases.get(command, command) @@ -330,7 +329,7 @@ def _complete_packages(ctx: FlowContext, before: Sequence[str], current: str) -> def _complete_projects(before: Sequence[str], current: str) -> list[str]: if len(before) <= 1: - return _filter(["check", "fetch", "summary"], current) + return _filter(["check", "fetch", "summary", "sync"], current) if before[1] == "check" and current.startswith("-"): return _filter(["--fetch", "--no-fetch"], current) return [] diff --git a/src/flow/cli.py b/src/flow/cli.py index c9fd9e7..95ae7f9 100644 --- a/src/flow/cli.py +++ b/src/flow/cli.py @@ -2,11 +2,14 @@ from __future__ import annotations +import copy import os import sys +from collections import OrderedDict from typing import Optional import typer +from typer.main import TyperGroup from flow import __version__ from flow.core import paths @@ -23,21 +26,114 @@ from flow.app.projects import ProjectService from flow.app.remote import RemoteService +_HELP_CONTEXT = {"help_option_names": ["-h", "--help"]} + +_ALIASED_COMMANDS: dict[str, str] = { + "dot": "dotfiles", + "package": "packages", + "pkg": "packages", + "bootstrap": "setup", + "provision": "setup", + "repo": "repos", + "project": "projects", +} + + +class _AliasGroupingTyperGroup(TyperGroup): + """Render aliased commands as a single help entry.""" + + def _grouped_command_names(self, ctx: typer.Context) -> OrderedDict[str, str]: + grouped: OrderedDict[str, list[str]] = OrderedDict() + for subcommand in super().list_commands(ctx): + cmd = super().get_command(ctx, subcommand) + if cmd is None or cmd.hidden: + continue + canonical_name = _ALIASED_COMMANDS.get(subcommand, subcommand) + grouped.setdefault(canonical_name, []).append(subcommand) + + commands: OrderedDict[str, str] = OrderedDict() + for canonical_name, subcommands in grouped.items(): + aliases = sorted(name for name in set(subcommands) if name != canonical_name) + display = ", ".join([canonical_name] + aliases) + commands[display] = canonical_name + + # Keep insertion order by canonical key + return commands + + def list_commands(self, ctx: typer.Context) -> list[str]: + return list(self._grouped_command_names(ctx).keys()) + + def get_command(self, ctx: typer.Context, cmd_name: str) -> object | None: + grouped = self._grouped_command_names(ctx) + canonical_name = grouped.get(cmd_name) + if canonical_name is None: + return super().get_command(ctx, cmd_name) + + command = super().get_command(ctx, canonical_name) + if command is None: + return None + if "," in cmd_name: + command = copy.copy(command) + command.name = cmd_name + return command + app = typer.Typer( + cls=_AliasGroupingTyperGroup, name="flow", help="DevFlow - development environment manager", no_args_is_help=True, add_completion=False, pretty_exceptions_show_locals=False, + context_settings=_HELP_CONTEXT, +) +dotfiles_app = typer.Typer( + cls=_AliasGroupingTyperGroup, + help="Manage dotfile symlinks", + no_args_is_help=False, + context_settings=_HELP_CONTEXT, +) +dotfiles_repos_app = typer.Typer( + cls=_AliasGroupingTyperGroup, + help="Manage dotfiles and module repos", + no_args_is_help=False, + context_settings=_HELP_CONTEXT, +) +packages_app = typer.Typer( + cls=_AliasGroupingTyperGroup, + help="Manage packages", + no_args_is_help=False, + context_settings=_HELP_CONTEXT, +) +setup_app = typer.Typer( + cls=_AliasGroupingTyperGroup, + help="Bootstrap a system profile", + no_args_is_help=False, + context_settings=_HELP_CONTEXT, +) +remote_app = typer.Typer( + cls=_AliasGroupingTyperGroup, + help="Manage remote targets", + no_args_is_help=False, + context_settings=_HELP_CONTEXT, +) +dev_app = typer.Typer( + cls=_AliasGroupingTyperGroup, + help="Manage development containers", + no_args_is_help=False, + context_settings=_HELP_CONTEXT, +) +projects_app = typer.Typer( + cls=_AliasGroupingTyperGroup, + help="Manage git projects", + no_args_is_help=False, + context_settings=_HELP_CONTEXT, +) +completion_app = typer.Typer( + cls=_AliasGroupingTyperGroup, + help="Shell completion helpers", + no_args_is_help=False, + context_settings=_HELP_CONTEXT, ) -dotfiles_app = typer.Typer(help="Manage dotfile symlinks", no_args_is_help=False) -dotfiles_repos_app = typer.Typer(help="Manage dotfiles and module repos", no_args_is_help=False) -packages_app = typer.Typer(help="Manage packages", no_args_is_help=False) -setup_app = typer.Typer(help="Bootstrap a system profile", no_args_is_help=False) -remote_app = typer.Typer(help="Manage remote targets", no_args_is_help=False) -dev_app = typer.Typer(help="Manage development containers", no_args_is_help=False) -projects_app = typer.Typer(help="Manage git projects", no_args_is_help=False) -completion_app = typer.Typer(help="Shell completion helpers", no_args_is_help=False) def _version_callback(value: bool) -> None: @@ -52,6 +148,7 @@ def _root( version: bool = typer.Option( False, "--version", + "-v", help="Show version", callback=_version_callback, is_eager=True, @@ -342,20 +439,6 @@ def remote_list(ctx: typer.Context) -> None: _run(ctx, lambda flow_ctx: RemoteService(flow_ctx).list()) -@app.command("enter") -def enter_alias( - ctx: typer.Context, - target: str = typer.Argument(..., help="Target ([user@]namespace@platform)"), - user: Optional[str] = typer.Option(None, "--user", "-u", help="SSH user override"), - namespace: Optional[str] = typer.Option(None, "--namespace", "-n", help="Namespace override"), - platform: Optional[str] = typer.Option(None, "--platform", "-p", help="Platform override"), - session: Optional[str] = typer.Option(None, "--session", "-s", help="tmux session name"), - no_tmux: bool = typer.Option(False, "--no-tmux", help="Open plain SSH without tmux"), - dry_run: bool = typer.Option(False, "--dry-run", "-d"), -) -> None: - _remote_enter(ctx, target, user, namespace, platform, session, no_tmux, dry_run) - - @dev_app.callback(invoke_without_command=True) def dev_default(ctx: typer.Context) -> None: if ctx.invoked_subcommand is None: @@ -462,12 +545,9 @@ def projects_summary(ctx: typer.Context) -> None: _run(ctx, lambda flow_ctx: ProjectService(flow_ctx).summary()) -@app.command("sync") -def sync_alias( - ctx: typer.Context, - fetch: bool = typer.Option(True, "--fetch/--no-fetch", help="Fetch remotes first"), -) -> None: - _projects_check(ctx, fetch) +@projects_app.command("sync") +def projects_sync(ctx: typer.Context) -> None: + _projects_check(ctx, fetch=True) @completion_app.callback(invoke_without_command=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index 36ad7cc..991c0c5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -40,6 +40,38 @@ def test_help_flag(): assert "setup" in result.stdout +def test_short_help_flag(): + """Test -h shows commands.""" + result = subprocess.run( + [sys.executable, "-m", "flow", "-h"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "dotfiles" in result.stdout + assert "packages" in result.stdout + assert "setup" in result.stdout + + +def test_help_groups_aliases(): + result = subprocess.run( + [sys.executable, "-m", "flow", "--help"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + lines = [line for line in result.stdout.splitlines() if "│" in line] + + dotfiles_line = next(line for line in lines if "dotfiles" in line and "dot" in line) + assert "dotfiles, dot" in dotfiles_line + assert next(line for line in lines if "packages" in line and "package" in line).startswith("│") + packages_line = next(line for line in lines if "packages" in line and "pkg" in line) + assert "packages, package, pkg" in packages_line + setup_line = next(line for line in lines if "setup" in line and "bootstrap" in line) + assert "setup, bootstrap, provision" in setup_line + projects_line = next(line for line in lines if "projects" in line and "project" in line) + assert "projects, project" in projects_line + + def test_no_color_flag(): """Test --no-color flag is accepted.""" result = subprocess.run( @@ -64,6 +96,10 @@ def test_dotfiles_help(): assert "sync" not in result.stdout assert "modules" not in result.stdout + repos_lines = [line for line in result.stdout.splitlines() if "repos" in line] + assert len(repos_lines) == 1 + assert "repos, repo" in repos_lines[0] + def test_packages_help(): result = subprocess.run(