wip
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
dist/
|
||||
build/
|
||||
*.spec
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
6
Makefile
6
Makefile
@@ -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)"
|
||||
|
||||
40
README.md
40
README.md
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
@@ -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",
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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:
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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")]:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user