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
|
PYTHON ?= python3
|
||||||
PROJECT_ROOT := $(abspath ..)
|
SRC_DIR := $(CURDIR)/src
|
||||||
ENTRYPOINT := __main__.py
|
ENTRYPOINT := src/flow/__main__.py
|
||||||
DIST_DIR := dist
|
DIST_DIR := dist
|
||||||
BUILD_DIR := build
|
BUILD_DIR := build
|
||||||
SPEC_FILE := flow.spec
|
SPEC_FILE := flow.spec
|
||||||
@@ -17,7 +17,7 @@ help:
|
|||||||
@printf " make clean Remove build artifacts\n"
|
@printf " make clean Remove build artifacts\n"
|
||||||
|
|
||||||
build:
|
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
|
install-local: build
|
||||||
mkdir -p "$(INSTALL_DIR)"
|
mkdir -p "$(INSTALL_DIR)"
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -1,16 +1,15 @@
|
|||||||
# flow
|
# flow
|
||||||
|
|
||||||
`flow` is a CLI for managing development instances, containers, dotfiles,
|
`flow` is a CLI for managing development instances, containers, dotfiles, bootstrap profiles, and
|
||||||
bootstrap profiles, and binary packages.
|
binary packages.
|
||||||
|
|
||||||
This repository contains the Python implementation of the tool and its command
|
This repository contains the Python implementation of the tool and its command modules.
|
||||||
modules.
|
|
||||||
|
|
||||||
## What is implemented
|
## What is implemented
|
||||||
|
|
||||||
- Instance access via `flow enter`
|
- Instance access via `flow enter`
|
||||||
- Container lifecycle commands under `flow dev` (`create`, `exec`, `connect`,
|
- Container lifecycle commands under `flow dev` (`create`, `exec`, `connect`, `list`, `stop`,
|
||||||
`list`, `stop`, `remove`, `respawn`)
|
`remove`, `respawn`)
|
||||||
- Dotfiles management (`dotfiles` / `dot`)
|
- Dotfiles management (`dotfiles` / `dot`)
|
||||||
- Bootstrap planning and execution (`bootstrap` / `setup` / `provision`)
|
- Bootstrap planning and execution (`bootstrap` / `setup` / `provision`)
|
||||||
- Binary package installation from manifest definitions (`package` / `pkg`)
|
- Binary package installation from manifest definitions (`package` / `pkg`)
|
||||||
@@ -72,35 +71,35 @@ Example:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
profiles:
|
profiles:
|
||||||
linux-vm:
|
linux-vm:
|
||||||
os: linux
|
os: linux
|
||||||
hostname: "$HOSTNAME"
|
hostname: "$HOSTNAME"
|
||||||
shell: zsh
|
shell: zsh
|
||||||
locale: en_US.UTF-8
|
locale: en_US.UTF-8
|
||||||
requires: [HOSTNAME]
|
requires: [HOSTNAME]
|
||||||
packages:
|
packages:
|
||||||
standard: [git, tmux, zsh]
|
standard: [git, tmux, zsh]
|
||||||
binary: [neovim]
|
binary: [neovim]
|
||||||
ssh_keygen:
|
ssh_keygen:
|
||||||
- type: ed25519
|
- type: ed25519
|
||||||
comment: "$USER@$HOSTNAME"
|
comment: "$USER@$HOSTNAME"
|
||||||
runcmd:
|
runcmd:
|
||||||
- mkdir -p ~/projects
|
- mkdir -p ~/projects
|
||||||
|
|
||||||
binaries:
|
binaries:
|
||||||
neovim:
|
neovim:
|
||||||
source: github:neovim/neovim
|
source: github:neovim/neovim
|
||||||
version: "0.10.4"
|
version: "0.10.4"
|
||||||
asset-pattern: "nvim-{{os}}-{{arch}}.tar.gz"
|
asset-pattern: "nvim-{{os}}-{{arch}}.tar.gz"
|
||||||
platform-map:
|
platform-map:
|
||||||
linux-amd64: { os: linux, arch: x86_64 }
|
linux-amd64: { os: linux, arch: x86_64 }
|
||||||
linux-arm64: { os: linux, arch: arm64 }
|
linux-arm64: { os: linux, arch: arm64 }
|
||||||
macos-arm64: { os: macos, arch: arm64 }
|
macos-arm64: { os: macos, arch: arm64 }
|
||||||
install-script: |
|
install-script: |
|
||||||
curl -fL "{{downloadUrl}}" -o /tmp/nvim.tar.gz
|
curl -fL "{{downloadUrl}}" -o /tmp/nvim.tar.gz
|
||||||
tar -xzf /tmp/nvim.tar.gz -C /tmp
|
tar -xzf /tmp/nvim.tar.gz -C /tmp
|
||||||
rm -rf ~/.local/bin/nvim
|
rm -rf ~/.local/bin/nvim
|
||||||
cp /tmp/nvim-*/bin/nvim ~/.local/bin/nvim
|
cp /tmp/nvim-*/bin/nvim ~/.local/bin/nvim
|
||||||
```
|
```
|
||||||
|
|
||||||
## Command overview
|
## Command overview
|
||||||
@@ -113,6 +112,9 @@ flow enter root@personal@orb
|
|||||||
flow enter personal@orb --dry-run
|
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
|
### Containers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -184,14 +186,13 @@ Passing an explicit file path to internal loaders bypasses this cascade.
|
|||||||
|
|
||||||
## State format policy
|
## State format policy
|
||||||
|
|
||||||
`flow` currently supports only the v2 dotfiles link state format
|
`flow` currently supports only the v2 dotfiles link state format (`linked.json`). Older state
|
||||||
(`linked.json`). Older state formats are intentionally not supported.
|
formats are intentionally not supported.
|
||||||
|
|
||||||
## CLI behavior
|
## CLI behavior
|
||||||
|
|
||||||
- User errors return non-zero exit codes.
|
- User errors return non-zero exit codes.
|
||||||
- External command failures are surfaced as concise one-line errors (no
|
- External command failures are surfaced as concise one-line errors (no traceback spam).
|
||||||
traceback spam).
|
|
||||||
- `Ctrl+C` exits with code `130`.
|
- `Ctrl+C` exits with code `130`.
|
||||||
|
|
||||||
## Zsh completion
|
## Zsh completion
|
||||||
@@ -216,9 +217,8 @@ fpath=(~/.zsh/completions $fpath)
|
|||||||
autoload -Uz compinit && compinit
|
autoload -Uz compinit && compinit
|
||||||
```
|
```
|
||||||
|
|
||||||
Completion is dynamic and pulls values from your current config/manifest/state
|
Completion is dynamic and pulls values from your current config/manifest/state (for example
|
||||||
(for example bootstrap profiles, package names, dotfiles packages, and
|
bootstrap profiles, package names, dotfiles packages, and configured `enter` targets).
|
||||||
configured `enter` targets).
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -236,22 +236,16 @@ Useful targets:
|
|||||||
make clean
|
make clean
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a syntax check:
|
Run tests:
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m compileall .
|
|
||||||
```
|
|
||||||
|
|
||||||
Run tests (when `pytest` is available):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m pytest
|
python3 -m pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional local venv setup:
|
Local development setup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -U pip pytest pyyaml
|
.venv/bin/pip install -e ".[dev]"
|
||||||
PYTHONPATH=/path/to/src .venv/bin/pytest
|
.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]
|
[project]
|
||||||
name = "flow"
|
name = "flow"
|
||||||
version = "0.1.0"
|
dynamic = ["version"]
|
||||||
description = "DevFlow - A unified toolkit for managing development instances, containers, and profiles"
|
description = "DevFlow - A unified toolkit for managing development instances, containers, and profiles"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = ["pyyaml>=6.0"]
|
dependencies = ["pyyaml>=6.0"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
build = ["pyinstaller>=6.0"]
|
build = ["pyinstaller>=6.0"]
|
||||||
|
dev = ["pytest>=7.0"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
flow = "flow.cli:main"
|
flow = "flow.cli:main"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
path = "src/flow/__init__.py"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/flow"]
|
packages = ["src/flow"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""flow bootstrap — environment provisioning with plan-then-execute model."""
|
"""flow bootstrap — environment provisioning with plan-then-execute model."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
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}")
|
raise RuntimeError(f"Required variable not set: {var}")
|
||||||
|
|
||||||
def handle_set_hostname(data):
|
def handle_set_hostname(data):
|
||||||
hostname = data["hostname"]
|
hostname = shlex.quote(data["hostname"])
|
||||||
if ctx.platform.os == "macos":
|
if ctx.platform.os == "macos":
|
||||||
run_command(f"sudo scutil --set ComputerName '{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 HostName {hostname}", ctx.console)
|
||||||
run_command(f"sudo scutil --set LocalHostName '{hostname}'", ctx.console)
|
run_command(f"sudo scutil --set LocalHostName {hostname}", ctx.console)
|
||||||
else:
|
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):
|
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 locale-gen {locale}", ctx.console)
|
||||||
run_command(f"sudo update-locale LANG={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)
|
shell_path = shutil.which(shell)
|
||||||
if not shell_path:
|
if not shell_path:
|
||||||
raise RuntimeError(f"Shell not found: {shell}")
|
raise RuntimeError(f"Shell not found: {shell}")
|
||||||
|
quoted_path = shlex.quote(shell_path)
|
||||||
try:
|
try:
|
||||||
with open("/etc/shells") as f:
|
with open("/etc/shells") as f:
|
||||||
if shell_path not in f.read():
|
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:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
run_command(f"chsh -s {shell_path}", ctx.console)
|
run_command(f"chsh -s {quoted_path}", ctx.console)
|
||||||
|
|
||||||
def handle_pm_update(data):
|
def handle_pm_update(data):
|
||||||
pm = data["pm"]
|
pm = data["pm"]
|
||||||
@@ -249,7 +251,7 @@ def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: di
|
|||||||
pm = data["pm"]
|
pm = data["pm"]
|
||||||
packages = data["packages"]
|
packages = data["packages"]
|
||||||
pkg_type = data.get("type", "standard")
|
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"):
|
if pm in ("apt-get", "apt"):
|
||||||
cmd = f"sudo {pm} install -y {pkg_str}"
|
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():
|
if key_path.exists():
|
||||||
ctx.console.warn(f"SSH key already exists: {key_path}")
|
ctx.console.warn(f"SSH key already exists: {key_path}")
|
||||||
return
|
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):
|
def handle_link_config(data):
|
||||||
config_name = data["config_name"]
|
config_name = data["config_name"]
|
||||||
@@ -328,18 +334,18 @@ def run_bootstrap(ctx: FlowContext, args):
|
|||||||
flow_pkg = DOTFILES_DIR / "common" / "flow"
|
flow_pkg = DOTFILES_DIR / "common" / "flow"
|
||||||
if flow_pkg.exists() and (flow_pkg / ".config" / "flow").exists():
|
if flow_pkg.exists() and (flow_pkg / ".config" / "flow").exists():
|
||||||
ctx.console.info("Found flow config in dotfiles, linking...")
|
ctx.console.info("Found flow config in dotfiles, linking...")
|
||||||
# Link flow package first
|
# Call the link function directly instead of spawning a subprocess
|
||||||
result = subprocess.run(
|
from flow.commands.dotfiles import run_link
|
||||||
[sys.executable, "-m", "flow", "dotfiles", "link", "flow"],
|
link_args = argparse.Namespace(
|
||||||
capture_output=True, text=True,
|
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")
|
ctx.console.success("Flow config linked from dotfiles")
|
||||||
# Reload manifest from newly linked location
|
# Reload manifest from newly linked location
|
||||||
ctx.manifest = load_manifest()
|
ctx.manifest = load_manifest()
|
||||||
else:
|
except (RuntimeError, SystemExit) as e:
|
||||||
detail = (result.stderr or "").strip() or (result.stdout or "").strip() or "unknown error"
|
ctx.console.warn(f"Failed to link flow config: {e}")
|
||||||
ctx.console.warn(f"Failed to link flow config: {detail}")
|
|
||||||
|
|
||||||
profiles = _get_profiles(ctx)
|
profiles = _get_profiles(ctx)
|
||||||
if not profiles:
|
if not profiles:
|
||||||
@@ -195,18 +195,18 @@ def run_exec(ctx: FlowContext, args):
|
|||||||
result = subprocess.run(exec_cmd)
|
result = subprocess.run(exec_cmd)
|
||||||
sys.exit(result.returncode)
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
# No command — try shells in order
|
# No command — try shells in order; 126/127 means the shell binary
|
||||||
last_code = 0
|
# 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"):
|
for shell in ("zsh -l", "bash -l", "sh"):
|
||||||
parts = shell.split()
|
parts = shell.split()
|
||||||
exec_cmd = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname] + parts
|
exec_cmd = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname] + parts
|
||||||
result = subprocess.run(exec_cmd)
|
result = subprocess.run(exec_cmd)
|
||||||
if result.returncode == 0:
|
if result.returncode not in (126, 127):
|
||||||
return
|
sys.exit(result.returncode)
|
||||||
last_code = result.returncode
|
|
||||||
|
|
||||||
ctx.console.error(f"Unable to start an interactive shell in {cname}")
|
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):
|
def run_connect(ctx: FlowContext, args):
|
||||||
@@ -323,6 +323,9 @@ def run_remove(ctx: FlowContext, args):
|
|||||||
|
|
||||||
|
|
||||||
def run_respawn(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)
|
cname = _cname(args.name)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["tmux", "list-panes", "-t", cname, "-s",
|
["tmux", "list-panes", "-t", cname, "-s",
|
||||||
@@ -324,7 +324,11 @@ def run_relink(ctx: FlowContext, args):
|
|||||||
ctx.console.info("Unlinking current symlinks...")
|
ctx.console.info("Unlinking current symlinks...")
|
||||||
run_unlink(ctx, args)
|
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...")
|
ctx.console.info("Relinking with updated configuration...")
|
||||||
run_link(ctx, args)
|
run_link(ctx, args)
|
||||||
|
|
||||||
@@ -413,7 +417,11 @@ def run_edit(ctx: FlowContext, args):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Ask before pushing
|
# Ask before pushing
|
||||||
response = input("Push changes to remote? [Y/n] ")
|
try:
|
||||||
|
response = input("Push changes to remote? [Y/n] ")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
response = "n"
|
||||||
|
print() # newline after ^C / EOF
|
||||||
if response.lower() != "n":
|
if response.lower() != "n":
|
||||||
subprocess.run(["git", "-C", str(DOTFILES_DIR), "push"], check=True)
|
subprocess.run(["git", "-C", str(DOTFILES_DIR), "push"], check=True)
|
||||||
ctx.console.success("Changes committed and pushed")
|
ctx.console.success("Changes committed and pushed")
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import getpass
|
import getpass
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from flow.core.config import FlowContext
|
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}"
|
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):
|
def run(ctx: FlowContext, args):
|
||||||
# Warn if already inside an instance
|
# Warn if already inside an instance
|
||||||
if os.environ.get("DF_NAMESPACE") and os.environ.get("DF_PLATFORM"):
|
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)
|
ssh_host = host_template.replace("<namespace>", namespace)
|
||||||
destination = _build_destination(user, ssh_host, preserve_host_user=not user_was_explicit)
|
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
|
# Build SSH command
|
||||||
ssh_cmd = ["ssh", "-tt"]
|
ssh_cmd = ["ssh", "-tt"]
|
||||||
if ssh_identity:
|
if ssh_identity:
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
"""OS and architecture detection."""
|
"""OS and architecture detection."""
|
||||||
|
|
||||||
import platform as _platform
|
import platform as _platform
|
||||||
import shutil
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -33,11 +31,3 @@ def detect_platform() -> PlatformInfo:
|
|||||||
raise RuntimeError(f"Unsupported architecture: {raw_arch}")
|
raise RuntimeError(f"Unsupported architecture: {raw_arch}")
|
||||||
|
|
||||||
return PlatformInfo(os=os_name, arch=arch, platform=f"{os_name}-{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,14 +26,17 @@ def run_command(
|
|||||||
)
|
)
|
||||||
|
|
||||||
output_lines = []
|
output_lines = []
|
||||||
for line in process.stdout:
|
assert process.stdout is not None # guaranteed by stdout=PIPE
|
||||||
line = line.rstrip()
|
try:
|
||||||
if line:
|
for line in process.stdout:
|
||||||
if not capture:
|
line = line.rstrip()
|
||||||
console.step_output(line)
|
if line:
|
||||||
output_lines.append(line)
|
if not capture:
|
||||||
|
console.step_output(line)
|
||||||
process.wait()
|
output_lines.append(line)
|
||||||
|
finally:
|
||||||
|
process.stdout.close()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
if check and process.returncode != 0:
|
if check and process.returncode != 0:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
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
|
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():
|
def test_aliases():
|
||||||
"""Test that command aliases work."""
|
"""Test that command aliases work."""
|
||||||
for alias, cmd in [("dot", "dotfiles"), ("pkg", "package"), ("setup", "bootstrap")]:
|
for alias, cmd in [("dot", "dotfiles"), ("pkg", "package"), ("setup", "bootstrap")]:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for command modules — registration and target parsing."""
|
"""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
|
from flow.commands.container import _cname, _parse_image_ref
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +24,21 @@ class TestParseTarget:
|
|||||||
assert plat is None
|
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:
|
class TestCname:
|
||||||
def test_adds_prefix(self):
|
def test_adds_prefix(self):
|
||||||
assert _cname("api") == "dev-api"
|
assert _cname("api") == "dev-api"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
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.config import AppConfig, FlowContext
|
||||||
from flow.core.console import ConsoleLogger
|
from flow.core.console import ConsoleLogger
|
||||||
from flow.core.paths import LINKED_STATE
|
|
||||||
from flow.core.platform import PlatformInfo
|
from flow.core.platform import PlatformInfo
|
||||||
from flow.core.stow import LinkTree, TreeFolder
|
from flow.core.stow import LinkTree, TreeFolder
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import platform as _platform
|
|||||||
|
|
||||||
import pytest
|
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():
|
def test_detect_platform_returns_platforminfo():
|
||||||
@@ -27,6 +27,4 @@ def test_detect_platform_unsupported_arch(monkeypatch):
|
|||||||
detect_platform()
|
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