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
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)"

View File

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

View File

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

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",
"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 []

View File

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

View File

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