This commit is contained in:
2026-02-13 02:13:27 +02:00
parent 906adb539d
commit e35f9651c1
78 changed files with 200 additions and 108 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.pyo
dist/
build/
*.spec
*.egg-info/
.pytest_cache/

View File

@@ -1,6 +1,6 @@
PYTHON ?= python3
PROJECT_ROOT := $(abspath ..)
ENTRYPOINT := __main__.py
SRC_DIR := $(CURDIR)/src
ENTRYPOINT := src/flow/__main__.py
DIST_DIR := dist
BUILD_DIR := build
SPEC_FILE := flow.spec
@@ -17,7 +17,7 @@ help:
@printf " make clean Remove build artifacts\n"
build:
$(PYTHON) -m PyInstaller --noconfirm --clean --onefile --name flow --paths "$(PROJECT_ROOT)" "$(ENTRYPOINT)"
$(PYTHON) -m PyInstaller --noconfirm --clean --onefile --name flow --paths "$(SRC_DIR)" "$(ENTRYPOINT)"
install-local: build
mkdir -p "$(INSTALL_DIR)"

View File

@@ -1,16 +1,15 @@
# flow
`flow` is a CLI for managing development instances, containers, dotfiles,
bootstrap profiles, and binary packages.
`flow` is a CLI for managing development instances, containers, dotfiles, bootstrap profiles, and
binary packages.
This repository contains the Python implementation of the tool and its command
modules.
This repository contains the Python implementation of the tool and its command modules.
## What is implemented
- Instance access via `flow enter`
- Container lifecycle commands under `flow dev` (`create`, `exec`, `connect`,
`list`, `stop`, `remove`, `respawn`)
- Container lifecycle commands under `flow dev` (`create`, `exec`, `connect`, `list`, `stop`,
`remove`, `respawn`)
- Dotfiles management (`dotfiles` / `dot`)
- Bootstrap planning and execution (`bootstrap` / `setup` / `provision`)
- Binary package installation from manifest definitions (`package` / `pkg`)
@@ -113,6 +112,9 @@ flow enter root@personal@orb
flow enter personal@orb --dry-run
```
If your local terminal uses `xterm-ghostty` or `wezterm`, `flow enter` shows a terminfo warning and
a manual fix command before connecting. `flow` never installs terminfo on the target automatically.
### Containers
```bash
@@ -184,14 +186,13 @@ Passing an explicit file path to internal loaders bypasses this cascade.
## State format policy
`flow` currently supports only the v2 dotfiles link state format
(`linked.json`). Older state formats are intentionally not supported.
`flow` currently supports only the v2 dotfiles link state format (`linked.json`). Older state
formats are intentionally not supported.
## CLI behavior
- User errors return non-zero exit codes.
- External command failures are surfaced as concise one-line errors (no
traceback spam).
- External command failures are surfaced as concise one-line errors (no traceback spam).
- `Ctrl+C` exits with code `130`.
## Zsh completion
@@ -216,9 +217,8 @@ fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinit
```
Completion is dynamic and pulls values from your current config/manifest/state
(for example bootstrap profiles, package names, dotfiles packages, and
configured `enter` targets).
Completion is dynamic and pulls values from your current config/manifest/state (for example
bootstrap profiles, package names, dotfiles packages, and configured `enter` targets).
## Development
@@ -236,22 +236,16 @@ Useful targets:
make clean
```
Run a syntax check:
```bash
python3 -m compileall .
```
Run tests (when `pytest` is available):
Run tests:
```bash
python3 -m pytest
```
Optional local venv setup:
Local development setup:
```bash
python3 -m venv .venv
.venv/bin/pip install -U pip pytest pyyaml
PYTHONPATH=/path/to/src .venv/bin/pytest
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -4,16 +4,23 @@ build-backend = "hatchling.build"
[project]
name = "flow"
version = "0.1.0"
dynamic = ["version"]
description = "DevFlow - A unified toolkit for managing development instances, containers, and profiles"
requires-python = ">=3.9"
dependencies = ["pyyaml>=6.0"]
[project.optional-dependencies]
build = ["pyinstaller>=6.0"]
dev = ["pytest>=7.0"]
[project.scripts]
flow = "flow.cli:main"
[tool.hatch.version]
path = "src/flow/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/flow"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@@ -1,8 +1,9 @@
"""flow bootstrap — environment provisioning with plan-then-execute model."""
import argparse
import os
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List
@@ -209,16 +210,16 @@ def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: di
raise RuntimeError(f"Required variable not set: {var}")
def handle_set_hostname(data):
hostname = data["hostname"]
hostname = shlex.quote(data["hostname"])
if ctx.platform.os == "macos":
run_command(f"sudo scutil --set ComputerName '{hostname}'", ctx.console)
run_command(f"sudo scutil --set HostName '{hostname}'", ctx.console)
run_command(f"sudo scutil --set LocalHostName '{hostname}'", ctx.console)
run_command(f"sudo scutil --set ComputerName {hostname}", ctx.console)
run_command(f"sudo scutil --set HostName {hostname}", ctx.console)
run_command(f"sudo scutil --set LocalHostName {hostname}", ctx.console)
else:
run_command(f"sudo hostnamectl set-hostname '{hostname}'", ctx.console)
run_command(f"sudo hostnamectl set-hostname {hostname}", ctx.console)
def handle_set_locale(data):
locale = data["locale"]
locale = shlex.quote(data["locale"])
run_command(f"sudo locale-gen {locale}", ctx.console)
run_command(f"sudo update-locale LANG={locale}", ctx.console)
@@ -227,13 +228,14 @@ def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: di
shell_path = shutil.which(shell)
if not shell_path:
raise RuntimeError(f"Shell not found: {shell}")
quoted_path = shlex.quote(shell_path)
try:
with open("/etc/shells") as f:
if shell_path not in f.read():
run_command(f"echo '{shell_path}' | sudo tee -a /etc/shells", ctx.console)
run_command(f"echo {quoted_path} | sudo tee -a /etc/shells", ctx.console)
except FileNotFoundError:
pass
run_command(f"chsh -s {shell_path}", ctx.console)
run_command(f"chsh -s {quoted_path}", ctx.console)
def handle_pm_update(data):
pm = data["pm"]
@@ -249,7 +251,7 @@ def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: di
pm = data["pm"]
packages = data["packages"]
pkg_type = data.get("type", "standard")
pkg_str = " ".join(packages)
pkg_str = " ".join(shlex.quote(p) for p in packages)
if pm in ("apt-get", "apt"):
cmd = f"sudo {pm} install -y {pkg_str}"
@@ -301,7 +303,11 @@ def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: di
if key_path.exists():
ctx.console.warn(f"SSH key already exists: {key_path}")
return
run_command(f'ssh-keygen -t {key_type} -f "{key_path}" -N "" -C "{comment}"', ctx.console)
run_command(
f"ssh-keygen -t {shlex.quote(key_type)} -f {shlex.quote(str(key_path))}"
f' -N "" -C {shlex.quote(comment)}',
ctx.console,
)
def handle_link_config(data):
config_name = data["config_name"]
@@ -328,18 +334,18 @@ def run_bootstrap(ctx: FlowContext, args):
flow_pkg = DOTFILES_DIR / "common" / "flow"
if flow_pkg.exists() and (flow_pkg / ".config" / "flow").exists():
ctx.console.info("Found flow config in dotfiles, linking...")
# Link flow package first
result = subprocess.run(
[sys.executable, "-m", "flow", "dotfiles", "link", "flow"],
capture_output=True, text=True,
# Call the link function directly instead of spawning a subprocess
from flow.commands.dotfiles import run_link
link_args = argparse.Namespace(
packages=["flow"], profile=None, copy=False, force=False, dry_run=False,
)
if result.returncode == 0:
try:
run_link(ctx, link_args)
ctx.console.success("Flow config linked from dotfiles")
# Reload manifest from newly linked location
ctx.manifest = load_manifest()
else:
detail = (result.stderr or "").strip() or (result.stdout or "").strip() or "unknown error"
ctx.console.warn(f"Failed to link flow config: {detail}")
except (RuntimeError, SystemExit) as e:
ctx.console.warn(f"Failed to link flow config: {e}")
profiles = _get_profiles(ctx)
if not profiles:

View File

@@ -195,18 +195,18 @@ def run_exec(ctx: FlowContext, args):
result = subprocess.run(exec_cmd)
sys.exit(result.returncode)
# No command — try shells in order
last_code = 0
# No command — try shells in order; 126/127 means the shell binary
# wasn't found, so we fall through. Any other exit code means the user
# exited the shell normally and we respect it.
for shell in ("zsh -l", "bash -l", "sh"):
parts = shell.split()
exec_cmd = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname] + parts
result = subprocess.run(exec_cmd)
if result.returncode == 0:
return
last_code = result.returncode
if result.returncode not in (126, 127):
sys.exit(result.returncode)
ctx.console.error(f"Unable to start an interactive shell in {cname}")
sys.exit(last_code or 1)
sys.exit(1)
def run_connect(ctx: FlowContext, args):
@@ -323,6 +323,9 @@ def run_remove(ctx: FlowContext, args):
def run_respawn(ctx: FlowContext, args):
if not shutil.which("tmux"):
ctx.console.error("tmux is required for respawn but was not found")
sys.exit(1)
cname = _cname(args.name)
result = subprocess.run(
["tmux", "list-panes", "-t", cname, "-s",

View File

@@ -324,7 +324,11 @@ def run_relink(ctx: FlowContext, args):
ctx.console.info("Unlinking current symlinks...")
run_unlink(ctx, args)
# Then link again
# Then link again — set defaults for attributes that run_link expects
# but the relink parser doesn't define.
args.copy = False
args.force = False
args.dry_run = False
ctx.console.info("Relinking with updated configuration...")
run_link(ctx, args)
@@ -413,7 +417,11 @@ def run_edit(ctx: FlowContext, args):
)
# Ask before pushing
try:
response = input("Push changes to remote? [Y/n] ")
except (EOFError, KeyboardInterrupt):
response = "n"
print() # newline after ^C / EOF
if response.lower() != "n":
subprocess.run(["git", "-C", str(DOTFILES_DIR), "push"], check=True)
ctx.console.success("Changes committed and pushed")

View File

@@ -3,6 +3,7 @@
import getpass
import os
import sys
from typing import Optional
from flow.core.config import FlowContext
@@ -57,6 +58,51 @@ def _build_destination(user: str, host: str, preserve_host_user: bool = False) -
return f"{user}@{host}"
def _terminfo_fix_command(term: Optional[str], destination: str) -> Optional[str]:
normalized_term = (term or "").strip().lower()
if normalized_term == "xterm-ghostty":
return f"infocmp -x xterm-ghostty | ssh {destination} -- tic -x -"
if normalized_term == "wezterm":
return (
f"ssh {destination} -- sh -lc "
"'tempfile=$(mktemp) && curl -fsSL -o \"$tempfile\" "
"https://raw.githubusercontent.com/wezterm/wezterm/main/termwiz/data/wezterm.terminfo "
"&& tic -x -o ~/.terminfo \"$tempfile\" && rm \"$tempfile\"'"
)
return None
def _handle_terminfo_warning(ctx: FlowContext, term: Optional[str], destination: str, dry_run: bool) -> bool:
install_cmd = _terminfo_fix_command(term, destination)
if not install_cmd:
return True
ctx.console.warn(
f"Detected TERM={term}. Remote host may be missing this terminfo entry."
)
ctx.console.info("flow will not install or modify terminfo on the target automatically.")
ctx.console.info("If needed, run this command manually before reconnecting:")
print(f" {install_cmd}")
if dry_run or not sys.stdin.isatty():
return True
response = ""
try:
response = input("Continue with SSH connection? [Y/n] ").strip().lower()
except EOFError:
return True
if response in {"n", "no"}:
ctx.console.warn("Cancelled before opening SSH session")
return False
return True
def run(ctx: FlowContext, args):
# Warn if already inside an instance
if os.environ.get("DF_NAMESPACE") and os.environ.get("DF_PLATFORM"):
@@ -106,6 +152,9 @@ def run(ctx: FlowContext, args):
ssh_host = host_template.replace("<namespace>", namespace)
destination = _build_destination(user, ssh_host, preserve_host_user=not user_was_explicit)
if not _handle_terminfo_warning(ctx, os.environ.get("TERM"), destination, dry_run=args.dry_run):
sys.exit(1)
# Build SSH command
ssh_cmd = ["ssh", "-tt"]
if ssh_identity:

View File

@@ -1,9 +1,7 @@
"""OS and architecture detection."""
import platform as _platform
import shutil
from dataclasses import dataclass
from typing import Optional
@dataclass
@@ -33,11 +31,3 @@ def detect_platform() -> PlatformInfo:
raise RuntimeError(f"Unsupported architecture: {raw_arch}")
return PlatformInfo(os=os_name, arch=arch, platform=f"{os_name}-{arch}")
def detect_container_runtime() -> Optional[str]:
"""Return 'docker' or 'podman' if available, else None."""
for runtime in ("docker", "podman"):
if shutil.which(runtime):
return runtime
return None

View File

@@ -26,13 +26,16 @@ def run_command(
)
output_lines = []
assert process.stdout is not None # guaranteed by stdout=PIPE
try:
for line in process.stdout:
line = line.rstrip()
if line:
if not capture:
console.step_output(line)
output_lines.append(line)
finally:
process.stdout.close()
process.wait()
if check and process.returncode != 0:

View File

@@ -135,6 +135,20 @@ def test_enter_dry_run_with_user():
assert "root@personal.orb" in result.stdout
def test_enter_dry_run_shows_terminfo_hint_for_ghostty():
env = _clean_env()
env["TERM"] = "xterm-ghostty"
result = subprocess.run(
[sys.executable, "-m", "flow", "enter", "--dry-run", "personal@orb"],
capture_output=True, text=True, env=env,
)
assert result.returncode == 0
assert "flow will not install or modify terminfo" in result.stdout
assert "infocmp -x xterm-ghostty | ssh" in result.stdout
def test_aliases():
"""Test that command aliases work."""
for alias, cmd in [("dot", "dotfiles"), ("pkg", "package"), ("setup", "bootstrap")]:

View File

@@ -1,6 +1,6 @@
"""Tests for command modules — registration and target parsing."""
from flow.commands.enter import _parse_target
from flow.commands.enter import _parse_target, _terminfo_fix_command
from flow.commands.container import _cname, _parse_image_ref
@@ -24,6 +24,21 @@ class TestParseTarget:
assert plat is None
class TestTerminfoFixCommand:
def test_ghostty_command(self):
cmd = _terminfo_fix_command("xterm-ghostty", "devbox.core.lan")
assert cmd == "infocmp -x xterm-ghostty | ssh devbox.core.lan -- tic -x -"
def test_wezterm_command(self):
cmd = _terminfo_fix_command("wezterm", "user@devbox.core.lan")
assert cmd is not None
assert "wezterm.terminfo" in cmd
assert "ssh user@devbox.core.lan" in cmd
def test_unknown_term(self):
assert _terminfo_fix_command("xterm-256color", "devbox.core.lan") is None
class TestCname:
def test_adds_prefix(self):
assert _cname("api") == "dev-api"

View File

@@ -2,7 +2,6 @@
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest

View File

@@ -2,14 +2,12 @@
import os
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flow.commands.dotfiles import _discover_packages, _walk_package, run_link, run_status
from flow.commands.dotfiles import _discover_packages, _walk_package
from flow.core.config import AppConfig, FlowContext
from flow.core.console import ConsoleLogger
from flow.core.paths import LINKED_STATE
from flow.core.platform import PlatformInfo
from flow.core.stow import LinkTree, TreeFolder

View File

@@ -4,7 +4,7 @@ import platform as _platform
import pytest
from flow.core.platform import PlatformInfo, detect_container_runtime, detect_platform
from flow.core.platform import PlatformInfo, detect_platform
def test_detect_platform_returns_platforminfo():
@@ -27,6 +27,4 @@ def test_detect_platform_unsupported_arch(monkeypatch):
detect_platform()
def test_detect_container_runtime_returns_string_or_none():
result = detect_container_runtime()
assert result is None or result in ("docker", "podman")