This commit is contained in:
2026-05-14 16:19:15 +03:00
parent 4ce98d0ff1
commit 8ae59e40b2
8 changed files with 349 additions and 48 deletions

52
.github/workflows/release.yml vendored Normal file
View File

@@ -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

View File

@@ -6,8 +6,11 @@ BUILD_DIR := build
SPEC_FILE := flow.spec SPEC_FILE := flow.spec
BINARY := $(DIST_DIR)/flow BINARY := $(DIST_DIR)/flow
INSTALL_DIR ?= $(HOME)/.local/bin 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: help:
@printf "Targets:\n" @printf "Targets:\n"
@@ -16,6 +19,7 @@ help:
@printf " make test-e2e Run Docker/Podman e2e tests (FLOW_RUN_E2E=1)\n" @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 check Run unit tests and CLI smoke check\n"
@printf " make package Build wheel and sdist into dist/\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 build Build standalone binary at dist/flow\n"
@printf " make install Build and install binary to ~/.local/bin/flow\n" @printf " make install Build and install binary to ~/.local/bin/flow\n"
@printf " make check-binary Run dist/flow --help\n" @printf " make check-binary Run dist/flow --help\n"
@@ -38,6 +42,16 @@ check: test
package: deps package: deps
$(UV) build $(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 build binary: deps
$(UV) run pyinstaller --noconfirm --clean --onefile --name flow --paths "$(SRC_DIR)" "$(ENTRYPOINT)" $(UV) run pyinstaller --noconfirm --clean --onefile --name flow --paths "$(SRC_DIR)" "$(ENTRYPOINT)"

View File

@@ -6,7 +6,32 @@ completion.
## Quick Start ## 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 ```bash
uv sync --locked --extra dev --extra build uv sync --locked --extra dev --extra build
@@ -23,13 +48,6 @@ flow setup show linux-work
flow setup run linux-work --dry-run 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: Build a standalone binary:
```bash ```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 `build/`, `dist/`, and `flow.spec` are generated by packaging/binary builds and
are ignored. `.venv/` is the uv-managed local environment. `venv/` is legacy are ignored. `.venv/` and `venv/` are local virtual environments and are also
local clutter and should not be used. ignored.
## Distribution
The primary release artifact is a Python tarball published from GitHub Releases:
```text
flow-<version>-python.tar.gz
flow-<version>-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 ## Command Surface
@@ -71,7 +123,6 @@ flow setup run [PROFILE|--profile NAME] [--dry-run] [--var KEY=VALUE]
# Remote targets # Remote targets
flow remote list flow remote list
flow remote enter TARGET [--user USER] [--namespace NAME] [--platform NAME] [--session NAME] [--no-tmux] [--dry-run] flow remote enter TARGET [--user USER] [--namespace NAME] [--platform NAME] [--session NAME] [--no-tmux] [--dry-run]
flow enter TARGET
# Dev containers # Dev containers
flow dev create NAME --image IMAGE [--project PATH] [--dry-run] flow dev create NAME --image IMAGE [--project PATH] [--dry-run]
@@ -87,7 +138,7 @@ flow dev list
flow projects check [--fetch] flow projects check [--fetch]
flow projects fetch flow projects fetch
flow projects summary flow projects summary
flow sync flow projects sync
# Shell completion # Shell completion
flow completion zsh flow completion zsh
@@ -104,9 +155,8 @@ Aliases:
- `dotfiles` -> `dot` - `dotfiles` -> `dot`
- `dotfiles repos` -> `dotfiles repo` - `dotfiles repos` -> `dotfiles repo`
- `packages` -> `package`, `pkg` - `packages` -> `package`, `pkg`
- `projects` -> `project`; `flow sync` -> `flow projects check --fetch` - `projects` -> `project`
- `setup` -> `bootstrap`, `provision` - `setup` -> `bootstrap`, `provision`
- `remote enter` -> `enter`
- `dev attach` -> `dev connect` - `dev attach` -> `dev connect`
- `dev remove` -> `dev rm` - `dev remove` -> `dev rm`
@@ -240,6 +290,7 @@ make test # unit tests, excluding e2e
make test-e2e # requires Docker or healthy Podman make test-e2e # requires Docker or healthy Podman
make check # tests plus CLI smoke checks make check # tests plus CLI smoke checks
make package # wheel and sdist in dist/ make package # wheel and sdist in dist/
make release-package # installable GitHub Release tarball
make build # PyInstaller binary in dist/flow make build # PyInstaller binary in dist/flow
make clean # remove build/test artifacts make clean # remove build/test artifacts
make distclean # also remove .venv/ and venv/ make distclean # also remove .venv/ and venv/

View File

@@ -68,10 +68,8 @@ flow dotfiles repos list|status|pull|push
flow packages install|remove|list flow packages install|remove|list
flow setup run|show|list flow setup run|show|list
flow remote enter|list flow remote enter|list
flow enter
flow dev create|attach|connect|exec|enter|stop|remove|rm|respawn|list flow dev create|attach|connect|exec|enter|stop|remove|rm|respawn|list
flow projects check|fetch|summary flow projects check|fetch|summary|sync
flow sync
flow completion zsh|install-zsh flow completion zsh|install-zsh
``` ```

71
packaging/install.sh Normal file
View File

@@ -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" <<EOF
#!/usr/bin/env sh
exec "$VENV_DIR/bin/flow" "\$@"
EOF
chmod 755 "$BIN_DIR/flow"
echo "Installed flow to $BIN_DIR/flow"
echo "Run: $BIN_DIR/flow --version"

View File

@@ -71,7 +71,6 @@ def _canonical_command(command: str) -> str:
"package": "packages", "package": "packages",
"pkg": "packages", "pkg": "packages",
"project": "projects", "project": "projects",
"sync": "projects",
} }
return aliases.get(command, command) 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]: def _complete_projects(before: Sequence[str], current: str) -> list[str]:
if len(before) <= 1: if len(before) <= 1:
return _filter(["check", "fetch", "summary"], current) return _filter(["check", "fetch", "summary", "sync"], current)
if before[1] == "check" and current.startswith("-"): if before[1] == "check" and current.startswith("-"):
return _filter(["--fetch", "--no-fetch"], current) return _filter(["--fetch", "--no-fetch"], current)
return [] return []

View File

@@ -2,11 +2,14 @@
from __future__ import annotations from __future__ import annotations
import copy
import os import os
import sys import sys
from collections import OrderedDict
from typing import Optional from typing import Optional
import typer import typer
from typer.main import TyperGroup
from flow import __version__ from flow import __version__
from flow.core import paths from flow.core import paths
@@ -23,21 +26,114 @@ from flow.app.projects import ProjectService
from flow.app.remote import RemoteService 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( app = typer.Typer(
cls=_AliasGroupingTyperGroup,
name="flow", name="flow",
help="DevFlow - development environment manager", help="DevFlow - development environment manager",
no_args_is_help=True, no_args_is_help=True,
add_completion=False, add_completion=False,
pretty_exceptions_show_locals=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: def _version_callback(value: bool) -> None:
@@ -52,6 +148,7 @@ def _root(
version: bool = typer.Option( version: bool = typer.Option(
False, False,
"--version", "--version",
"-v",
help="Show version", help="Show version",
callback=_version_callback, callback=_version_callback,
is_eager=True, is_eager=True,
@@ -342,20 +439,6 @@ def remote_list(ctx: typer.Context) -> None:
_run(ctx, lambda flow_ctx: RemoteService(flow_ctx).list()) _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) @dev_app.callback(invoke_without_command=True)
def dev_default(ctx: typer.Context) -> None: def dev_default(ctx: typer.Context) -> None:
if ctx.invoked_subcommand is 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()) _run(ctx, lambda flow_ctx: ProjectService(flow_ctx).summary())
@app.command("sync") @projects_app.command("sync")
def sync_alias( def projects_sync(ctx: typer.Context) -> None:
ctx: typer.Context, _projects_check(ctx, fetch=True)
fetch: bool = typer.Option(True, "--fetch/--no-fetch", help="Fetch remotes first"),
) -> None:
_projects_check(ctx, fetch)
@completion_app.callback(invoke_without_command=True) @completion_app.callback(invoke_without_command=True)

View File

@@ -40,6 +40,38 @@ def test_help_flag():
assert "setup" in result.stdout 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(): def test_no_color_flag():
"""Test --no-color flag is accepted.""" """Test --no-color flag is accepted."""
result = subprocess.run( result = subprocess.run(
@@ -64,6 +96,10 @@ def test_dotfiles_help():
assert "sync" not in result.stdout assert "sync" not in result.stdout
assert "modules" 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(): def test_packages_help():
result = subprocess.run( result = subprocess.run(