From 906adb539df6d51d6bf414d9d1b218a39cef5ace Mon Sep 17 00:00:00 2001 From: Tomas Mirchev Date: Thu, 12 Feb 2026 09:42:59 +0200 Subject: [PATCH] flow --- Makefile | 31 ++ README.md | 257 +++++++++ __init__.py | 1 + __main__.py | 4 + __pycache__/__init__.cpython-313.pyc | Bin 0 -> 169 bytes __pycache__/__main__.cpython-313.pyc | Bin 0 -> 249 bytes __pycache__/cli.cpython-313.pyc | Bin 0 -> 4295 bytes cli.py | 92 +++ commands/__init__.py | 0 commands/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 152 bytes .../__pycache__/bootstrap.cpython-313.pyc | Bin 0 -> 20844 bytes .../__pycache__/completion.cpython-313.pyc | Bin 0 -> 21919 bytes .../__pycache__/container.cpython-313.pyc | Bin 0 -> 17841 bytes commands/__pycache__/dotfiles.cpython-313.pyc | Bin 0 -> 23326 bytes commands/__pycache__/enter.cpython-313.pyc | Bin 0 -> 5802 bytes commands/__pycache__/package.cpython-313.pyc | Bin 0 -> 8898 bytes commands/__pycache__/sync.cpython-313.pyc | Bin 0 -> 10558 bytes commands/bootstrap.py | 418 ++++++++++++++ commands/completion.py | 525 ++++++++++++++++++ commands/container.py | 349 ++++++++++++ commands/dotfiles.py | 425 ++++++++++++++ commands/enter.py | 127 +++++ commands/package.py | 181 ++++++ commands/sync.py | 199 +++++++ core/__init__.py | 0 core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 148 bytes core/__pycache__/action.cpython-313.pyc | Bin 0 -> 8599 bytes core/__pycache__/config.cpython-313.pyc | Bin 0 -> 6279 bytes core/__pycache__/console.cpython-313.pyc | Bin 0 -> 11809 bytes core/__pycache__/paths.cpython-313.pyc | Bin 0 -> 1839 bytes core/__pycache__/platform.cpython-313.pyc | Bin 0 -> 2204 bytes core/__pycache__/process.cpython-313.pyc | Bin 0 -> 1718 bytes core/__pycache__/stow.cpython-313.pyc | Bin 0 -> 15465 bytes core/__pycache__/variables.cpython-313.pyc | Bin 0 -> 2403 bytes core/action.py | 120 ++++ core/config.py | 151 +++++ core/console.py | 138 +++++ core/paths.py | 37 ++ core/platform.py | 43 ++ core/process.py | 45 ++ core/stow.py | 358 ++++++++++++ core/variables.py | 38 ++ pyproject.toml | 19 + tests/__init__.py | 0 tests/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 140 bytes .../test_action.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 14659 bytes tests/__pycache__/test_action.cpython-313.pyc | Bin 0 -> 5650 bytes ...est_bootstrap.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 18168 bytes .../test_bootstrap.cpython-313.pyc | Bin 0 -> 5960 bytes .../test_cli.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 33216 bytes tests/__pycache__/test_cli.cpython-313.pyc | Bin 0 -> 7524 bytes ...test_commands.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 14013 bytes .../__pycache__/test_commands.cpython-313.pyc | Bin 0 -> 3648 bytes ...st_completion.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 11968 bytes .../test_completion.cpython-313.pyc | Bin 0 -> 4394 bytes .../test_config.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 13603 bytes tests/__pycache__/test_config.cpython-313.pyc | Bin 0 -> 3279 bytes .../test_console.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 14562 bytes .../__pycache__/test_console.cpython-313.pyc | Bin 0 -> 4516 bytes ...test_dotfiles.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 10557 bytes .../__pycache__/test_dotfiles.cpython-313.pyc | Bin 0 -> 3316 bytes ...files_folding.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 28206 bytes .../test_dotfiles_folding.cpython-313.pyc | Bin 0 -> 12235 bytes .../test_paths.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 14095 bytes tests/__pycache__/test_paths.cpython-313.pyc | Bin 0 -> 3389 bytes ...test_platform.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 6393 bytes .../__pycache__/test_platform.cpython-313.pyc | Bin 0 -> 2257 bytes ..._self_hosting.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 17892 bytes .../test_self_hosting.cpython-313.pyc | Bin 0 -> 7990 bytes .../test_stow.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 36934 bytes tests/__pycache__/test_stow.cpython-313.pyc | Bin 0 -> 12063 bytes ...est_variables.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 8236 bytes .../test_variables.cpython-313.pyc | Bin 0 -> 2529 bytes tests/test_action.py | 115 ++++ tests/test_bootstrap.py | 129 +++++ tests/test_cli.py | 153 +++++ tests/test_commands.py | 59 ++ tests/test_completion.py | 63 +++ tests/test_config.py | 70 +++ tests/test_console.py | 95 ++++ tests/test_dotfiles.py | 67 +++ tests/test_dotfiles_folding.py | 300 ++++++++++ tests/test_paths.py | 70 +++ tests/test_platform.py | 32 ++ tests/test_self_hosting.py | 215 +++++++ tests/test_stow.py | 310 +++++++++++ tests/test_variables.py | 52 ++ 87 files changed, 5288 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __main__.py create mode 100644 __pycache__/__init__.cpython-313.pyc create mode 100644 __pycache__/__main__.cpython-313.pyc create mode 100644 __pycache__/cli.cpython-313.pyc create mode 100644 cli.py create mode 100644 commands/__init__.py create mode 100644 commands/__pycache__/__init__.cpython-313.pyc create mode 100644 commands/__pycache__/bootstrap.cpython-313.pyc create mode 100644 commands/__pycache__/completion.cpython-313.pyc create mode 100644 commands/__pycache__/container.cpython-313.pyc create mode 100644 commands/__pycache__/dotfiles.cpython-313.pyc create mode 100644 commands/__pycache__/enter.cpython-313.pyc create mode 100644 commands/__pycache__/package.cpython-313.pyc create mode 100644 commands/__pycache__/sync.cpython-313.pyc create mode 100644 commands/bootstrap.py create mode 100644 commands/completion.py create mode 100644 commands/container.py create mode 100644 commands/dotfiles.py create mode 100644 commands/enter.py create mode 100644 commands/package.py create mode 100644 commands/sync.py create mode 100644 core/__init__.py create mode 100644 core/__pycache__/__init__.cpython-313.pyc create mode 100644 core/__pycache__/action.cpython-313.pyc create mode 100644 core/__pycache__/config.cpython-313.pyc create mode 100644 core/__pycache__/console.cpython-313.pyc create mode 100644 core/__pycache__/paths.cpython-313.pyc create mode 100644 core/__pycache__/platform.cpython-313.pyc create mode 100644 core/__pycache__/process.cpython-313.pyc create mode 100644 core/__pycache__/stow.cpython-313.pyc create mode 100644 core/__pycache__/variables.cpython-313.pyc create mode 100644 core/action.py create mode 100644 core/config.py create mode 100644 core/console.py create mode 100644 core/paths.py create mode 100644 core/platform.py create mode 100644 core/process.py create mode 100644 core/stow.py create mode 100644 core/variables.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-313.pyc create mode 100644 tests/__pycache__/test_action.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_action.cpython-313.pyc create mode 100644 tests/__pycache__/test_bootstrap.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_bootstrap.cpython-313.pyc create mode 100644 tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_cli.cpython-313.pyc create mode 100644 tests/__pycache__/test_commands.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_commands.cpython-313.pyc create mode 100644 tests/__pycache__/test_completion.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_completion.cpython-313.pyc create mode 100644 tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_config.cpython-313.pyc create mode 100644 tests/__pycache__/test_console.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_console.cpython-313.pyc create mode 100644 tests/__pycache__/test_dotfiles.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_dotfiles.cpython-313.pyc create mode 100644 tests/__pycache__/test_dotfiles_folding.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_dotfiles_folding.cpython-313.pyc create mode 100644 tests/__pycache__/test_paths.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_paths.cpython-313.pyc create mode 100644 tests/__pycache__/test_platform.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_platform.cpython-313.pyc create mode 100644 tests/__pycache__/test_self_hosting.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_self_hosting.cpython-313.pyc create mode 100644 tests/__pycache__/test_stow.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_stow.cpython-313.pyc create mode 100644 tests/__pycache__/test_variables.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_variables.cpython-313.pyc create mode 100644 tests/test_action.py create mode 100644 tests/test_bootstrap.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_completion.py create mode 100644 tests/test_config.py create mode 100644 tests/test_console.py create mode 100644 tests/test_dotfiles.py create mode 100644 tests/test_dotfiles_folding.py create mode 100644 tests/test_paths.py create mode 100644 tests/test_platform.py create mode 100644 tests/test_self_hosting.py create mode 100644 tests/test_stow.py create mode 100644 tests/test_variables.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6e29c4d --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +PYTHON ?= python3 +PROJECT_ROOT := $(abspath ..) +ENTRYPOINT := __main__.py +DIST_DIR := dist +BUILD_DIR := build +SPEC_FILE := flow.spec +BINARY := $(DIST_DIR)/flow +INSTALL_DIR ?= $(HOME)/.local/bin + +.PHONY: build install-local check-binary clean help + +help: + @printf "Targets:\n" + @printf " make build Build standalone binary at dist/flow\n" + @printf " make install-local Install binary to ~/.local/bin/flow\n" + @printf " make check-binary Run dist/flow --help\n" + @printf " make clean Remove build artifacts\n" + +build: + $(PYTHON) -m PyInstaller --noconfirm --clean --onefile --name flow --paths "$(PROJECT_ROOT)" "$(ENTRYPOINT)" + +install-local: build + mkdir -p "$(INSTALL_DIR)" + install -m 755 "$(BINARY)" "$(INSTALL_DIR)/flow" + @printf "Installed flow to $(INSTALL_DIR)/flow\n" + +check-binary: + "./$(BINARY)" --help + +clean: + rm -rf "$(BUILD_DIR)" "$(DIST_DIR)" "$(SPEC_FILE)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d98ffd --- /dev/null +++ b/README.md @@ -0,0 +1,257 @@ +# flow + +`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. + +## What is implemented + +- Instance access via `flow enter` +- 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`) +- Multi-repo sync checks (`sync`) + +## Installation + +Build and install a standalone binary (no pip install required for use): + +```bash +make build +make install-local +``` + +This installs `flow` to `~/.local/bin/flow`. + +## Configuration + +`flow` uses XDG paths by default: + +- `~/.config/devflow/config` +- `~/.config/devflow/manifest.yaml` +- `~/.local/share/devflow/` +- `~/.local/state/devflow/` + +### `config` (INI) + +```ini +[repository] +dotfiles_url = git@github.com:you/dotfiles.git +dotfiles_branch = main + +[paths] +projects_dir = ~/projects + +[defaults] +container_registry = registry.tomastm.com +container_tag = latest +tmux_session = default + +[targets] +# Format A: namespace = platform ssh_host [ssh_identity] +personal = orb personal.orb + +# Format B: namespace@platform = ssh_host [ssh_identity] +work@ec2 = work.internal ~/.ssh/id_work +``` + +## Manifest format + +The manifest is YAML with two top-level sections used by the current code: + +- `profiles` for bootstrap profiles +- `binaries` for package definitions + +`environments` is no longer supported. + +Example: + +```yaml +profiles: + linux-vm: + os: linux + hostname: "$HOSTNAME" + shell: zsh + locale: en_US.UTF-8 + requires: [HOSTNAME] + packages: + standard: [git, tmux, zsh] + binary: [neovim] + ssh_keygen: + - type: ed25519 + comment: "$USER@$HOSTNAME" + runcmd: + - mkdir -p ~/projects + +binaries: + neovim: + source: github:neovim/neovim + version: "0.10.4" + asset-pattern: "nvim-{{os}}-{{arch}}.tar.gz" + platform-map: + linux-amd64: { os: linux, arch: x86_64 } + linux-arm64: { os: linux, arch: arm64 } + macos-arm64: { os: macos, arch: arm64 } + install-script: | + curl -fL "{{downloadUrl}}" -o /tmp/nvim.tar.gz + tar -xzf /tmp/nvim.tar.gz -C /tmp + rm -rf ~/.local/bin/nvim + cp /tmp/nvim-*/bin/nvim ~/.local/bin/nvim +``` + +## Command overview + +### Enter instances + +```bash +flow enter personal@orb +flow enter root@personal@orb +flow enter personal@orb --dry-run +``` + +### Containers + +```bash +flow dev create api -i tm0/node -p ~/projects/api +flow dev connect api +flow dev exec api -- npm test +flow dev list +flow dev stop api +flow dev remove api +``` + +### Dotfiles + +```bash +flow dotfiles init --repo git@github.com:you/dotfiles.git +flow dotfiles link +flow dotfiles status +flow dotfiles relink +flow dotfiles clean --dry-run +``` + +### Bootstrap + +```bash +flow bootstrap list +flow bootstrap show linux-vm +flow bootstrap run --profile linux-vm --var HOSTNAME=devbox +flow bootstrap run --profile linux-vm --dry-run +``` + +### Packages + +```bash +flow package install neovim +flow package list +flow package list --all +flow package remove neovim +``` + +### Sync + +```bash +flow sync check +flow sync check --no-fetch +flow sync fetch +flow sync summary +``` + +### Completion + +```bash +flow completion install-zsh +flow completion zsh +``` + +## Self-hosted config priority + +When present, `flow` prefers config from a linked dotfiles package: + +1. `~/.local/share/devflow/dotfiles/flow/.config/flow/config` +2. `~/.config/devflow/config` + +And for manifest: + +1. `~/.local/share/devflow/dotfiles/flow/.config/flow/manifest.yaml` +2. `~/.config/devflow/manifest.yaml` + +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. + +## CLI behavior + +- User errors return non-zero exit codes. +- External command failures are surfaced as concise one-line errors (no + traceback spam). +- `Ctrl+C` exits with code `130`. + +## Zsh completion + +Recommended one-shot install: + +```bash +flow completion install-zsh +``` + +Manual install (equivalent): + +```bash +mkdir -p ~/.zsh/completions +flow completion zsh > ~/.zsh/completions/_flow +``` + +Then ensure your `.zshrc` includes: + +```bash +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). + +## Development + +Binary build (maintainers): + +```bash +python3 -m pip install pyinstaller +make build +make install-local +``` + +Useful targets: + +```bash +make clean +``` + +Run a syntax check: + +```bash +python3 -m compileall . +``` + +Run tests (when `pytest` is available): + +```bash +python3 -m pytest +``` + +Optional local venv setup: + +```bash +python3 -m venv .venv +.venv/bin/pip install -U pip pytest pyyaml +PYTHONPATH=/path/to/src .venv/bin/pytest +``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..d3e839d --- /dev/null +++ b/__main__.py @@ -0,0 +1,4 @@ +from flow.cli import main + +if __name__ == "__main__": + main() diff --git a/__pycache__/__init__.cpython-313.pyc b/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6337090abbe14fc22b720a6ba8a6440a7a1078de GIT binary patch literal 169 zcmey&%ge<81cGusnPNctF^B^Lj8MjB4j^MHLoh=TLpq}-Qx&U$o}r$BpC;oi?)dn! z)S}|d{Ji-1l?Tl`kXXNLm>X+o_CKl@#6y;~7CYKcJr{(0A>lYU#Lm2V#nR%Hd r@$q^EmA5!-a`RJ4b5iY!*nrwWb{2!2^nsa?k?{tfXd`zK3y=!{TX`v0 literal 0 HcmV?d00001 diff --git a/__pycache__/__main__.cpython-313.pyc b/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a461d6117a9f300eb134064351fa78ba7ad2f04 GIT binary patch literal 249 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 VdO*Qzt-+$&y|C*G3SMN^{|`fEZ#n<~ literal 0 HcmV?d00001 diff --git a/commands/__pycache__/completion.cpython-313.pyc b/commands/__pycache__/completion.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..119b844adfe6029a3243503a4817796a6c701152 GIT binary patch literal 21919 zcmdsfd2k!onP)f9IEb6zeF`EeiI8~eKB$wrEK8Kc79BaV&A=o`LZSes8`MEtHnXl> zQ&DC_MREypyc24ZiK$6;!=y43IeWx*lG;q|>@Fb42GUUy&BU9jOifMEmM4dIt9F0i zYjgvkAjNiE`^P+q53k?V?|tvP-+O(}>9lbOXF`7x`kRd$_e*-vf+dr<_m2jSdx;Y{ zi4%Fz@F*|w?AIU}*soDCvR^?G@N0b3w9hP=eNy_%CMM8^}A zqVtK$)q?1%=8g+emFPx(wdg@ygA(f#l_m^+wLvUq<+XWvUY1vvmsi5_>htnSS>B?& zyfT*O%gZZgd5iP%Dp+2FxLT}yqDUQ0PPrgfQTcIRYRt(sh}EoBlUVbFEz_p4J@!Da7f6e$NpwG}~$ zqocW*^7zFLvmEH9vq{d0;7sm z41RVz7#;{JMll#gMc3Yg;*tI%yAB-Mzx~KA(Qi=fqmjU1|3D->6dG2X?BQe}92yGB zQItA%9z3%9k^Q^G{+*BXD(**m#3S4H@87kvAHPR-O;k1dwrttrTQ~*eQBhY{*L_9E z^V=0uFdPj^ig7S_O0f<`qC=t4pp3^zR4IBo5{b%DDKMs(7%2TG7xIV5ow>(pgc#3G;U^rub7&*$b!n;WFPn$Z zCls7V#W9aoQ>^?0{ZwrbFx$fYREsr#pmi;qr%-TBy!sf}nqb`vK%>vJnr9r*szPI! z^Vj!C6=*t9DJ~>WJdXt>nJAHxiZ_dsAgz-is6i?l1W_qQknTjyAt@A&5*6oB`G6D} zBQmv&pv7oV`oJCv%hAB-Xe%|UdqjOG-0TBWYwKW0n(#i?-iF%k*&)ai9Xmr(a3C6y z&iYP|1f`&_e<8^d1^Pm=@3a((MuTCHzGW~t6c`_kN>!Ni2SRJBG%#UR`$lS&3Il1t z$67`szVWfaKs4w(9g2?lhQ`2;*a$-5P}C=fLt|sX=m(b8)^G&&@!-U& zoe^I+67`*o42Fiz`Z7Ik@dVh#)tlVhV(oc)+FL7JFoa02fcI31A& zkNAy>5Is8jx3k(kSj|C(d z+|@;CdHiYhQC1var~R2ll@*sX9%fLJ8PX{Zs*Q1up9F7K+*F#uJ*uTxQN%ieOzAsx zsCSnrDn?9H@o4>M(D$3MyxF%Jk(O#PI>nOdPce^#1_y&-sROw(F-RYUZBi%I!b+(V zm%6D0fATi4ZOk=o^`@+K32R+!G~W4W+*%j6?w{PAw%Jp*>V&O2?pqy4Y+Ezgeb?r^ z*mk}xRu*5fW2!A~>z?dR3zn2nnh;7~c`8o7Eq7hT7sKbnu_xo5ho-`D*Wt-MX~CWn zDicCw+Uibwn$pg#^A^rj^)4rv%=0$R7lxjQY`QbI*SsE8GP!#Fqi!^+h5-o*CaIOU1iLd%T& zoyt__jzs4UR)pzg+THebu$<1jg6YMc&-L7{>6+}hC2W>f6L2jVyi?2)M5`3e6PUyV znZR6$1Qc-+j1Y~Y&?Y=c{2k@iWJsoo^P74kH(E-?6qO?K3lgz=iTJHVq$HJ5F0UAe zp^O-$op@4;0;8dT+QFurFY!b7tY14wZFS#FTKa{rqZ9dOXiLE{XixW`>%=|+!t(B#u#5^ zEIfrS)cTN9z~cJ24o)^K;+pQ81_^$NMknp3h)_kHgG-%*rVvU8QPPV)xe<{%3-29w z?S<##)_QFg-n6^;^F=fdZ_Yd%HO80un*4cK(Ft3MuOabLfrSV|XCt26jxisjnk+z% zqeWa0O1Q^(+0cm@mT-IPI4)A$tLt9bsxF{4bTE4Jg5cs-8AFBxi#W~>NXdpUzlK+< zG~r3j8N!c+%XuHl_DtTK^aOaB+l4qAUYJ0PTnvm#(|+> zg^%VTxU+v0{JlTZQhFTkqCa^TqDk(Kt^7h8xR%BCxn}^+siHgf;+u`%U-7*aN$>9O zt(ZG}^~9wU(@)MUp4tBDzU%vDx{@UwudPUUcPH(8;=-Qa-}3I3iJrgGwB2PLKtc}$ z6bAGuBtB7~Lm4a#bbu9n;9+1@=}BNvl?cHkk)k6MJ%%U`avl|OI;dso7^-}Rs;xy- z72LLOL(!#*X;-$tu zz%03^mvCfn6vk77j;92&hC+Cn1zDI68;>RJ%BD8h;0S=n!tK1B~6)n4q^a9oy-B0HnrWH`86 zU8Zc6&!EaMRimEuRl2jd=__Rr$%N|9r#n`C*ZysLvZm|X_Sw!a?|Wt6x9o9iWxS>f z_jgw7nrP)~SMX586skA-cgUR*kjF#B4H+S?Z7J`ngm+cayC!L08yD8z zF9dZa@c$hmPlupxhk~+1DX9VYGe2m ze@wTWH3e$nSdNhz#RB9NDNhT+{TR2HYvJ@>0EZz1DJ?$y>)E2JEl_K8t^u_MbM-;C z7Sai95-}CU96SRdB`ZQ?EEraV<1&0O0{MuTwxHPHl_8Uq98AJsXdtR0IN>%7D}D$b zoG5I>~!p? zHNIKHBhKSBRx~%_?_LvDAQuxwz7;kbX*7krOxkMd>!)cwIzFP^L->xW{m@!M`vHT2 zAf{0)2!-Bq0OoyRji{Gw%mqO-X!xy-G=PyFfFHAPABPMFptV0t^!trH($jd0!ngU& z61`F}hva^EG8i(!YJ?GtCq*EEPo9H-6qU)#02Fg*oRQUM_9TX*8xa|HqB$6|eDqmV z`ZxHKPap!Sl`NWBo+xRV+@H2rr0n$xdwt68OW0vqsH)dYSE_McqH*2r#tljD@U6xT zvB39_zIOEcpT4{{e&}%O&|`^1kEIR;5{Clur-P|OLzmZ1cV1n4Wo@EyL&7_pw2#Dv z5oj61G7RIWuk|2?TT_)2h0U?sSjmD?i_aP<8SHw17jogc2QY2;D<6ta zdm-q<&!el7ubv>n)mTXO%%U?+Mpj!ejHK^ z{IP!Vmx7ZA-f>pN_9mQ7a8p^FFP{6{xfh>@%3e}+b<3qKZ$dYFd|QT6fc!YU)Zhb)`LZDbM1BXYowqtIgM&-}bC{*Tj`J{l>~!-FM0x zW@_(=pwhuBg5` zacSb}vzMQp?f&N88+%hNTN5o?UpJ;&b|zbPrYo1ED*cH{|7_zon{PC~UAgL>iL32= z*TVVMzVG53-s#q)-4_>p_v^7_=P~VJj%g1f^aYWJL~j5^@mYhghN z`7n6nK|?-=%+Y!UyqASSwkT<}sv}S|iP-OIw}{vi5wT~rL16n{x=yL}f*mCLMj$Wg z2IRYGCrM%`WT*D(l#y+#@f^&%HN#1CUHJZ5#A)*8TZ{P`3u(-KLXNy>&rl{&3Jb?7 zv{au_tEc@oanPKtTKR)CXTPHdDv5b8GH?Rkck@_e5O$1W7?URqt$_(^Yb#7a=rR+o z(ZJKe(aqTV+{`vWs04nZKy;$)nP=MeQ=zW`rENse4d>1s`8AM%!)AhOATS26ZLmKw z9vvHxVrwIKCJN_M=wuK%5@|ku2b@wPT1@ls?UC}V>azWR*jp7H-i z27_o29YlN792*TqM?+z3#X@n%Runc_ux}Gq`3>Vsev@KBC-h3iAVtZKEMt#|Hc{aC zf^9&pNw)eZs7}N()u@BZAR0iilwnjaqt*@l$v;N~@4m}Bx#y>@hA9KA*^6DD?}`~x z_L`)U7O}xmsVkxGY^-_HLEaYnw99H*i*0%2JuIRK~U^Ep@E3yAIdnuC%B8s`HXF|f;2^>60)GrIHm2p8UW7hud(>48#S3t>-M}^8rLoAfP z5kCI_xXRH+n#@6*(E*)e41Gq7!gTb=YJ<9AKsYN+A^J6?6vA@g5bctl44hGnqu7Zw zGdM|KqH=RIav~T8dWOP-!86#-7?&j4t};Iz1PKN26L9ds6HfQD?LO&i=pv3kc^na> znzML%$%SK+yYD#M)BNEenQA2Uo>Pnqv{N~X6@kH1@)o zc*=Cw;`o9*z4pR0NlP`A?Y?YB=Ax#r%Cl>~@=S8k%Bi8xpS)?nc9Ii?(|uD~qB9Ywn&#qn;6Eq$LPRwg-PMp0D5blFD z_?S^PWb8t4#>lEdn(qT7M>TO$Sa9{a1!vU#hDObAXjE-}rnF38yx8=46+hA}BE>u& z4&eZb!VfV2YL+Kh`c-BqeFN3*;!oayh}NX!N_Wh5`CyXNjGx=FmsU3A{7FR}JXv2n zHNE)N+UvEmL#f4UE^fbcD#5&4zrST)E0YNNcU9Z1!uQRHq(8@FVPi@{NiImUfP}aK z{Kf&gF)vV+-w2l`6QD3h7=fW>p#>mRXZ02X-H8xUkC2`=4Vsuyf};b*o)3yzTQdq7 zLwJaaqF@-itrGA+x{d~LuF|*_KBSmbJSSq+*=VU|zV;7Ek(2!@#!KHumA}WI3=^E- zS6UYvdb@PVdFvFPwtJ=@eapURX8SCEeb+a-=geP!^sT0KP}pLDYfCO|zUA;iF)CX+ zZ{{2xhMCe=(Me%cTn@Mth8rNY5yTFMRu(!qNM|?uq|c~T(}b61uOAK!z?qZ5^W__6;Edk0=CIYdsHO1!orb) zz;Hzfo(T@n26Q-#lPJ=cF=rg$ibkMhNWqhlQ>0IvgmOXKXQ#u`Ybcj)BHBVTlm3#T z?`nC{Us39Ni2MSSt06jA0{%Y`{J_vU@`1UvRXu$%fz*l6=&1CcsQQ1VNKau4k9t@8 zWWDQsDtxQ-Z>bDR%SqosHFK_ao;%>F3l>#NgP+Dd|UO`cL?i+o4Z_Rak6OYhQR~I*_zf+;NxRs_2gg zMw0GO+#0%DUK=}nDKxV>S-$Li^Xy}D+iyIMQxz6l+_fcX+46Vg>y*;AbXiqweY~_O zU0E9&iB~Mse&4m5OKjMPx4YhTnoHbMmU$0!Hr1Mo4XA$EJ0;bzb;**JxTEE6ebdb0 znci#1linRKACDD(*YPdKO)=H6{c7j6<5QNncgG#OYwGlcu2^T%UW(TQR7PHSUh(dpvOs1`55;;f9gjDyNtUlcgIhI^{1`72;|}qz-TUBykgpGO zoEm!JoZ741Ir+%%-&?|!bn|RO{*BJ6ogU#$ml<(kE=f+;Pl8J_eyDQFr6hd{@*>@w z(Yf40$~Waxeui$%j7y9V-_TpuF)m_ZT*UfOxzxuVUs3+}vfG?lsT?-u@Je!uu#h#; za=9fqrHze!{l|qF9kgtRB9)a7h2CC>Q{&w#cOU<_?}of{<--Gf-Te_{)BX7HQDl>+ zz^d!;E;Qo|HWjmWe+-r`kWU4qNr^5^1UgU1%*+#{T*BPMFKsf7%ThT|gMcyC{7>tl(nkA30vt%}aMbxHT8xb>;{6Hg&#^1_$2 z_;i*$^JucXow4wh$(q%3!^x5@amSWIO!-#Lr{hlyBufV4j=^`lOJ`1DS9{6=F?GA5 zZT57sV%^P(qABfnz&<)+BjL!F}Ez~-Wa!T)OdY? zrnhP~-h_bJ8F%bVe{}hjJ2_oG4ZrYQddDNF9Y+#7j>P+prgl7@*zq_)jo{$k61N88 z{Q<;xRjE^~OPwFKRPAmOepF;eTv+P-0~YZUmOA?iNC$yQ2U7t)K?N)n;uDY#x{j;W zgZ6LMv|?QbEtFo+{&P8ThTo{tN#(L1qhE4c=5u&(S$l@dE;N-tT>l!E%`uh30^}A} zMGaZUqv-gk&|AO_VRrkS8E$BlE~0-;lOBnjR-bkLrE)wIV`7ZSOyGq0seqKji*8W| z1z1y_B05Bsm}zfXpor#h9YO&z?=wEXk}T8FkFiKe)S&|!@;@L^Kml!=vn1W?NH52T>)y3e?*WqOZT@h42hA6pNCF0w#(%+Z5%swhb92GPuL z{A0fx7iz5e6q@sH+8|-!0q-vQqqPxS09?p*hz9+3o$9t@0F3HX3z9vOav}XsikMZI zB`Ids8Hw_6iz7>FFh8$TZE}C9mfmqnMG-+G_;X5sm7;Bk@{iROXDKVQ@h82FN{<11 zWKv=Z(3K`(w&c-O2zFl~YPeOi{^qh|$&R>VM?sPK5u|NR9A)+$xgJ zN@>7CW%L4Fdts#$Hl}&9bQa~ZDL5JzAm3Z)B_GXPSu~?tcG6E#BTP6ZA@QMb30Q(J{3Sh5!gM(TFXt(8IiA8TL%Q`wSSn#-LN!<6a(n~n4?y?Y zacjQ;Udr$XWP@r~5>z(fL|5Zv(~jegm_&Q8(l02Y4y2zULb*(@lCfiqS5Xp|_M#`p z&!A**fwd|@Y3CMP`(cJSyD72Fc|438?GMFfV2^C|Erc6{#OMp`^PlZf&EimL&K!oU^AcY>F+$PCdpR+cmQ}UPF$6l1*{Prn|1v z>BHwkQ^vI2MWC4NnEuR^D_4xJU$>Pq* zeLwY7PYHLd&Z)sawc|oVag7QF)Yk(#P{Ba6dx;z7anB&|3UV6>b8)+q-MF{e*ahEa zzwa5F&&Mv1`dR}%d3<;nSCB!~GO}}?Ha9mkJ=45x$luhw@M3V@&01gPVy&;Csk^VY zcURAm24vys8MecIY}=FP8hk6av}3!WJv=@->Oa@ux8YTxAzyQ|uj!e_oOe=X-xgno z&+p&li;e`tsOMu>eR0Cg*Lpb9@q!i&td@Fu#n*v{p-|=p$Btnf>M$~6d$Pf|d9!bM z`*Pp2&lWCgdq~-H%Y9EinKLrlu?`a4VB@mOlR2-?WOUlj+1Td@XdTom)&x$QdaZ6h zR*WsEdMw71bNjZRPVz{k*M5sIgfNnB*OfX1)<6;HK6T!PHZ4CR2OAoI9Pp@yLd1~0 z@la?ZK@;k9Z>f;0Mk{}4TSGmE6JEN4qm2fFk=GOtkQzj@8X)orlM%3ed>Y%nkf;O zS@S`hqY94Zlepq$tBG^!{n&`?mjg-z=5avT7h+)2la zoTn!0V1GeB?%~QrZb-tOBFCCpN`BMM1ce;Ce z&D2EPx@N8s5w2ab^G0Cd-s$xh&fT)s(%s9t59CU`_zh#z_T}6g%{;}+%XS!yZ}L21 z#hnp!#0%(vXdZHz(UKt-@vJ;vn{&M-dxTiMREgMdjL?z=JQ-J+VK)ms6IL7h9>C`2 zi50#kmh{^K(WRonKCy=j@}AU6+SS?o7_xSJmVoE1Y& z>TyBP=rC+Nh~p`sCR!#I`i#uj_!87w>w>&XOf}W%PAf%Cy|qS(Fl&s3&g$p%Dd+-} zlwZR}=x>3~;0>ni-KK19t>0GYsZf@UIQ=CE#3gidB0CyU=Lckl9bH2XSxSY?M+cZ; zfK?mDagkn#<%OZpSx=l)#aY!21|PvvHz=|#`*ioSj~m9=dUcZ`TPHgv&14z`%rFmj zG>9Ex;c>Ff7`A+J<2t&IX5GgQ5oz$F5&A7X(x0J3Rs8=Msoh6z;2RzIBm>UB$%>U- z)as9%@H-fmOQap8&1(#TrWQNx#v=riUj;s-l~+hG2v}YdY7EH-1U}D zS>CafOrM4#`qhnd70Jf+P>QcQE;(L#B8dZ>rnIZ<;^_I&S2oU!Cta@+6_f`ak`=w z+hP}c&iAAozJ$X!TY6K7Q))A^Q;zzCqds=}wqsekqyh(0QyyQ!d6p(TOJ|qO z^?tqee8bHn^eGBEeVIZ1n8Zx!oWMQ_v4(wpqC6p#PoKVYZsv5NqV2ZOp0>MH9k3v` z`76LGpW76Y{u&-Vl&3%;E+qT$5eOVX7XVgGO-y=m0sb^`lgviJb2RppOE4L3_tT=Pl$oBzEL-H+=-)RyiXu_X+vq zKj_LSwJa>nDYI(hp|SgmdX7l{2eVd8Oi$Fm?Gqfl|7$lJeG@+M1bsqhQ2RWN_O+d6 zW}Hg{{t0g%`~J>g=G$S?09w`QAL$8<5-T{1zUez(puIs9uj_>|fZKGLCK1VpZS9S| zKPa00HdU)Kf>}uah)GCBAfhBt^ecM&C7W0{0s_aWt^tXN8>V>HQesRbM+ITJHRCVf zDERi#aBy&;X)bM~o+Zd=#CgV)%)-x7A-Ptc?_F@0+}61fDO^}*dwRGMXKVBlte zhdM+s22RljhjMix3EMFoiyYu2GEp3$CluYbRVQt=ZwYm5Q0kWe)+v^L92FSo@0Uoe zkirywfucV}L^3BjG8%eX#Q`%uuM`RoOEd+ElopBf3W+QjrG$*1HYl372QetOK}^8o zMB+g4WJ;tUiU}jxmsBJhmASKJ^d?!o3I#sENxBe|?$Tp7MgK(6BNQE==paRpQ$!|_ z>MXB+R+%8{I}7WfdaRiN~{t}1@<%+c9_>rc*e zNZuTLebsBBf2QR8`X;_U7Ma6wa()uuDM3`bF=BD z{2Jzj9K3cZs+^fMUOP9>AvuTs@pCi!diQJSAIbS%zKnOxSZ7PFV`NC)1dQ?Xy70RA zM$!9}obNys8<~->on%FG18PM!-AoM|YOKd}H_vlO&aGlUH&?%Id~L(~lrvx5%Dblb z#>7hp<~by1F;@J{HOW)&@Y-F&Hq sH_sfQEHi-+Kl7#K0FW@x;b$(&eqI;er>FN;mhijydo4U)&#?3V0hF8`B>(^b literal 0 HcmV?d00001 diff --git a/commands/__pycache__/container.cpython-313.pyc b/commands/__pycache__/container.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c6eeef7365e9d6954782825b7cfa3fb2abe5188 GIT binary patch literal 17841 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 ZN!qL#(qIdW*BvbMnCkpH3S)w;{|!PErZWHl literal 0 HcmV?d00001 diff --git a/commands/__pycache__/dotfiles.cpython-313.pyc b/commands/__pycache__/dotfiles.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aab5461ea5c277f66637216f664964914a08bf72 GIT binary patch literal 23326 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 G@c#hDel6|* literal 0 HcmV?d00001 diff --git a/commands/__pycache__/enter.cpython-313.pyc b/commands/__pycache__/enter.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8aa927da48e4b2392301950767d2e4bc05a1adc8 GIT binary patch literal 5802 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 M?BtIG?hQfy4@aiDv;Y7A literal 0 HcmV?d00001 diff --git a/commands/__pycache__/package.cpython-313.pyc b/commands/__pycache__/package.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e9d41b8c384dbbe5f414eb2365a801bc22af8a94 GIT binary patch literal 8898 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%0D2BjPbW#gKNtWw6-v4j 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 dict: + profiles = ctx.manifest.get("profiles") + if profiles is None: + if "environments" in ctx.manifest: + raise RuntimeError( + "Manifest key 'environments' is no longer supported. Rename it to 'profiles'." + ) + return {} + + if not isinstance(profiles, dict): + raise RuntimeError("Manifest key 'profiles' must be a mapping") + + return profiles + + +def _parse_variables(var_args: list) -> dict: + variables = {} + for v in var_args: + if "=" not in v: + raise ValueError(f"Invalid --var value '{v}'. Expected KEY=VALUE") + key, value = v.split("=", 1) + if not key: + raise ValueError(f"Invalid --var value '{v}'. KEY cannot be empty") + variables[key] = value + return variables + + +def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variables: dict) -> List[Action]: + """Plan all actions from a profile configuration.""" + actions = [] + + # Variable checks + for req_var in env_config.get("requires", []): + actions.append(Action( + type="check-variable", + description=f"Check required variable: {req_var}", + data={"variable": req_var, "variables": variables}, + skip_on_error=False, + )) + + # Hostname + if "hostname" in env_config: + hostname = substitute(env_config["hostname"], variables) + actions.append(Action( + type="set-hostname", + description=f"Set hostname to: {hostname}", + data={"hostname": hostname}, + skip_on_error=False, + )) + + # Locale + if "locale" in env_config: + actions.append(Action( + type="set-locale", + description=f"Set locale to: {env_config['locale']}", + data={"locale": env_config["locale"]}, + skip_on_error=True, + os_filter="linux", + )) + + # Shell + if "shell" in env_config: + actions.append(Action( + type="set-shell", + description=f"Set shell to: {env_config['shell']}", + data={"shell": env_config["shell"]}, + skip_on_error=True, + os_filter="linux", + )) + + # Packages + if "packages" in env_config: + packages_config = env_config["packages"] + pm = env_config.get("package-manager", "apt-get") + + # Package manager update + actions.append(Action( + type="pm-update", + description=f"Update {pm} package repositories", + data={"pm": pm}, + skip_on_error=False, + )) + + # Standard packages + standard = [] + for pkg in packages_config.get("standard", []) + packages_config.get("package", []): + if isinstance(pkg, str): + standard.append(pkg) + else: + standard.append(pkg["name"]) + + if standard: + actions.append(Action( + type="install-packages", + description=f"Install {len(standard)} packages via {pm}", + data={"pm": pm, "packages": standard, "type": "standard"}, + skip_on_error=False, + )) + + # Cask packages (macOS) + cask = [] + for pkg in packages_config.get("cask", []): + if isinstance(pkg, str): + cask.append(pkg) + else: + cask.append(pkg["name"]) + + if cask: + actions.append(Action( + type="install-packages", + description=f"Install {len(cask)} cask packages via {pm}", + data={"pm": pm, "packages": cask, "type": "cask"}, + skip_on_error=False, + os_filter="macos", + )) + + # Binary packages + binaries_manifest = ctx.manifest.get("binaries", {}) + for pkg in packages_config.get("binary", []): + pkg_name = pkg if isinstance(pkg, str) else pkg["name"] + binary_def = binaries_manifest.get(pkg_name, {}) + actions.append(Action( + type="install-binary", + description=f"Install binary: {pkg_name}", + data={"name": pkg_name, "definition": binary_def, "spec": pkg if isinstance(pkg, dict) else {}}, + skip_on_error=True, + )) + + # SSH keygen + for ssh_config in env_config.get("ssh_keygen", []): + filename = ssh_config.get("filename", f"id_{ssh_config['type']}") + actions.append(Action( + type="generate-ssh-key", + description=f"Generate SSH key: {filename}", + data=ssh_config, + skip_on_error=True, + )) + + # Config linking + for config in env_config.get("configs", []): + config_name = config if isinstance(config, str) else config["name"] + actions.append(Action( + type="link-config", + description=f"Link configuration: {config_name}", + data={"config_name": config_name}, + skip_on_error=True, + )) + + # Custom commands + for i, command in enumerate(env_config.get("runcmd", []), 1): + actions.append(Action( + type="run-command", + description=f"Run custom command {i}", + data={"command": command}, + skip_on_error=True, + )) + + return actions + + +def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: dict): + """Register all action type handlers.""" + + def handle_check_variable(data): + var = data["variable"] + if var not in data.get("variables", {}): + raise RuntimeError(f"Required variable not set: {var}") + + def handle_set_hostname(data): + hostname = 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) + else: + run_command(f"sudo hostnamectl set-hostname '{hostname}'", ctx.console) + + def handle_set_locale(data): + locale = data["locale"] + run_command(f"sudo locale-gen {locale}", ctx.console) + run_command(f"sudo update-locale LANG={locale}", ctx.console) + + def handle_set_shell(data): + shell = data["shell"] + shell_path = shutil.which(shell) + if not shell_path: + raise RuntimeError(f"Shell not found: {shell}") + 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) + except FileNotFoundError: + pass + run_command(f"chsh -s {shell_path}", ctx.console) + + def handle_pm_update(data): + pm = data["pm"] + commands = { + "apt-get": "sudo apt-get update -qq", + "apt": "sudo apt update -qq", + "brew": "brew update", + } + cmd = commands.get(pm, f"sudo {pm} update") + run_command(cmd, ctx.console) + + def handle_install_packages(data): + pm = data["pm"] + packages = data["packages"] + pkg_type = data.get("type", "standard") + pkg_str = " ".join(packages) + + if pm in ("apt-get", "apt"): + cmd = f"sudo {pm} install -y {pkg_str}" + elif pm == "brew" and pkg_type == "cask": + cmd = f"brew install --cask {pkg_str}" + elif pm == "brew": + cmd = f"brew install {pkg_str}" + else: + cmd = f"sudo {pm} install {pkg_str}" + + run_command(cmd, ctx.console) + + def handle_install_binary(data): + from flow.core.variables import substitute_template + pkg_name = data["name"] + pkg_def = data["definition"] + if not pkg_def: + raise RuntimeError(f"No binary definition for: {pkg_name}") + + source = pkg_def.get("source", "") + if not source.startswith("github:"): + raise RuntimeError(f"Unsupported source: {source}") + + owner_repo = source[len("github:"):] + version = pkg_def.get("version", "") + asset_pattern = pkg_def.get("asset-pattern", "") + platform_map = pkg_def.get("platform-map", {}) + mapping = platform_map.get(ctx.platform.platform) + if not mapping: + raise RuntimeError(f"No platform mapping for {ctx.platform.platform}") + + template_ctx = {**mapping, "version": version} + asset = substitute_template(asset_pattern, template_ctx) + url = f"https://github.com/{owner_repo}/releases/download/v{version}/{asset}" + template_ctx["downloadUrl"] = url + + install_script = pkg_def.get("install-script", "") + if install_script: + resolved = substitute_template(install_script, template_ctx) + run_command(resolved, ctx.console) + + def handle_generate_ssh_key(data): + ssh_dir = Path.home() / ".ssh" + ssh_dir.mkdir(mode=0o700, exist_ok=True) + key_type = data["type"] + comment = substitute(data.get("comment", ""), variables) + filename = data.get("filename", f"id_{key_type}") + key_path = ssh_dir / filename + 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) + + def handle_link_config(data): + config_name = data["config_name"] + ctx.console.info(f"Linking config: {config_name}") + + def handle_run_command(data): + command = substitute(data["command"], variables) + run_command(command, ctx.console) + + executor.register("check-variable", handle_check_variable) + executor.register("set-hostname", handle_set_hostname) + executor.register("set-locale", handle_set_locale) + executor.register("set-shell", handle_set_shell) + executor.register("pm-update", handle_pm_update) + executor.register("install-packages", handle_install_packages) + executor.register("install-binary", handle_install_binary) + executor.register("generate-ssh-key", handle_generate_ssh_key) + executor.register("link-config", handle_link_config) + executor.register("run-command", handle_run_command) + + +def run_bootstrap(ctx: FlowContext, args): + # Check if flow package exists in dotfiles and link it first + 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, + ) + if result.returncode == 0: + 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}") + + profiles = _get_profiles(ctx) + if not profiles: + ctx.console.error("No profiles found in manifest.") + sys.exit(1) + + profile_name = args.profile + if not profile_name: + if len(profiles) == 1: + profile_name = next(iter(profiles)) + else: + ctx.console.error(f"Multiple profiles available. Specify with --profile: {', '.join(profiles.keys())}") + sys.exit(1) + + if profile_name not in profiles: + ctx.console.error(f"Profile not found: {profile_name}. Available: {', '.join(profiles.keys())}") + sys.exit(1) + + env_config = profiles[profile_name] + + profile_os = env_config.get("os") + if profile_os and profile_os != ctx.platform.os: + ctx.console.error( + f"Profile '{profile_name}' targets '{profile_os}', current OS is '{ctx.platform.os}'" + ) + sys.exit(1) + + try: + variables = _parse_variables(args.var) + except ValueError as e: + ctx.console.error(str(e)) + sys.exit(1) + + actions = _plan_actions(ctx, profile_name, env_config, variables) + executor = ActionExecutor(ctx.console) + _register_handlers(executor, ctx, variables) + executor.execute(actions, dry_run=args.dry_run, current_os=ctx.platform.os) + + +def run_list(ctx: FlowContext, args): + profiles = _get_profiles(ctx) + if not profiles: + ctx.console.info("No profiles defined in manifest.") + return + + headers = ["PROFILE", "OS", "PACKAGES", "ACTIONS"] + rows = [] + for name, config in sorted(profiles.items()): + os_name = config.get("os", "any") + pkg_count = 0 + for section in config.get("packages", {}).values(): + if isinstance(section, list): + pkg_count += len(section) + action_count = sum(1 for k in ("hostname", "locale", "shell") if k in config) + action_count += len(config.get("ssh_keygen", [])) + action_count += len(config.get("configs", [])) + action_count += len(config.get("runcmd", [])) + rows.append([name, os_name, str(pkg_count), str(action_count)]) + + ctx.console.table(headers, rows) + + +def run_show(ctx: FlowContext, args): + profiles = _get_profiles(ctx) + profile_name = args.profile + + if profile_name not in profiles: + ctx.console.error(f"Profile not found: {profile_name}. Available: {', '.join(profiles.keys())}") + sys.exit(1) + + env_config = profiles[profile_name] + variables = {} + actions = _plan_actions(ctx, profile_name, env_config, variables) + + executor = ActionExecutor(ctx.console) + executor.execute(actions, dry_run=True) diff --git a/commands/completion.py b/commands/completion.py new file mode 100644 index 0000000..cfd28f6 --- /dev/null +++ b/commands/completion.py @@ -0,0 +1,525 @@ +"""flow completion — shell completion support (dynamic zsh).""" + +import argparse +import json +import shutil +import subprocess +from pathlib import Path +from typing import List, Optional, Sequence, Set + +from flow.commands.enter import HOST_TEMPLATES +from flow.core.config import load_config, load_manifest +from flow.core.paths import DOTFILES_DIR, INSTALLED_STATE + +ZSH_RC_START = "# >>> flow completion >>>" +ZSH_RC_END = "# <<< flow completion <<<" + +TOP_LEVEL_COMMANDS = [ + "enter", + "dev", + "dotfiles", + "dot", + "bootstrap", + "setup", + "provision", + "package", + "pkg", + "sync", + "completion", +] + + +def register(subparsers): + p = subparsers.add_parser("completion", help="Shell completion helpers") + sub = p.add_subparsers(dest="completion_command") + + zsh = sub.add_parser("zsh", help="Print zsh completion script") + zsh.set_defaults(handler=run_zsh_script) + + install = sub.add_parser("install-zsh", help="Install zsh completion script") + install.add_argument( + "--dir", + default="~/.zsh/completions", + help="Directory where _flow completion file is written", + ) + install.add_argument( + "--rc", + default="~/.zshrc", + help="Shell rc file to update with fpath/compinit snippet", + ) + install.add_argument( + "--no-rc", + action="store_true", + help="Do not modify rc file; only write completion script", + ) + install.set_defaults(handler=run_install_zsh) + + hidden = sub.add_parser("_zsh_complete", help=argparse.SUPPRESS) + hidden.add_argument("--cword", type=int, required=True, help=argparse.SUPPRESS) + hidden.add_argument("words", nargs="*", help=argparse.SUPPRESS) + hidden.set_defaults(handler=run_zsh_complete) + + p.set_defaults(handler=lambda _ctx, args: p.print_help()) + + +def _canonical_command(command: str) -> str: + alias_map = { + "dot": "dotfiles", + "setup": "bootstrap", + "provision": "bootstrap", + "pkg": "package", + } + return alias_map.get(command, command) + + +def _safe_config(): + try: + return load_config() + except Exception: + return None + + +def _safe_manifest(): + try: + return load_manifest() + except Exception: + return {} + + +def _list_targets() -> List[str]: + cfg = _safe_config() + if cfg is None: + return [] + return sorted({f"{t.namespace}@{t.platform}" for t in cfg.targets}) + + +def _list_namespaces() -> List[str]: + cfg = _safe_config() + if cfg is None: + return [] + return sorted({t.namespace for t in cfg.targets}) + + +def _list_platforms() -> List[str]: + cfg = _safe_config() + config_platforms: Set[str] = set() + if cfg is not None: + config_platforms = {t.platform for t in cfg.targets} + return sorted(set(HOST_TEMPLATES.keys()) | config_platforms) + + +def _list_bootstrap_profiles() -> List[str]: + manifest = _safe_manifest() + return sorted(manifest.get("profiles", {}).keys()) + + +def _list_manifest_packages() -> List[str]: + manifest = _safe_manifest() + return sorted(manifest.get("binaries", {}).keys()) + + +def _list_installed_packages() -> List[str]: + if not INSTALLED_STATE.exists(): + return [] + try: + with open(INSTALLED_STATE) as f: + state = json.load(f) + except Exception: + return [] + if not isinstance(state, dict): + return [] + return sorted(state.keys()) + + +def _list_dotfiles_profiles() -> List[str]: + profiles_dir = DOTFILES_DIR / "profiles" + if not profiles_dir.is_dir(): + return [] + return sorted([p.name for p in profiles_dir.iterdir() if p.is_dir() and not p.name.startswith(".")]) + + +def _list_dotfiles_packages(profile: Optional[str] = None) -> List[str]: + package_names: Set[str] = set() + + common = DOTFILES_DIR / "common" + if common.is_dir(): + for pkg in common.iterdir(): + if pkg.is_dir() and not pkg.name.startswith("."): + package_names.add(pkg.name) + + if profile: + profile_dir = DOTFILES_DIR / "profiles" / profile + if profile_dir.is_dir(): + for pkg in profile_dir.iterdir(): + if pkg.is_dir() and not pkg.name.startswith("."): + package_names.add(pkg.name) + else: + profiles_dir = DOTFILES_DIR / "profiles" + if profiles_dir.is_dir(): + for profile_dir in profiles_dir.iterdir(): + if not profile_dir.is_dir(): + continue + for pkg in profile_dir.iterdir(): + if pkg.is_dir() and not pkg.name.startswith("."): + package_names.add(pkg.name) + + return sorted(package_names) + + +def _list_container_names() -> List[str]: + runtime = None + for rt in ("docker", "podman"): + if shutil.which(rt): + runtime = rt + break + + if not runtime: + return [] + + try: + result = subprocess.run( + [ + runtime, + "ps", + "-a", + "--filter", + "label=dev=true", + "--format", + '{{.Label "dev.name"}}', + ], + capture_output=True, + text=True, + timeout=1, + ) + except Exception: + return [] + + if result.returncode != 0: + return [] + + names = [] + for line in result.stdout.splitlines(): + line = line.strip() + if line: + names.append(line) + return sorted(set(names)) + + +def _split_words(words: Sequence[str], cword: int): + tokens = list(words) + index = max(0, cword - 1) + + if tokens: + tokens = tokens[1:] + index = max(0, cword - 2) + + if index > len(tokens): + index = len(tokens) + + current = tokens[index] if index < len(tokens) else "" + before = tokens[:index] + return before, current + + +def _filter(candidates: Sequence[str], prefix: str) -> List[str]: + unique = sorted(set(candidates)) + if not prefix: + return unique + return [c for c in unique if c.startswith(prefix)] + + +def _profile_from_before(before: Sequence[str]) -> Optional[str]: + for i, token in enumerate(before): + if token == "--profile" and i + 1 < len(before): + return before[i + 1] + return None + + +def _complete_dev(before: Sequence[str], current: str) -> List[str]: + if len(before) <= 1: + return _filter(["create", "exec", "connect", "list", "stop", "remove", "rm", "respawn"], current) + + sub = "remove" if before[1] == "rm" else before[1] + + if sub in {"remove", "stop", "connect", "exec", "respawn"}: + options = { + "remove": ["-f", "--force", "-h", "--help"], + "stop": ["--kill", "-h", "--help"], + "exec": ["-h", "--help"], + "connect": ["-h", "--help"], + "respawn": ["-h", "--help"], + }[sub] + + if current.startswith("-"): + return _filter(options, current) + + non_opt = [t for t in before[2:] if not t.startswith("-")] + if len(non_opt) == 0: + return _filter(_list_container_names(), current) + return [] + + if sub == "create": + options = ["-i", "--image", "-p", "--project", "-h", "--help"] + if before and before[-1] in ("-i", "--image"): + return _filter(["tm0/node", "docker/python", "docker/alpine"], current) + + if current.startswith("-"): + return _filter(options, current) + + return [] + + if sub == "list": + return [] + + return [] + + +def _complete_dotfiles(before: Sequence[str], current: str) -> List[str]: + if len(before) <= 1: + return _filter( + ["init", "link", "unlink", "status", "sync", "relink", "clean", "edit"], + current, + ) + + sub = before[1] + + if sub == "init": + return _filter(["--repo", "-h", "--help"], current) if current.startswith("-") else [] + + if sub in {"link", "relink"}: + if before and before[-1] == "--profile": + return _filter(_list_dotfiles_profiles(), current) + + if current.startswith("-"): + return _filter(["--profile", "--copy", "--force", "--dry-run", "-h", "--help"], current) + + profile = _profile_from_before(before) + return _filter(_list_dotfiles_packages(profile), current) + + if sub == "unlink": + if current.startswith("-"): + return _filter(["-h", "--help"], current) + return _filter(_list_dotfiles_packages(), current) + + if sub == "edit": + if current.startswith("-"): + return _filter(["--no-commit", "-h", "--help"], current) + non_opt = [t for t in before[2:] if not t.startswith("-")] + if len(non_opt) == 0: + return _filter(_list_dotfiles_packages(), current) + return [] + + if sub == "clean": + return _filter(["--dry-run", "-h", "--help"], current) if current.startswith("-") else [] + + return [] + + +def _complete_bootstrap(before: Sequence[str], current: str) -> List[str]: + if len(before) <= 1: + return _filter(["run", "list", "show"], current) + + sub = before[1] + + if sub == "run": + if before and before[-1] == "--profile": + return _filter(_list_bootstrap_profiles(), current) + if current.startswith("-"): + return _filter(["--profile", "--dry-run", "--var", "-h", "--help"], current) + return [] + + if sub == "show": + if current.startswith("-"): + return _filter(["-h", "--help"], current) + non_opt = [t for t in before[2:] if not t.startswith("-")] + if len(non_opt) == 0: + return _filter(_list_bootstrap_profiles(), current) + return [] + + return [] + + +def _complete_package(before: Sequence[str], current: str) -> List[str]: + if len(before) <= 1: + return _filter(["install", "list", "remove"], current) + + sub = before[1] + + if sub == "install": + if current.startswith("-"): + return _filter(["--dry-run", "-h", "--help"], current) + return _filter(_list_manifest_packages(), current) + + if sub == "remove": + if current.startswith("-"): + return _filter(["-h", "--help"], current) + return _filter(_list_installed_packages(), current) + + if sub == "list": + if current.startswith("-"): + return _filter(["--all", "-h", "--help"], current) + return [] + + return [] + + +def _complete_sync(before: Sequence[str], current: str) -> List[str]: + if len(before) <= 1: + return _filter(["check", "fetch", "summary"], current) + + sub = before[1] + if sub == "check" and current.startswith("-"): + return _filter(["--fetch", "--no-fetch", "-h", "--help"], current) + + if current.startswith("-"): + return _filter(["-h", "--help"], current) + return [] + + +def complete(words: Sequence[str], cword: int) -> List[str]: + before, current = _split_words(words, cword) + + if not before: + return _filter(TOP_LEVEL_COMMANDS + ["-h", "--help", "-v", "--version"], current) + + command = _canonical_command(before[0]) + + if command == "enter": + if before and before[-1] in ("-p", "--platform"): + return _filter(_list_platforms(), current) + if before and before[-1] in ("-n", "--namespace"): + return _filter(_list_namespaces(), current) + if current.startswith("-"): + return _filter( + ["-u", "--user", "-n", "--namespace", "-p", "--platform", "-s", "--session", "--no-tmux", "-d", "--dry-run", "-h", "--help"], + current, + ) + return _filter(_list_targets(), current) + + if command == "dev": + return _complete_dev(before, current) + + if command == "dotfiles": + return _complete_dotfiles(before, current) + + if command == "bootstrap": + return _complete_bootstrap(before, current) + + if command == "package": + return _complete_package(before, current) + + if command == "sync": + return _complete_sync(before, current) + + if command == "completion": + if len(before) <= 1: + return _filter(["zsh", "install-zsh"], current) + + sub = before[1] + if sub == "install-zsh" and current.startswith("-"): + return _filter(["--dir", "--rc", "--no-rc", "-h", "--help"], current) + + return [] + + return [] + + +def run_zsh_complete(_ctx, args): + candidates = complete(args.words, args.cword) + for item in candidates: + print(item) + + +def _zsh_script_text() -> str: + return r'''#compdef flow + +_flow() { + local -a suggestions + suggestions=("${(@f)$(flow completion _zsh_complete --cword "$CURRENT" -- "${words[@]}" 2>/dev/null)}") + + if (( ${#suggestions[@]} > 0 )); then + compadd -Q -- "${suggestions[@]}" + return 0 + fi + + if [[ "$words[CURRENT]" == */* || "$words[CURRENT]" == ./* || "$words[CURRENT]" == ~* ]]; then + _files + return 0 + fi + + return 1 +} + +compdef _flow flow +''' + + +def _zsh_dir_for_rc(path: Path) -> str: + home = Path.home().resolve() + resolved = path.expanduser().resolve() + try: + rel = resolved.relative_to(home) + return f"~/{rel}" if str(rel) != "." else "~" + except ValueError: + return str(resolved) + + +def _zsh_rc_snippet(completions_dir: Path) -> str: + dir_expr = _zsh_dir_for_rc(completions_dir) + return ( + f"{ZSH_RC_START}\n" + f"fpath=({dir_expr} $fpath)\n" + "autoload -Uz compinit && compinit\n" + f"{ZSH_RC_END}\n" + ) + + +def _ensure_rc_snippet(rc_path: Path, completions_dir: Path) -> bool: + snippet = _zsh_rc_snippet(completions_dir) + if rc_path.exists(): + content = rc_path.read_text() + else: + content = "" + + if ZSH_RC_START in content and ZSH_RC_END in content: + start = content.find(ZSH_RC_START) + end = content.find(ZSH_RC_END, start) + if end >= 0: + end += len(ZSH_RC_END) + updated = content[:start] + snippet.rstrip("\n") + content[end:] + if updated == content: + return False + rc_path.parent.mkdir(parents=True, exist_ok=True) + rc_path.write_text(updated) + return True + + sep = "" if content.endswith("\n") or content == "" else "\n" + rc_path.parent.mkdir(parents=True, exist_ok=True) + rc_path.write_text(content + sep + snippet) + return True + + +def run_install_zsh(_ctx, args): + completions_dir = Path(args.dir).expanduser() + completions_dir.mkdir(parents=True, exist_ok=True) + + completion_file = completions_dir / "_flow" + completion_file.write_text(_zsh_script_text()) + print(f"Installed completion script: {completion_file}") + + if args.no_rc: + print("Skipped rc file update (--no-rc)") + return + + rc_path = Path(args.rc).expanduser() + changed = _ensure_rc_snippet(rc_path, completions_dir) + if changed: + print(f"Updated shell rc: {rc_path}") + else: + print(f"Shell rc already configured: {rc_path}") + + print("Restart shell or run: autoload -Uz compinit && compinit") + + +def run_zsh_script(_ctx, _args): + print(_zsh_script_text()) diff --git a/commands/container.py b/commands/container.py new file mode 100644 index 0000000..a97efc5 --- /dev/null +++ b/commands/container.py @@ -0,0 +1,349 @@ +"""flow dev — container management.""" + +import os +import shutil +import subprocess +import sys + +from flow.core.config import FlowContext + +DEFAULT_REGISTRY = "registry.tomastm.com" +DEFAULT_TAG = "latest" +CONTAINER_HOME = "/home/dev" + + +def register(subparsers): + p = subparsers.add_parser("dev", help="Manage development containers") + sub = p.add_subparsers(dest="dev_command") + + # create + create = sub.add_parser("create", help="Create and start a development container") + create.add_argument("name", help="Container name") + create.add_argument("-i", "--image", required=True, help="Container image") + create.add_argument("-p", "--project", help="Path to project directory") + create.set_defaults(handler=run_create) + + # exec + exec_cmd = sub.add_parser("exec", help="Execute command in a container") + exec_cmd.add_argument("name", help="Container name") + exec_cmd.add_argument("cmd", nargs="*", help="Command to run (default: interactive shell)") + exec_cmd.set_defaults(handler=run_exec) + + # connect + connect = sub.add_parser("connect", help="Attach to container tmux session") + connect.add_argument("name", help="Container name") + connect.set_defaults(handler=run_connect) + + # list + ls = sub.add_parser("list", help="List development containers") + ls.set_defaults(handler=run_list) + + # stop + stop = sub.add_parser("stop", help="Stop a development container") + stop.add_argument("name", help="Container name") + stop.add_argument("--kill", action="store_true", help="Kill instead of graceful stop") + stop.set_defaults(handler=run_stop) + + # remove + remove = sub.add_parser("remove", aliases=["rm"], help="Remove a development container") + remove.add_argument("name", help="Container name") + remove.add_argument("-f", "--force", action="store_true", help="Force removal") + remove.set_defaults(handler=run_remove) + + # respawn + respawn = sub.add_parser("respawn", help="Respawn all tmux panes for a session") + respawn.add_argument("name", help="Session/container name") + respawn.set_defaults(handler=run_respawn) + + p.set_defaults(handler=lambda ctx, args: p.print_help()) + + +def _runtime(): + for rt in ("docker", "podman"): + if shutil.which(rt): + return rt + raise RuntimeError("No container runtime found (docker or podman)") + + +def _cname(name: str) -> str: + """Normalize to dev- prefix.""" + return name if name.startswith("dev-") else f"dev-{name}" + + +def _parse_image_ref( + image: str, + *, + default_registry: str = DEFAULT_REGISTRY, + default_tag: str = DEFAULT_TAG, +): + """Parse image shorthand into (full_ref, repo, tag, label).""" + registry = default_registry + tag = default_tag + + if image.startswith("docker/"): + registry = "docker.io" + image = f"library/{image.split('/', 1)[1]}" + elif image.startswith("tm0/"): + registry = default_registry + image = image.split("/", 1)[1] + elif "/" in image: + prefix, remainder = image.split("/", 1) + if "." in prefix or ":" in prefix or prefix == "localhost": + registry = prefix + image = remainder + + if ":" in image.split("/")[-1]: + tag = image.rsplit(":", 1)[1] + image = image.rsplit(":", 1)[0] + + repo = image + full_ref = f"{registry}/{repo}:{tag}" + label_prefix = registry.rsplit(".", 1)[0].rsplit(".", 1)[-1] if "." in registry else registry + label = f"{label_prefix}/{repo.split('/')[-1]}" + + return full_ref, repo, tag, label + + +def _container_exists(rt: str, cname: str) -> bool: + result = subprocess.run( + [rt, "container", "ls", "-a", "--format", "{{.Names}}"], + capture_output=True, text=True, + ) + return cname in result.stdout.strip().split("\n") + + +def _container_running(rt: str, cname: str) -> bool: + result = subprocess.run( + [rt, "container", "ls", "--format", "{{.Names}}"], + capture_output=True, text=True, + ) + return cname in result.stdout.strip().split("\n") + + +def run_create(ctx: FlowContext, args): + rt = _runtime() + cname = _cname(args.name) + + if _container_exists(rt, cname): + ctx.console.error(f"Container already exists: {cname}") + sys.exit(1) + + project_path = os.path.realpath(args.project) if args.project else None + if project_path and not os.path.isdir(project_path): + ctx.console.error(f"Invalid project path: {project_path}") + sys.exit(1) + + full_ref, _, _, _ = _parse_image_ref( + args.image, + default_registry=ctx.config.container_registry, + default_tag=ctx.config.container_tag, + ) + + cmd = [ + rt, "run", "-d", + "--name", cname, + "--label", "dev=true", + "--label", f"dev.name={args.name}", + "--label", f"dev.image_ref={full_ref}", + "--network", "host", + "--init", + ] + + if project_path: + cmd.extend(["-v", f"{project_path}:/workspace"]) + cmd.extend(["--label", f"dev.project_path={project_path}"]) + + docker_sock = "/var/run/docker.sock" + if os.path.exists(docker_sock): + cmd.extend(["-v", f"{docker_sock}:{docker_sock}"]) + + home = os.path.expanduser("~") + if os.path.isdir(f"{home}/.ssh"): + cmd.extend(["-v", f"{home}/.ssh:{CONTAINER_HOME}/.ssh:ro"]) + if os.path.isfile(f"{home}/.npmrc"): + cmd.extend(["-v", f"{home}/.npmrc:{CONTAINER_HOME}/.npmrc:ro"]) + if os.path.isdir(f"{home}/.npm"): + cmd.extend(["-v", f"{home}/.npm:{CONTAINER_HOME}/.npm"]) + + # Add docker group if available + try: + import grp + docker_gid = str(grp.getgrnam("docker").gr_gid) + cmd.extend(["--group-add", docker_gid]) + except (KeyError, ImportError): + pass + + cmd.extend([full_ref, "sleep", "infinity"]) + subprocess.run(cmd, check=True) + ctx.console.success(f"Created and started container: {cname}") + + +def run_exec(ctx: FlowContext, args): + rt = _runtime() + cname = _cname(args.name) + + if not _container_running(rt, cname): + ctx.console.error(f"Container {cname} not running") + sys.exit(1) + + if args.cmd: + exec_cmd = [rt, "exec"] + if sys.stdin.isatty(): + exec_cmd.extend(["-it"]) + exec_cmd.append(cname) + exec_cmd.extend(args.cmd) + result = subprocess.run(exec_cmd) + sys.exit(result.returncode) + + # No command — try shells in order + last_code = 0 + 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 + + ctx.console.error(f"Unable to start an interactive shell in {cname}") + sys.exit(last_code or 1) + + +def run_connect(ctx: FlowContext, args): + rt = _runtime() + cname = _cname(args.name) + + if not _container_exists(rt, cname): + ctx.console.error(f"Container does not exist: {cname}") + sys.exit(1) + + if not _container_running(rt, cname): + subprocess.run([rt, "start", cname], capture_output=True) + + if not shutil.which("tmux"): + ctx.console.warn("tmux not found; falling back to direct exec") + args.cmd = [] + run_exec(ctx, args) + return + + # Get image label for env + result = subprocess.run( + [rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"], + capture_output=True, text=True, + ) + image_ref = result.stdout.strip() + _, _, _, image_label = _parse_image_ref(image_ref) + + # Create tmux session if needed + check = subprocess.run(["tmux", "has-session", "-t", cname], capture_output=True) + if check.returncode != 0: + ns = os.environ.get("DF_NAMESPACE", "") + plat = os.environ.get("DF_PLATFORM", "") + subprocess.run([ + "tmux", "new-session", "-ds", cname, + "-e", f"DF_IMAGE={image_label}", + "-e", f"DF_NAMESPACE={ns}", + "-e", f"DF_PLATFORM={plat}", + f"flow dev exec {args.name}", + ]) + subprocess.run([ + "tmux", "set-option", "-t", cname, + "default-command", f"flow dev exec {args.name}", + ]) + + if os.environ.get("TMUX"): + os.execvp("tmux", ["tmux", "switch-client", "-t", cname]) + else: + os.execvp("tmux", ["tmux", "attach", "-t", cname]) + + +def run_list(ctx: FlowContext, args): + rt = _runtime() + result = subprocess.run( + [rt, "ps", "-a", "--filter", "label=dev=true", + "--format", '{{.Label "dev.name"}}|{{.Image}}|{{.Label "dev.project_path"}}|{{.Status}}'], + capture_output=True, text=True, + ) + + headers = ["NAME", "IMAGE", "PROJECT", "STATUS"] + rows = [] + for line in result.stdout.strip().split("\n"): + if not line: + continue + parts = line.split("|") + if len(parts) >= 4: + name, image, project, status = parts[0], parts[1], parts[2], parts[3] + # Shorten paths + home = os.path.expanduser("~") + if project.startswith(home): + project = "~" + project[len(home):] + rows.append([name, image, project, status]) + + if not rows: + ctx.console.info("No development containers found.") + return + + ctx.console.table(headers, rows) + + +def run_stop(ctx: FlowContext, args): + rt = _runtime() + cname = _cname(args.name) + + if not _container_exists(rt, cname): + ctx.console.error(f"Container {cname} does not exist") + sys.exit(1) + + if args.kill: + ctx.console.info(f"Killing container {cname}...") + subprocess.run([rt, "kill", cname], check=True) + else: + ctx.console.info(f"Stopping container {cname}...") + subprocess.run([rt, "stop", cname], check=True) + + _tmux_fallback(cname) + + +def run_remove(ctx: FlowContext, args): + rt = _runtime() + cname = _cname(args.name) + + if not _container_exists(rt, cname): + ctx.console.error(f"Container {cname} does not exist") + sys.exit(1) + + if args.force: + ctx.console.info(f"Removing container {cname} (force)...") + subprocess.run([rt, "rm", "-f", cname], check=True) + else: + ctx.console.info(f"Removing container {cname}...") + subprocess.run([rt, "rm", cname], check=True) + + _tmux_fallback(cname) + + +def run_respawn(ctx: FlowContext, args): + cname = _cname(args.name) + result = subprocess.run( + ["tmux", "list-panes", "-t", cname, "-s", + "-F", "#{session_name}:#{window_index}.#{pane_index}"], + capture_output=True, text=True, + ) + for pane in result.stdout.strip().split("\n"): + if pane: + ctx.console.info(f"Respawning {pane}...") + subprocess.run(["tmux", "respawn-pane", "-t", pane]) + + +def _tmux_fallback(cname: str): + """If inside tmux in the target session, switch to default.""" + if not os.environ.get("TMUX"): + return + result = subprocess.run( + ["tmux", "display-message", "-p", "#S"], + capture_output=True, text=True, + ) + current = result.stdout.strip() + if current == cname: + subprocess.run(["tmux", "new-session", "-ds", "default"], capture_output=True) + subprocess.run(["tmux", "switch-client", "-t", "default"]) diff --git a/commands/dotfiles.py b/commands/dotfiles.py new file mode 100644 index 0000000..449d42d --- /dev/null +++ b/commands/dotfiles.py @@ -0,0 +1,425 @@ +"""flow dotfiles — dotfile management with GNU Stow-style symlinking.""" + +import json +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + +from flow.core.config import FlowContext +from flow.core.paths import DOTFILES_DIR, LINKED_STATE +from flow.core.stow import LinkTree, TreeFolder + + +def register(subparsers): + p = subparsers.add_parser("dotfiles", aliases=["dot"], help="Manage dotfiles") + sub = p.add_subparsers(dest="dotfiles_command") + + # init + init = sub.add_parser("init", help="Clone dotfiles repository") + init.add_argument("--repo", help="Override repository URL") + init.set_defaults(handler=run_init) + + # link + link = sub.add_parser("link", help="Create symlinks for dotfile packages") + link.add_argument("packages", nargs="*", help="Specific packages to link (default: all)") + link.add_argument("--profile", help="Profile to use for overrides") + link.add_argument("--copy", action="store_true", help="Copy instead of symlink") + link.add_argument("--force", action="store_true", help="Overwrite existing files") + link.add_argument("--dry-run", action="store_true", help="Show what would be done") + link.set_defaults(handler=run_link) + + # unlink + unlink = sub.add_parser("unlink", help="Remove dotfile symlinks") + unlink.add_argument("packages", nargs="*", help="Specific packages to unlink (default: all)") + unlink.set_defaults(handler=run_unlink) + + # status + status = sub.add_parser("status", help="Show dotfiles link status") + status.set_defaults(handler=run_status) + + # sync + sync = sub.add_parser("sync", help="Pull latest dotfiles from remote") + sync.set_defaults(handler=run_sync) + + # relink + relink = sub.add_parser("relink", help="Refresh symlinks after changes") + relink.add_argument("packages", nargs="*", help="Specific packages to relink (default: all)") + relink.add_argument("--profile", help="Profile to use for overrides") + relink.set_defaults(handler=run_relink) + + # clean + clean = sub.add_parser("clean", help="Remove broken symlinks") + clean.add_argument("--dry-run", action="store_true", help="Show what would be done") + clean.set_defaults(handler=run_clean) + + # edit + edit = sub.add_parser("edit", help="Edit package config with auto-commit") + edit.add_argument("package", help="Package name to edit") + edit.add_argument("--no-commit", action="store_true", help="Skip auto-commit") + edit.set_defaults(handler=run_edit) + + p.set_defaults(handler=lambda ctx, args: p.print_help()) + + +def _load_state() -> dict: + if LINKED_STATE.exists(): + with open(LINKED_STATE) as f: + return json.load(f) + return {"links": {}} + + +def _save_state(state: dict): + LINKED_STATE.parent.mkdir(parents=True, exist_ok=True) + with open(LINKED_STATE, "w") as f: + json.dump(state, f, indent=2) + + +def _discover_packages(dotfiles_dir: Path, profile: Optional[str] = None) -> dict: + """Discover packages from common/ and optionally profiles//. + + Returns {package_name: source_dir} with profile dirs taking precedence. + """ + packages = {} + common = dotfiles_dir / "common" + if common.is_dir(): + for pkg in sorted(common.iterdir()): + if pkg.is_dir() and not pkg.name.startswith("."): + packages[pkg.name] = pkg + + if profile: + profile_dir = dotfiles_dir / "profiles" / profile + if profile_dir.is_dir(): + for pkg in sorted(profile_dir.iterdir()): + if pkg.is_dir() and not pkg.name.startswith("."): + packages[pkg.name] = pkg # Override common + + return packages + + +def _walk_package(source_dir: Path, home: Path): + """Yield (source_file, target_file) pairs for a package directory. + + Files in the package directory map relative to $HOME. + """ + for root, _dirs, files in os.walk(source_dir): + for fname in files: + src = Path(root) / fname + rel = src.relative_to(source_dir) + dst = home / rel + yield src, dst + + +def run_init(ctx: FlowContext, args): + repo_url = args.repo or ctx.config.dotfiles_url + if not repo_url: + ctx.console.error("No dotfiles repository URL. Set it in config or pass --repo.") + sys.exit(1) + + if DOTFILES_DIR.exists(): + ctx.console.warn(f"Dotfiles directory already exists: {DOTFILES_DIR}") + return + + DOTFILES_DIR.parent.mkdir(parents=True, exist_ok=True) + branch = ctx.config.dotfiles_branch + cmd = ["git", "clone", "-b", branch, repo_url, str(DOTFILES_DIR)] + ctx.console.info(f"Cloning {repo_url} (branch: {branch})...") + subprocess.run(cmd, check=True) + ctx.console.success(f"Dotfiles cloned to {DOTFILES_DIR}") + + +def run_link(ctx: FlowContext, args): + if not DOTFILES_DIR.exists(): + ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") + sys.exit(1) + + home = Path.home() + packages = _discover_packages(DOTFILES_DIR, args.profile) + + # Filter to requested packages + if args.packages: + packages = {k: v for k, v in packages.items() if k in args.packages} + missing = set(args.packages) - set(packages.keys()) + if missing: + ctx.console.warn(f"Packages not found: {', '.join(missing)}") + if not packages: + ctx.console.error("No valid packages selected") + sys.exit(1) + + # Build current link tree from state + state = _load_state() + try: + tree = LinkTree.from_state(state) + except RuntimeError as e: + ctx.console.error(str(e)) + sys.exit(1) + folder = TreeFolder(tree) + + all_operations = [] + copied_count = 0 + + for pkg_name, source_dir in packages.items(): + ctx.console.info(f"[{pkg_name}]") + for src, dst in _walk_package(source_dir, home): + if args.copy: + if dst.exists() or dst.is_symlink(): + if not args.force: + ctx.console.warn(f" Skipped (exists): {dst}") + continue + if dst.is_dir() and not dst.is_symlink(): + ctx.console.error(f"Cannot overwrite directory with --copy: {dst}") + continue + if not args.dry_run: + dst.unlink() + + if args.dry_run: + print(f" COPY: {src} -> {dst}") + else: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + print(f" Copied: {src} -> {dst}") + copied_count += 1 + continue + + ops = folder.plan_link(src, dst, pkg_name) + all_operations.extend(ops) + + if args.copy: + if args.dry_run: + return + ctx.console.success(f"Copied {copied_count} item(s)") + return + + # Conflict detection (two-phase) + conflicts = folder.detect_conflicts(all_operations) + if conflicts and not args.force: + for conflict in conflicts: + ctx.console.error(conflict) + ctx.console.error("\nUse --force to overwrite existing files") + sys.exit(1) + + # Handle force mode: remove conflicting targets + if args.force and not args.dry_run: + for op in all_operations: + if op.type != "create_symlink": + continue + if not (op.target.exists() or op.target.is_symlink()): + continue + if op.target in tree.links: + continue + if op.target.is_dir() and not op.target.is_symlink(): + ctx.console.error(f"Cannot overwrite directory with --force: {op.target}") + sys.exit(1) + op.target.unlink() + + # Execute operations + if args.dry_run: + ctx.console.info("\nPlanned operations:") + for op in all_operations: + print(str(op)) + else: + folder.execute_operations(all_operations, dry_run=False) + state = folder.to_state() + _save_state(state) + ctx.console.success(f"Linked {len(all_operations)} item(s)") + + +def run_unlink(ctx: FlowContext, args): + state = _load_state() + links_by_package = state.get("links", {}) + if not links_by_package: + ctx.console.info("No linked dotfiles found.") + return + + packages_to_unlink = args.packages if args.packages else list(links_by_package.keys()) + removed = 0 + + for pkg_name in packages_to_unlink: + links = links_by_package.get(pkg_name, {}) + if not links: + continue + + ctx.console.info(f"[{pkg_name}]") + for dst_str in list(links.keys()): + dst = Path(dst_str) + if dst.is_symlink(): + dst.unlink() + print(f" Removed: {dst}") + removed += 1 + elif dst.exists(): + ctx.console.warn(f" Not a symlink, skipping: {dst}") + else: + print(f" Already gone: {dst}") + + links_by_package.pop(pkg_name, None) + + _save_state(state) + ctx.console.success(f"Removed {removed} symlink(s)") + + +def run_status(ctx: FlowContext, args): + state = _load_state() + links_by_package = state.get("links", {}) + if not links_by_package: + ctx.console.info("No linked dotfiles.") + return + + for pkg_name, links in links_by_package.items(): + ctx.console.info(f"[{pkg_name}]") + for dst_str, link_info in links.items(): + dst = Path(dst_str) + + if not isinstance(link_info, dict) or "source" not in link_info: + ctx.console.error( + "Unsupported linked state format. Remove linked.json and relink dotfiles." + ) + sys.exit(1) + + src_str = link_info["source"] + is_dir_link = bool(link_info.get("is_directory_link", False)) + + link_type = "FOLDED" if is_dir_link else "OK" + + if dst.is_symlink(): + target = os.readlink(dst) + if target == src_str or str(dst.resolve()) == str(Path(src_str).resolve()): + print(f" {link_type}: {dst} -> {src_str}") + else: + print(f" CHANGED: {dst} -> {target} (expected {src_str})") + elif dst.exists(): + print(f" NOT SYMLINK: {dst}") + else: + print(f" BROKEN: {dst} (missing)") + + +def run_sync(ctx: FlowContext, args): + if not DOTFILES_DIR.exists(): + ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") + sys.exit(1) + + ctx.console.info("Pulling latest dotfiles...") + result = subprocess.run( + ["git", "-C", str(DOTFILES_DIR), "pull", "--rebase"], + capture_output=True, text=True, + ) + if result.returncode == 0: + if result.stdout.strip(): + print(result.stdout.strip()) + ctx.console.success("Dotfiles synced.") + else: + ctx.console.error(f"Git pull failed: {result.stderr.strip()}") + sys.exit(1) + + +def run_relink(ctx: FlowContext, args): + """Refresh symlinks after changes (unlink + link).""" + if not DOTFILES_DIR.exists(): + ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") + sys.exit(1) + + # First unlink + ctx.console.info("Unlinking current symlinks...") + run_unlink(ctx, args) + + # Then link again + ctx.console.info("Relinking with updated configuration...") + run_link(ctx, args) + + +def run_clean(ctx: FlowContext, args): + """Remove broken symlinks.""" + state = _load_state() + if not state.get("links"): + ctx.console.info("No linked dotfiles found.") + return + + removed = 0 + for pkg_name, links in state["links"].items(): + for dst_str in list(links.keys()): + dst = Path(dst_str) + + # Check if symlink is broken + if dst.is_symlink() and not dst.exists(): + if args.dry_run: + print(f"Would remove broken symlink: {dst}") + else: + dst.unlink() + print(f"Removed broken symlink: {dst}") + del links[dst_str] + removed += 1 + + if not args.dry_run: + _save_state(state) + + if removed > 0: + ctx.console.success(f"Cleaned {removed} broken symlink(s)") + else: + ctx.console.info("No broken symlinks found") + + +def run_edit(ctx: FlowContext, args): + """Edit package config with auto-commit workflow.""" + if not DOTFILES_DIR.exists(): + ctx.console.error(f"Dotfiles not found at {DOTFILES_DIR}. Run 'flow dotfiles init' first.") + sys.exit(1) + + package_name = args.package + + # Find package directory + common_dir = DOTFILES_DIR / "common" / package_name + profile_dirs = list((DOTFILES_DIR / "profiles").glob(f"*/{package_name}")) + + package_dir = None + if common_dir.exists(): + package_dir = common_dir + elif profile_dirs: + package_dir = profile_dirs[0] + else: + ctx.console.error(f"Package not found: {package_name}") + sys.exit(1) + + # Git pull before editing + ctx.console.info("Pulling latest changes...") + result = subprocess.run( + ["git", "-C", str(DOTFILES_DIR), "pull", "--rebase"], + capture_output=True, text=True, + ) + if result.returncode != 0: + ctx.console.warn(f"Git pull failed: {result.stderr.strip()}") + + # Open editor + editor = os.environ.get("EDITOR", "vim") + ctx.console.info(f"Opening {package_dir} in {editor}...") + edit_result = subprocess.run(shlex.split(editor) + [str(package_dir)]) + if edit_result.returncode != 0: + ctx.console.warn(f"Editor exited with status {edit_result.returncode}") + + # Check for changes + result = subprocess.run( + ["git", "-C", str(DOTFILES_DIR), "status", "--porcelain"], + capture_output=True, text=True, + ) + + if result.stdout.strip() and not args.no_commit: + # Auto-commit changes + ctx.console.info("Changes detected, committing...") + subprocess.run(["git", "-C", str(DOTFILES_DIR), "add", "."], check=True) + subprocess.run( + ["git", "-C", str(DOTFILES_DIR), "commit", "-m", f"Update {package_name} config"], + check=True, + ) + + # Ask before pushing + response = input("Push changes to remote? [Y/n] ") + if response.lower() != "n": + subprocess.run(["git", "-C", str(DOTFILES_DIR), "push"], check=True) + ctx.console.success("Changes committed and pushed") + else: + ctx.console.info("Changes committed locally (not pushed)") + elif result.stdout.strip() and args.no_commit: + ctx.console.info("Changes detected; skipped commit (--no-commit)") + else: + ctx.console.info("No changes to commit") diff --git a/commands/enter.py b/commands/enter.py new file mode 100644 index 0000000..e4250a7 --- /dev/null +++ b/commands/enter.py @@ -0,0 +1,127 @@ +"""flow enter — connect to a development instance via SSH.""" + +import getpass +import os +import sys + +from flow.core.config import FlowContext + +# Default host templates per platform +HOST_TEMPLATES = { + "orb": ".orb", + "utm": ".utm.local", + "core": ".core.lan", +} + + +def register(subparsers): + p = subparsers.add_parser("enter", help="Connect to a development instance via SSH") + p.add_argument("target", help="Target: [user@]namespace@platform") + p.add_argument("-u", "--user", help="SSH user (overrides target)") + p.add_argument("-n", "--namespace", help="Namespace (overrides target)") + p.add_argument("-p", "--platform", help="Platform (overrides target)") + p.add_argument("-s", "--session", default="default", help="Tmux session name (default: 'default')") + p.add_argument("--no-tmux", action="store_true", help="Skip tmux attachment") + p.add_argument("-d", "--dry-run", action="store_true", help="Show command without executing") + p.set_defaults(handler=run) + + +def _parse_target(target: str): + """Parse [user@]namespace@platform into (user, namespace, platform).""" + user = None + namespace = None + platform = None + + if "@" in target: + platform = target.rsplit("@", 1)[1] + rest = target.rsplit("@", 1)[0] + else: + rest = target + + if "@" in rest: + user = rest.rsplit("@", 1)[0] + namespace = rest.rsplit("@", 1)[1] + else: + namespace = rest + + return user, namespace, platform + + +def _build_destination(user: str, host: str, preserve_host_user: bool = False) -> str: + if "@" in host: + host_user, host_name = host.rsplit("@", 1) + effective_user = host_user if preserve_host_user else (user or host_user) + return f"{effective_user}@{host_name}" + if not user: + return host + return f"{user}@{host}" + + +def run(ctx: FlowContext, args): + # Warn if already inside an instance + if os.environ.get("DF_NAMESPACE") and os.environ.get("DF_PLATFORM"): + ns = os.environ["DF_NAMESPACE"] + plat = os.environ["DF_PLATFORM"] + ctx.console.error( + f"Not recommended inside an instance. Currently in: {ns}@{plat}" + ) + sys.exit(1) + + user, namespace, platform = _parse_target(args.target) + + # Apply overrides + if args.user: + user = args.user + if args.namespace: + namespace = args.namespace + if args.platform: + platform = args.platform + + user_was_explicit = bool(user) + + if not user: + user = os.environ.get("USER") or getpass.getuser() + if not namespace: + ctx.console.error("Namespace is required in target") + sys.exit(1) + if not platform: + ctx.console.error("Platform is required in target") + sys.exit(1) + + # Resolve SSH host from template or config + host_template = HOST_TEMPLATES.get(platform) + ssh_identity = None + + # Check config targets for override + for tc in ctx.config.targets: + if tc.namespace == namespace and tc.platform == platform: + host_template = tc.ssh_host + ssh_identity = tc.ssh_identity + break + + if not host_template: + ctx.console.error(f"Unknown platform: {platform}") + sys.exit(1) + + ssh_host = host_template.replace("", namespace) + destination = _build_destination(user, ssh_host, preserve_host_user=not user_was_explicit) + + # Build SSH command + ssh_cmd = ["ssh", "-tt"] + if ssh_identity: + ssh_cmd.extend(["-i", os.path.expanduser(ssh_identity)]) + ssh_cmd.append(destination) + + if not args.no_tmux: + ssh_cmd.extend([ + "tmux", "new-session", "-As", args.session, + "-e", f"DF_NAMESPACE={namespace}", + "-e", f"DF_PLATFORM={platform}", + ]) + + if args.dry_run: + ctx.console.info("Dry run command:") + print(" " + " ".join(ssh_cmd)) + return + + os.execvp("ssh", ssh_cmd) diff --git a/commands/package.py b/commands/package.py new file mode 100644 index 0000000..4965166 --- /dev/null +++ b/commands/package.py @@ -0,0 +1,181 @@ +"""flow package — binary package management from manifest definitions.""" + +import json +import subprocess +import sys +from typing import Any, Dict, Optional, Tuple + +from flow.core.config import FlowContext +from flow.core.paths import INSTALLED_STATE +from flow.core.variables import substitute_template + + +def register(subparsers): + p = subparsers.add_parser("package", aliases=["pkg"], help="Manage binary packages") + sub = p.add_subparsers(dest="package_command") + + # install + inst = sub.add_parser("install", help="Install packages from manifest") + inst.add_argument("packages", nargs="+", help="Package names to install") + inst.add_argument("--dry-run", action="store_true", help="Show what would be done") + inst.set_defaults(handler=run_install) + + # list + ls = sub.add_parser("list", help="List installed and available packages") + ls.add_argument("--all", action="store_true", help="Show all available packages") + ls.set_defaults(handler=run_list) + + # remove + rm = sub.add_parser("remove", help="Remove installed packages") + rm.add_argument("packages", nargs="+", help="Package names to remove") + rm.set_defaults(handler=run_remove) + + p.set_defaults(handler=lambda ctx, args: p.print_help()) + + +def _load_installed() -> dict: + if INSTALLED_STATE.exists(): + with open(INSTALLED_STATE) as f: + return json.load(f) + return {} + + +def _save_installed(state: dict): + INSTALLED_STATE.parent.mkdir(parents=True, exist_ok=True) + with open(INSTALLED_STATE, "w") as f: + json.dump(state, f, indent=2) + + +def _get_definitions(ctx: FlowContext) -> dict: + """Get package definitions from manifest (binaries section).""" + return ctx.manifest.get("binaries", {}) + + +def _resolve_download_url( + pkg_def: Dict[str, Any], + platform_str: str, +) -> Optional[Tuple[str, Dict[str, str]]]: + """Build GitHub release download URL from package definition.""" + source = pkg_def.get("source", "") + if not source.startswith("github:"): + return None + + owner_repo = source[len("github:"):] + version = pkg_def.get("version", "") + asset_pattern = pkg_def.get("asset-pattern", "") + platform_map = pkg_def.get("platform-map", {}) + + mapping = platform_map.get(platform_str) + if not mapping: + return None + + # Build template context + template_ctx = {**mapping, "version": version} + asset = substitute_template(asset_pattern, template_ctx) + url = f"https://github.com/{owner_repo}/releases/download/v{version}/{asset}" + + template_ctx["downloadUrl"] = url + return url, template_ctx + + +def run_install(ctx: FlowContext, args): + definitions = _get_definitions(ctx) + installed = _load_installed() + platform_str = ctx.platform.platform + had_error = False + + for pkg_name in args.packages: + pkg_def = definitions.get(pkg_name) + if not pkg_def: + ctx.console.error(f"Package not found in manifest: {pkg_name}") + had_error = True + continue + + ctx.console.info(f"Installing {pkg_name} v{pkg_def.get('version', '?')}...") + + result = _resolve_download_url(pkg_def, platform_str) + if not result: + ctx.console.error(f"No download available for {pkg_name} on {platform_str}") + had_error = True + continue + + url, template_ctx = result + + if args.dry_run: + ctx.console.info(f"[{pkg_name}] Would download: {url}") + install_script = pkg_def.get("install-script", "") + if install_script: + ctx.console.info(f"[{pkg_name}] Would run install script") + continue + + # Run install script with template vars resolved + install_script = pkg_def.get("install-script", "") + if not install_script: + ctx.console.error(f"Package '{pkg_name}' has no install-script") + had_error = True + continue + + resolved_script = substitute_template(install_script, template_ctx) + ctx.console.info(f"Running install script for {pkg_name}...") + proc = subprocess.run( + resolved_script, shell=True, + capture_output=False, + ) + if proc.returncode != 0: + ctx.console.error(f"Install script failed for {pkg_name}") + had_error = True + continue + + installed[pkg_name] = { + "version": pkg_def.get("version", ""), + "source": pkg_def.get("source", ""), + } + ctx.console.success(f"Installed {pkg_name} v{pkg_def.get('version', '')}") + + _save_installed(installed) + if had_error: + sys.exit(1) + + +def run_list(ctx: FlowContext, args): + definitions = _get_definitions(ctx) + installed = _load_installed() + + headers = ["PACKAGE", "INSTALLED", "AVAILABLE"] + rows = [] + + if args.all: + # Show all defined packages + if not definitions: + ctx.console.info("No packages defined in manifest.") + return + for name, pkg_def in sorted(definitions.items()): + inst_ver = installed.get(name, {}).get("version", "-") + avail_ver = pkg_def.get("version", "?") + rows.append([name, inst_ver, avail_ver]) + else: + # Show installed only + if not installed: + ctx.console.info("No packages installed.") + return + for name, info in sorted(installed.items()): + avail = definitions.get(name, {}).get("version", "?") + rows.append([name, info.get("version", "?"), avail]) + + ctx.console.table(headers, rows) + + +def run_remove(ctx: FlowContext, args): + installed = _load_installed() + + for pkg_name in args.packages: + if pkg_name not in installed: + ctx.console.warn(f"Package not installed: {pkg_name}") + continue + + # Remove from installed state + del installed[pkg_name] + ctx.console.success(f"Removed {pkg_name} from installed packages") + ctx.console.warn("Note: binary files were not automatically deleted. Remove manually if needed.") + + _save_installed(installed) diff --git a/commands/sync.py b/commands/sync.py new file mode 100644 index 0000000..dfccae4 --- /dev/null +++ b/commands/sync.py @@ -0,0 +1,199 @@ +"""flow sync — check git sync status of all projects.""" + +import os +import subprocess +import sys + +from flow.core.config import FlowContext + + +def register(subparsers): + p = subparsers.add_parser("sync", help="Git sync tools for projects") + sub = p.add_subparsers(dest="sync_command") + + check = sub.add_parser("check", help="Check all projects status") + check.add_argument( + "--fetch", + dest="fetch", + action="store_true", + help="Run git fetch before checking (default)", + ) + check.add_argument( + "--no-fetch", + dest="fetch", + action="store_false", + help="Skip git fetch", + ) + check.set_defaults(fetch=True) + check.set_defaults(handler=run_check) + + fetch = sub.add_parser("fetch", help="Fetch all project remotes") + fetch.set_defaults(handler=run_fetch) + + summary = sub.add_parser("summary", help="Quick overview of project status") + summary.set_defaults(handler=run_summary) + + p.set_defaults(handler=lambda ctx, args: p.print_help()) + + +def _git(repo: str, *cmd, capture: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "-C", repo] + list(cmd), + capture_output=capture, text=True, + ) + + +def _check_repo(repo_path: str, do_fetch: bool = True): + """Check a single repo, return (name, issues list).""" + name = os.path.basename(repo_path) + git_dir = os.path.join(repo_path, ".git") + if not os.path.isdir(git_dir): + return name, None # Not a git repo + + issues = [] + + if do_fetch: + fetch_result = _git(repo_path, "fetch", "--all", "--quiet") + if fetch_result.returncode != 0: + issues.append("git fetch failed") + + # Current branch + result = _git(repo_path, "rev-parse", "--abbrev-ref", "HEAD") + branch = result.stdout.strip() if result.returncode == 0 else "HEAD" + + # Uncommitted changes + diff_result = _git(repo_path, "diff", "--quiet") + cached_result = _git(repo_path, "diff", "--cached", "--quiet") + if diff_result.returncode != 0 or cached_result.returncode != 0: + issues.append("uncommitted changes") + else: + untracked = _git(repo_path, "ls-files", "--others", "--exclude-standard") + if untracked.stdout.strip(): + issues.append("untracked files") + + # Unpushed commits + upstream_check = _git(repo_path, "rev-parse", "--abbrev-ref", f"{branch}@{{u}}") + if upstream_check.returncode == 0: + unpushed = _git(repo_path, "rev-list", "--oneline", f"{branch}@{{u}}..{branch}") + if unpushed.stdout.strip(): + count = len(unpushed.stdout.strip().split("\n")) + issues.append(f"{count} unpushed commit(s) on {branch}") + else: + issues.append(f"no upstream for {branch}") + + # Unpushed branches + branches_result = _git(repo_path, "for-each-ref", "--format=%(refname:short)", "refs/heads") + for b in branches_result.stdout.strip().split("\n"): + if not b or b == branch: + continue + up = _git(repo_path, "rev-parse", "--abbrev-ref", f"{b}@{{u}}") + if up.returncode == 0: + ahead = _git(repo_path, "rev-list", "--count", f"{b}@{{u}}..{b}") + if ahead.stdout.strip() != "0": + issues.append(f"branch {b}: {ahead.stdout.strip()} ahead") + else: + issues.append(f"branch {b}: no upstream") + + return name, issues + + +def run_check(ctx: FlowContext, args): + projects_dir = os.path.expanduser(ctx.config.projects_dir) + if not os.path.isdir(projects_dir): + ctx.console.error(f"Projects directory not found: {projects_dir}") + sys.exit(1) + + rows = [] + needs_action = [] + not_git = [] + checked = 0 + + for entry in sorted(os.listdir(projects_dir)): + repo_path = os.path.join(projects_dir, entry) + if not os.path.isdir(repo_path): + continue + + name, issues = _check_repo(repo_path, do_fetch=args.fetch) + if issues is None: + not_git.append(name) + continue + checked += 1 + if issues: + needs_action.append(name) + rows.append([name, "; ".join(issues)]) + else: + rows.append([name, "clean and synced"]) + + if checked == 0: + ctx.console.info("No git repositories found in projects directory.") + if not_git: + ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}") + return + + ctx.console.table(["PROJECT", "STATUS"], rows) + + if needs_action: + ctx.console.warn(f"Projects needing action: {', '.join(sorted(needs_action))}") + else: + ctx.console.success("All repositories clean and synced.") + + if not_git: + ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}") + + +def run_fetch(ctx: FlowContext, args): + projects_dir = os.path.expanduser(ctx.config.projects_dir) + if not os.path.isdir(projects_dir): + ctx.console.error(f"Projects directory not found: {projects_dir}") + sys.exit(1) + + had_error = False + fetched = 0 + for entry in sorted(os.listdir(projects_dir)): + repo_path = os.path.join(projects_dir, entry) + if not os.path.isdir(os.path.join(repo_path, ".git")): + continue + ctx.console.info(f"Fetching {entry}...") + result = _git(repo_path, "fetch", "--all", "--quiet") + fetched += 1 + if result.returncode != 0: + ctx.console.error(f"Failed to fetch {entry}") + had_error = True + + if fetched == 0: + ctx.console.info("No git repositories found in projects directory.") + return + + if had_error: + sys.exit(1) + + ctx.console.success("All remotes fetched.") + + +def run_summary(ctx: FlowContext, args): + projects_dir = os.path.expanduser(ctx.config.projects_dir) + if not os.path.isdir(projects_dir): + ctx.console.error(f"Projects directory not found: {projects_dir}") + sys.exit(1) + + headers = ["PROJECT", "STATUS"] + rows = [] + + for entry in sorted(os.listdir(projects_dir)): + repo_path = os.path.join(projects_dir, entry) + if not os.path.isdir(repo_path): + continue + + name, issues = _check_repo(repo_path, do_fetch=False) + if issues is None: + rows.append([name, "not a git repo"]) + elif issues: + rows.append([name, "; ".join(issues)]) + else: + rows.append([name, "clean"]) + + if not rows: + ctx.console.info("No projects found.") + return + + ctx.console.table(headers, rows) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/__pycache__/__init__.cpython-313.pyc b/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a79bc8f5732bd54ecf4b497cfcf00e2914d3ce25 GIT binary patch literal 148 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 CFrKym literal 0 HcmV?d00001 diff --git a/core/__pycache__/config.cpython-313.pyc b/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cfcc83ce6e1d65c61d5469535fad95ea127358ba GIT binary patch literal 6279 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 jP+jR|Z?U=FS5c%D6=`MiZi6DNY|H)!jE`4u literal 0 HcmV?d00001 diff --git a/core/__pycache__/console.cpython-313.pyc b/core/__pycache__/console.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24ef17f0feef783aef2abd8cbd51649768e23c36 GIT binary patch literal 11809 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 arhi4*e@*HBovQqe+Nz>AKB6!reg7ArCCnfI literal 0 HcmV?d00001 diff --git a/core/__pycache__/paths.cpython-313.pyc b/core/__pycache__/paths.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8cb964b87895fe5c546e3ac022bd5ee1de1a995a GIT binary patch literal 1839 zcmaJ=Pi)&%7=KQj*op19O|m8H#PHgVHE2uN)}d{{w$P?+)-IjmAch*r@*Jmeo5bbW z?bd>X1qa3fF(jttz@e2u3KEAMci^xi$6C2`PaqAA3%82Go%ftS8$0Yt?|Z-Rd!OI; zeZTMXfgpqdUcY9(Hh!c5ct(`|VtU}<76rh4AOi?wO7_iDkiw^b-gnIp{SuJrGA#%2 z4wn6sL75o=D>Mw0{YAe2(DQQ?obM@+DF;S@%$DgShxl?xj(SMIDCVIEMsW{`IO9Ve8Y+k7 z!yX#ODB+=KS&)+ib*t)P7%RtmQQ{bNqa2b)~ zM6`0JvJA;!k(8_vEFAC~Cw}L95dJp&Ao6Xbb-Ockro)}xrOzJVTYyB&sWP=#n90v7 zbBot=TTB{LS~ZptQ_(j_FeDI{>1-+6bqJ>Grl!_Wz^bWGM-g%>m$Idtca6=qYU^7< zLv0#W-Lli0YNL*Vy+_%KX;+Q9ZXtFJw?SRTh_N)N+FA{pdPQz=b)&heSJEq%+1!d4 zq{a1mPe(M#N+a3#*PwAVY{8A|+h1th(x7om#M@g-Y#&VqVqh)?Kofibig+|%UigQ` zzxG9+1g;+ffCCa(_NVBr$tkGgVkMlM1oe;C4X9V73YMB}LPNJCqbk`oeN(EK&6BpI z-!U*BrIZgbYnVDbw21X=in&@y1yQ)yhv|F~u^!EqdF&%XHXNfBQk?8U+RHjK`C(R!AifqbL*N#RdbW_C$;l;UCcltFT(4+P;inf%1b78PiF|XMe8b-; zE?&z`mAZxV3;Du)Zn`^lNX!>7LkkN7LwxYa%);V_z2Qc~18YA%5(lPk4jw^20k7gN zeeAmpv$9^-FT+Wk2I19;V!7|9DC!BwJOiQML2^GxQRnWB?E`#l$GoE*|A%P%EMWOB zH$UI};uDAdLm1wTj_t_p_`{H!y4X2!sUy7e$Z&;OC;WsH?}`}r!d9^c^?eOnE{>bGEPUz{-NK0{tPC3F} zB-y&)M#dcOshDWJ>5AhHzxNzP4!4fDkx_@+6Jz&QTv2lPr(&}8o-0mZzv$83V`(>< zcEtT4NRDq;-NdQ(rkl9%=xsNV-39Tz_(c1P8^5#*hWDaxw3BZ1j3fRV^i!uj&i(_N Cxx+L7 literal 0 HcmV?d00001 diff --git a/core/__pycache__/platform.cpython-313.pyc b/core/__pycache__/platform.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c954a192a26bd122c91fabaa6f674fbdf21d78b GIT binary patch literal 2204 zcmZuy&2JM&6rcU_uD!7x$R{R&C43~Vz_h|iD1=IokEVnel*MYLL|QHOCfQ=|n%Q*? zQBlpM4OA$nNa?Bc7EbLkNB#r{AL34fR0%HJT&1YjzS*@+hUl2s!y^evBNu*8Kn3IWEV z-dK2vjkm8HyQwO=l~r_{SvFlG;})@@WDVLgZ7cDX?vjc#B$(A*J(JTN2MpXyg1>)}sA32$PABGp*u%!rBfI^n7+CNq7pMAR-kS!5O9jhBg!!{qHS< zT1RO#2kk)_Y7AsgAR5uQ<^53+$O(8LuLmW9E^uj-YYi`da`m>tgdx(KR6IIl`SZ?hsXhrlL3GoCft1`a9B9zP8F$`L0@fPkCbXWR>r!b#|Ah zS2I<<`yI~;!~P0%G7<86#&)m-{Wwbk6FM%L77=bxj$ry6fh0ITl8kdCaR1_n26Pd1 z%9oKOR;W9G4Pd?X0hdK^c$67}JguX7Fn#$LF$PfcF2WBHWVUlD4^`<(he85@Q<^5eTsiWQ96m zmkbQkug;B6(`b)vaUL?Rmo%szqV0J6ClVc?XgmfxG=~a^M^O^>%V7%y{uJE3@OOR& zRYtqv?wxS|%W!`+e7roh8|hlR`}pq0nQG)j`OBIZdGy0>Bv$Fayfw9Tv2t{x8kwv} zlW(HEJ5l9jRH;S>cA`Vu(V=Selk)Vg+_`q;@s*9?qw#I%z&D5KK+^*V0Zqa7 z<>Q2YPt;ajgo-$;(Awy*3Oa>kq#*c$a4X3<@A)W$Svzyrz=Mj7mBE6Y&Fj{nVlFEB zce{d?LTTB|EJLXjv7cDKTKeheZKNY( zTdodi##$o?kHU%0z~3t)W%RmZa8=xu53P+o8QTzdWTh%AwT{@6)IS319I6LUTf28- z@d(gK12j;;ykbqO5Ke+3a_g&SIGA1}3AhgmKxj;F)-PW4n}yF(MR|dbP(_j8KB`)k zj!0vEf&Bts1_vzz4$W1aV;JU7bm}$g`wN9%p%bsrQNYH^|FI%7Tt}exI56v;*@?RQ ypOv;l=gVTP=h)`x*4Nu@ljUGNATzVfM&G}Pw)H-NX@54fdEvKn?+|o75dQX^FhCgwzPjv0su6>s|A9 zO-d@%AR&D^1aW4b?Io`1So>yX=Qm$(XM7lo zMF44b>?>xr;ZyKIY zNug{x8-`o;D^-8`DGzcvEwI?E>v*nBmfVdELRa2Si%c$VlH!L<_BM%aGqqT#_*F_! zXvgM4%Y|aEKxKX?crb+$1UkhKDx5@o`a1k@4By^@oRAf`=~>Fb+0I)7t_0qY<>7m1 z0&4}07uQK+2Hea0nT=0ZlCJN zC9L|=ivT(4zc)$LglxRau_NZxqi%Cb7ID4QjkvS`5CsFU(t%zaKu`rzlH5xQ=6B=A4t6Y&1?v#MFl*h@Jjh--$; zz6;VMq$4}0vsK3^7@a|Dk1h_becHFmq%*{%6wL6-Q$96mxix}J@%$1FcZ(BBS%1~B zJ|@&F*m;L++m=H-rmj`jJ!^+hK4d#D1k18suxt!FMXp-Du_LBu3?@Vvq(hYN9OW~R z)|uw{q>>MoAmUZmD%33!&tvkkTOkgUmKT@jnPRTYy_Z{Qx#CLT=2;aMZTYr$k12%; z!b?orE?7Q`bhgGV5vF1Pyw!P1UCI(zU&SUqDJ^#rNn5Vvq{CF@W%1$eT|qS{No(iI zxBHe_j5`<|Vy|U3-7?AeZn@xP&})kneJ`_ayW1I$7TZG6rG(pflhc(?Sd>5g)jD?Z&k)Ki!jliN^QN{5&vIbwXcWk$y6=)8ursne{x0ugUzLzY?uWp*(7#{7vNB;lEL>vGnt+}pEUqZcg zZMT6=a(RLS!f%w_Qq?AJ(pOOB6YZ_y(G&$i_z9+;K=OAuh20GdH~NPfgU9}goe-2p tEYTE^{~8-@%E+5oexljOr3jazT+%@AZ*nwxD~Y;n)MVqIIO?tJ{{sK6cLV?c literal 0 HcmV?d00001 diff --git a/core/__pycache__/stow.cpython-313.pyc b/core/__pycache__/stow.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e768ebac89c89f2e29bf724d1a09ea8ba0888fb7 GIT binary patch literal 15465 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 SQ`zi(JVwnY=I>;ce)%u7>et-> literal 0 HcmV?d00001 diff --git a/core/action.py b/core/action.py new file mode 100644 index 0000000..cb07d67 --- /dev/null +++ b/core/action.py @@ -0,0 +1,120 @@ +"""Action dataclass and ActionExecutor for plan-then-execute workflows.""" + +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from flow.core.console import ConsoleLogger + + +@dataclass +class Action: + type: str + description: str + data: Dict[str, Any] = field(default_factory=dict) + skip_on_error: bool = True + os_filter: Optional[str] = None + status: str = "pending" + error: Optional[str] = None + + +class ActionExecutor: + """Register handlers for action types, then execute a plan.""" + + def __init__(self, console: ConsoleLogger): + self.console = console + self._handlers: Dict[str, Callable] = {} + self.post_comments: List[str] = [] + + def register(self, action_type: str, handler: Callable) -> None: + self._handlers[action_type] = handler + + def execute(self, actions: List[Action], *, dry_run: bool = False, current_os: str = "") -> None: + if dry_run: + self._print_plan(actions) + return + + # Filter OS-incompatible actions + compatible = [a for a in actions if a.os_filter is None or a.os_filter == current_os] + skipped_count = len(actions) - len(compatible) + if skipped_count: + self.console.info(f"Skipped {skipped_count} OS-incompatible actions") + + self.console.section_header(f"EXECUTING {len(compatible)} ACTIONS") + + for i, action in enumerate(compatible, 1): + self.console.step_start(i, len(compatible), action.description) + + handler = self._handlers.get(action.type) + if not handler: + action.status = "skipped" + self.console.step_skip(f"No handler for action type: {action.type}") + continue + + try: + handler(action.data) + action.status = "completed" + self.console.step_complete() + except Exception as e: + action.error = str(e) + if action.skip_on_error: + action.status = "skipped" + self.console.step_skip(str(e)) + else: + action.status = "failed" + self.console.step_fail(str(e)) + print(f"\n{self.console.RED}Critical action failed, stopping execution{self.console.RESET}") + break + + self._print_summary(compatible) + + def _print_plan(self, actions: List[Action]) -> None: + self.console.plan_header("EXECUTION PLAN", len(actions)) + + grouped: Dict[str, List[Action]] = {} + for action in actions: + category = action.type.split("-")[0] + grouped.setdefault(category, []).append(action) + + for category, category_actions in grouped.items(): + self.console.plan_category(category) + for i, action in enumerate(category_actions, 1): + self.console.plan_item( + i, + action.description, + action.os_filter, + not action.skip_on_error, + ) + + self.console.plan_legend() + + def _print_summary(self, actions: List[Action]) -> None: + completed = sum(1 for a in actions if a.status == "completed") + failed = sum(1 for a in actions if a.status == "failed") + skipped = sum(1 for a in actions if a.status == "skipped") + + self.console.section_summary("EXECUTION SUMMARY") + c = self.console + + print(f"Total actions: {c.BOLD}{len(actions)}{c.RESET}") + print(f"Completed: {c.GREEN}{completed}{c.RESET}") + if failed: + print(f"Failed: {c.RED}{failed}{c.RESET}") + if skipped: + print(f"Skipped: {c.YELLOW}{skipped}{c.RESET}") + + if self.post_comments: + print(f"\n{c.BOLD}POST-INSTALL NOTES{c.RESET}") + print(f"{c.CYAN}{'-' * 25}{c.RESET}") + for i, comment in enumerate(self.post_comments, 1): + print(f"{i}. {comment}") + + if failed: + print(f"\n{c.BOLD}FAILED ACTIONS{c.RESET}") + print(f"{c.RED}{'-' * 20}{c.RESET}") + for action in actions: + if action.status == "failed": + print(f"{c.RED}>{c.RESET} {action.description}") + print(f" {c.GRAY}Error: {action.error}{c.RESET}") + print(f"\n{c.RED}{failed} action(s) failed. Check the errors above.{c.RESET}") + else: + print(f"\n{c.GREEN}All actions completed successfully!{c.RESET}") diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..4ae7ac9 --- /dev/null +++ b/core/config.py @@ -0,0 +1,151 @@ +"""Configuration loading (INI config + YAML manifest) and FlowContext.""" + +import configparser +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +from flow.core.console import ConsoleLogger +from flow.core import paths +from flow.core.platform import PlatformInfo + + +@dataclass +class TargetConfig: + namespace: str + platform: str + ssh_host: str + ssh_identity: Optional[str] = None + + +@dataclass +class AppConfig: + dotfiles_url: str = "" + dotfiles_branch: str = "main" + projects_dir: str = "~/projects" + container_registry: str = "registry.tomastm.com" + container_tag: str = "latest" + tmux_session: str = "default" + targets: List[TargetConfig] = field(default_factory=list) + + +def _parse_target_config(key: str, value: str) -> Optional[TargetConfig]: + """Parse a target line from config. + + Supported formats: + 1) namespace = platform ssh_host [ssh_identity] + 2) namespace@platform = ssh_host [ssh_identity] + """ + parts = value.split() + if not parts: + return None + + if "@" in key: + namespace, platform = key.split("@", 1) + ssh_host = parts[0] + ssh_identity = parts[1] if len(parts) > 1 else None + if not namespace or not platform: + return None + return TargetConfig( + namespace=namespace, + platform=platform, + ssh_host=ssh_host, + ssh_identity=ssh_identity, + ) + + if len(parts) < 2: + return None + + return TargetConfig( + namespace=key, + platform=parts[0], + ssh_host=parts[1], + ssh_identity=parts[2] if len(parts) > 2 else None, + ) + + +def load_config(path: Optional[Path] = None) -> AppConfig: + """Load INI config file into AppConfig with cascading priority. + + Priority: + 1. Dotfiles repo (self-hosted): ~/.local/share/devflow/dotfiles/flow/.config/flow/config + 2. Local override: ~/.config/devflow/config + 3. Empty fallback + """ + cfg = AppConfig() + + if path is None: + # Priority 1: Check dotfiles repo for self-hosted config + if paths.DOTFILES_CONFIG.exists(): + path = paths.DOTFILES_CONFIG + # Priority 2: Fall back to local config + else: + path = paths.CONFIG_FILE + + assert path is not None + + if not path.exists(): + return cfg + + parser = configparser.ConfigParser() + parser.read(path) + + if parser.has_section("repository"): + cfg.dotfiles_url = parser.get("repository", "dotfiles_url", fallback=cfg.dotfiles_url) + cfg.dotfiles_branch = parser.get("repository", "dotfiles_branch", fallback=cfg.dotfiles_branch) + + if parser.has_section("paths"): + cfg.projects_dir = parser.get("paths", "projects_dir", fallback=cfg.projects_dir) + + if parser.has_section("defaults"): + cfg.container_registry = parser.get("defaults", "container_registry", fallback=cfg.container_registry) + cfg.container_tag = parser.get("defaults", "container_tag", fallback=cfg.container_tag) + cfg.tmux_session = parser.get("defaults", "tmux_session", fallback=cfg.tmux_session) + + if parser.has_section("targets"): + for key in parser.options("targets"): + raw_value = parser.get("targets", key) + tc = _parse_target_config(key, raw_value) + if tc is not None: + cfg.targets.append(tc) + + return cfg + + +def load_manifest(path: Optional[Path] = None) -> Dict[str, Any]: + """Load YAML manifest file with cascading priority. + + Priority: + 1. Dotfiles repo (self-hosted): ~/.local/share/devflow/dotfiles/flow/.config/flow/manifest.yaml + 2. Local override: ~/.config/devflow/manifest.yaml + 3. Empty fallback + """ + if path is None: + # Priority 1: Check dotfiles repo for self-hosted manifest + if paths.DOTFILES_MANIFEST.exists(): + path = paths.DOTFILES_MANIFEST + # Priority 2: Fall back to local manifest + else: + path = paths.MANIFEST_FILE + + assert path is not None + + if not path.exists(): + return {} + + try: + with open(path, "r") as f: + data = yaml.safe_load(f) + except yaml.YAMLError as e: + raise RuntimeError(f"Invalid YAML in {path}: {e}") from e + return data if isinstance(data, dict) else {} + + +@dataclass +class FlowContext: + config: AppConfig + manifest: Dict[str, Any] + platform: PlatformInfo + console: ConsoleLogger diff --git a/core/console.py b/core/console.py new file mode 100644 index 0000000..95d6cf9 --- /dev/null +++ b/core/console.py @@ -0,0 +1,138 @@ +"""Console output formatting — ported from dotfiles_v2/src/console_logger.py.""" + +import time +from typing import Optional + + +class ConsoleLogger: + # Color constants + BLUE = "\033[34m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + RED = "\033[31m" + CYAN = "\033[36m" + GRAY = "\033[90m" + DARK_GRAY = "\033[2;37m" + BOLD = "\033[1m" + DIM = "\033[2m" + RESET = "\033[0m" + + # Box drawing characters + BOX_VERTICAL = "\u2502" + BOX_HORIZONTAL = "\u2500" + BOX_TOP_LEFT = "\u250c" + BOX_TOP_RIGHT = "\u2510" + BOX_BOTTOM_LEFT = "\u2514" + BOX_BOTTOM_RIGHT = "\u2518" + + def __init__(self): + self.step_counter = 0 + self.start_time = None + + def info(self, message: str): + print(f"{self.CYAN}[INFO]{self.RESET} {message}") + + def warn(self, message: str): + print(f"{self.YELLOW}[WARN]{self.RESET} {message}") + + def error(self, message: str): + print(f"{self.RED}[ERROR]{self.RESET} {message}") + + def success(self, message: str): + print(f"{self.GREEN}[SUCCESS]{self.RESET} {message}") + + def step_start(self, current: int, total: int, description: str): + print( + f"\n{self.BOLD}{self.BLUE}Step {current}/{total}:{self.RESET} " + f"{self.BOLD}{description}{self.RESET}" + ) + print(f"{self.BLUE}{self.BOX_HORIZONTAL * 4}{self.RESET} {self.GRAY}Starting...{self.RESET}") + self.start_time = time.time() + + def step_command(self, command: str): + print(f"{self.BLUE}{self.BOX_VERTICAL} {self.RESET}{self.GRAY}$ {command}{self.RESET}") + + def step_output(self, line: str): + if line.strip(): + print(f"{self.BLUE}{self.BOX_VERTICAL} {self.RESET}{self.DARK_GRAY} {line.rstrip()}{self.RESET}") + + def step_complete(self, message: str = "Completed successfully"): + elapsed = time.time() - self.start_time if self.start_time else 0 + print(f"{self.BLUE}{self.BOX_VERTICAL} {self.RESET}{self.GREEN}> {message} ({elapsed:.1f}s){self.RESET}") + + def step_skip(self, message: str): + elapsed = time.time() - self.start_time if self.start_time else 0 + print( + f"{self.BLUE}{self.BOX_VERTICAL} {self.RESET}" + f"{self.YELLOW}> Skipped: {message} ({elapsed:.1f}s){self.RESET}" + ) + + def step_fail(self, message: str): + elapsed = time.time() - self.start_time if self.start_time else 0 + print( + f"{self.BLUE}{self.BOX_VERTICAL} {self.RESET}" + f"{self.RED}> Failed: {message} ({elapsed:.1f}s){self.RESET}" + ) + + def section_header(self, title: str, subtitle: str = ""): + width = 70 + print(f"\n{self.BOLD}{self.BLUE}{'=' * width}{self.RESET}") + if subtitle: + print(f"{self.BOLD}{self.BLUE} {title.upper()} - {subtitle}{self.RESET}") + else: + print(f"{self.BOLD}{self.BLUE} {title.upper()}{self.RESET}") + print(f"{self.BOLD}{self.BLUE}{'=' * width}{self.RESET}") + + def section_summary(self, title: str): + width = 70 + print(f"\n{self.BOLD}{self.GREEN}{'=' * width}{self.RESET}") + print(f"{self.BOLD}{self.GREEN} {title.upper()}{self.RESET}") + print(f"{self.BOLD}{self.GREEN}{'=' * width}{self.RESET}") + + def plan_header(self, title: str, count: int): + width = 70 + print(f"\n{self.BOLD}{self.CYAN}{'=' * width}{self.RESET}") + print(f"{self.BOLD}{self.CYAN} {title.upper()} ({count} actions){self.RESET}") + print(f"{self.BOLD}{self.CYAN}{'=' * width}{self.RESET}") + + def plan_category(self, category: str): + print(f"\n{self.BOLD}{self.CYAN}{category.upper()}{self.RESET}") + print(f"{self.CYAN}{'-' * 20}{self.RESET}") + + def plan_item(self, number: int, description: str, os_filter: Optional[str] = None, critical: bool = False): + os_indicator = f" {self.GRAY}({os_filter}){self.RESET}" if os_filter else "" + error_indicator = f" {self.RED}(critical){self.RESET}" if critical else "" + print(f" {number:2d}. {description}{os_indicator}{error_indicator}") + + def plan_legend(self): + print( + f"\n{self.GRAY}Legend: {self.RED}(critical){self.GRAY} = stops on failure, " + f"{self.GRAY}(os){self.GRAY} = OS-specific{self.RESET}" + ) + + def table(self, headers: list[str], rows: list[list[str]]): + """Print a formatted table.""" + if not rows: + return + + normalized_headers = [str(h) for h in headers] + normalized_rows = [[str(cell) for cell in row] for row in rows] + + # Calculate column widths + widths = [len(h) for h in normalized_headers] + for row in normalized_rows: + for i, cell in enumerate(row): + if i < len(widths): + widths[i] = max(widths[i], len(cell)) + + # Header + header_line = " ".join( + f"{self.BOLD}{h:<{widths[i]}}{self.RESET}" for i, h in enumerate(normalized_headers) + ) + print(header_line) + print(self.GRAY + " ".join("-" * w for w in widths) + self.RESET) + + # Rows + for row in normalized_rows: + line = " ".join(f"{cell:<{widths[i]}}" for i, cell in enumerate(row)) + print(line) diff --git a/core/paths.py b/core/paths.py new file mode 100644 index 0000000..e31c140 --- /dev/null +++ b/core/paths.py @@ -0,0 +1,37 @@ +"""XDG-compliant path constants for DevFlow.""" + +import os +from pathlib import Path + + +def _xdg(env_var: str, fallback: str) -> Path: + return Path(os.environ.get(env_var, fallback)) + + +HOME = Path.home() + +CONFIG_DIR = _xdg("XDG_CONFIG_HOME", str(HOME / ".config")) / "devflow" +DATA_DIR = _xdg("XDG_DATA_HOME", str(HOME / ".local" / "share")) / "devflow" +STATE_DIR = _xdg("XDG_STATE_HOME", str(HOME / ".local" / "state")) / "devflow" + +MANIFEST_FILE = CONFIG_DIR / "manifest.yaml" +CONFIG_FILE = CONFIG_DIR / "config" + +DOTFILES_DIR = DATA_DIR / "dotfiles" +PACKAGES_DIR = DATA_DIR / "packages" +SCRATCH_DIR = DATA_DIR / "scratch" +PROJECTS_DIR = HOME / "projects" + +LINKED_STATE = STATE_DIR / "linked.json" +INSTALLED_STATE = STATE_DIR / "installed.json" + +# Self-hosted flow config paths (from dotfiles repo) +DOTFILES_FLOW_CONFIG = DOTFILES_DIR / "flow" / ".config" / "flow" +DOTFILES_MANIFEST = DOTFILES_FLOW_CONFIG / "manifest.yaml" +DOTFILES_CONFIG = DOTFILES_FLOW_CONFIG / "config" + + +def ensure_dirs() -> None: + """Create all required directories if they don't exist.""" + for d in (CONFIG_DIR, DATA_DIR, STATE_DIR, PACKAGES_DIR, SCRATCH_DIR): + d.mkdir(parents=True, exist_ok=True) diff --git a/core/platform.py b/core/platform.py new file mode 100644 index 0000000..7c83c01 --- /dev/null +++ b/core/platform.py @@ -0,0 +1,43 @@ +"""OS and architecture detection.""" + +import platform as _platform +import shutil +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class PlatformInfo: + os: str = "linux" # "linux" or "macos" + arch: str = "amd64" # "amd64" or "arm64" + platform: str = "" # "linux-amd64", etc. + + def __post_init__(self): + if not self.platform: + self.platform = f"{self.os}-{self.arch}" + + +_OS_MAP = {"Darwin": "macos", "Linux": "linux"} +_ARCH_MAP = {"x86_64": "amd64", "aarch64": "arm64", "arm64": "arm64"} + + +def detect_platform() -> PlatformInfo: + raw_os = _platform.system() + os_name = _OS_MAP.get(raw_os) + if os_name is None: + raise RuntimeError(f"Unsupported operating system: {raw_os}") + + raw_arch = _platform.machine().lower() + arch = _ARCH_MAP.get(raw_arch) + if arch is None: + 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 diff --git a/core/process.py b/core/process.py new file mode 100644 index 0000000..6e1ad2e --- /dev/null +++ b/core/process.py @@ -0,0 +1,45 @@ +"""Command execution with streaming output.""" + +import subprocess + +from flow.core.console import ConsoleLogger + + +def run_command( + command: str, + console: ConsoleLogger, + *, + check: bool = True, + shell: bool = True, + capture: bool = False, +) -> subprocess.CompletedProcess: + """Run a command with real-time streamed output.""" + console.step_command(command) + + process = subprocess.Popen( + command, + shell=shell, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + bufsize=1, + ) + + output_lines = [] + for line in process.stdout: + line = line.rstrip() + if line: + if not capture: + console.step_output(line) + output_lines.append(line) + + process.wait() + + if check and process.returncode != 0: + raise RuntimeError( + f"Command failed (exit {process.returncode}): {command}" + ) + + return subprocess.CompletedProcess( + command, process.returncode, stdout="\n".join(output_lines), stderr="" + ) diff --git a/core/stow.py b/core/stow.py new file mode 100644 index 0000000..7e694b1 --- /dev/null +++ b/core/stow.py @@ -0,0 +1,358 @@ +"""GNU Stow-style tree folding/unfolding for efficient symlink management.""" + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Set + + +@dataclass +class LinkOperation: + """Represents a single operation to perform during linking.""" + + type: str # "create_symlink" | "create_dir" | "unfold" | "remove" | "remove_dir" + source: Path + target: Path + package: str + is_directory_link: bool = False + + def __str__(self) -> str: + if self.type == "create_symlink": + link_type = "DIR" if self.is_directory_link else "FILE" + return f" {link_type} LINK: {self.target} -> {self.source}" + elif self.type == "create_dir": + return f" CREATE DIR: {self.target}" + elif self.type == "unfold": + return f" UNFOLD: {self.target} (directory symlink -> individual file symlinks)" + elif self.type == "remove": + return f" REMOVE: {self.target}" + elif self.type == "remove_dir": + return f" REMOVE DIR: {self.target}" + return f" {self.type}: {self.target}" + + +@dataclass +class LinkTree: + """Represents the current state of symlinks.""" + + links: Dict[Path, Path] = field(default_factory=dict) # target -> source + packages: Dict[Path, str] = field(default_factory=dict) # target -> package_name + directory_links: Set[Path] = field(default_factory=set) # targets that are directory links + + def add_link(self, target: Path, source: Path, package: str, is_dir_link: bool = False): + """Add a link to the tree.""" + self.links[target] = source + self.packages[target] = package + if is_dir_link: + self.directory_links.add(target) + + def remove_link(self, target: Path): + """Remove a link from the tree.""" + self.links.pop(target, None) + self.packages.pop(target, None) + self.directory_links.discard(target) + + def is_directory_link(self, target: Path) -> bool: + """Check if a target is a directory symlink.""" + return target in self.directory_links + + def get_package(self, target: Path) -> Optional[str]: + """Get the package that owns a link.""" + return self.packages.get(target) + + def can_fold(self, target_dir: Path, package: str) -> bool: + """Check if all links in target_dir belong to the same package. + + Returns True if we can create a single directory symlink instead of + individual file symlinks. + """ + # Check all direct children of target_dir + for link_target, link_package in self.packages.items(): + # If link_target is a child of target_dir + try: + link_target.relative_to(target_dir) + # If parent is target_dir and package differs, cannot fold + if link_target.parent == target_dir and link_package != package: + return False + except ValueError: + # link_target is not under target_dir, skip + continue + + return True + + @classmethod + def from_state(cls, state: dict) -> "LinkTree": + """Build a LinkTree from the linked.json state format (v2 only).""" + tree = cls() + links_dict = state.get("links", {}) + + for package_name, pkg_links in links_dict.items(): + for target_str, link_info in pkg_links.items(): + target = Path(target_str) + if not isinstance(link_info, dict) or "source" not in link_info: + raise RuntimeError( + "Unsupported linked state format. Remove linked.json and relink dotfiles." + ) + + source = Path(link_info["source"]) + is_dir_link = bool(link_info.get("is_directory_link", False)) + + tree.add_link(target, source, package_name, is_dir_link) + + return tree + + def to_state(self) -> dict: + """Convert LinkTree to linked.json state format.""" + state = {"version": 2, "links": {}} + + # Group links by package + package_links: Dict[str, Dict[str, dict]] = {} + for target, source in self.links.items(): + package = self.packages[target] + if package not in package_links: + package_links[package] = {} + + package_links[package][str(target)] = { + "source": str(source), + "is_directory_link": target in self.directory_links, + } + + state["links"] = package_links + return state + + +class TreeFolder: + """Implements GNU Stow tree folding/unfolding algorithm.""" + + def __init__(self, tree: LinkTree): + self.tree = tree + + def plan_link( + self, source: Path, target: Path, package: str, is_dir_link: bool = False + ) -> List[LinkOperation]: + """Plan operations needed to create a link (may include unfolding). + + Args: + source: Source path (file or directory in dotfiles) + target: Target path (where symlink should be created) + package: Package name + is_dir_link: Whether this is a directory symlink (folded) + + Returns a list of operations to execute in order. + """ + operations = [] + + # Check if parent is a directory symlink that needs unfolding + parent = target.parent + if parent in self.tree.links and self.tree.is_directory_link(parent): + # Parent is a folded directory symlink, need to unfold + unfold_ops = self._plan_unfold(parent) + operations.extend(unfold_ops) + + # Create symlink operation (conflict detection will handle existing links) + operations.append( + LinkOperation( + type="create_symlink", + source=source, + target=target, + package=package, + is_directory_link=is_dir_link, + ) + ) + + return operations + + def _find_fold_point( + self, source: Path, target: Path, package: str + ) -> Path: + """Find the deepest directory level where we can create a folder symlink. + + Returns the target path where the symlink should be created. + For single files, this should just return the file path (no folding). + Folding only makes sense when linking entire directories. + """ + # For now, disable automatic folding at the plan_link level + # Folding should be done at a higher level when we know we're + # linking an entire directory tree from a package + return target + + def _plan_unfold(self, folded_dir: Path) -> List[LinkOperation]: + """Plan operations to unfold a directory symlink. + + When unfolding: + 1. Remove the directory symlink + 2. Create a real directory + 3. Create individual file symlinks for all files + """ + operations = [] + + # Get the source of the folded directory + source_dir = self.tree.links.get(folded_dir) + if not source_dir: + return operations + + package = self.tree.packages.get(folded_dir, "") + + # Remove the directory symlink + operations.append( + LinkOperation( + type="remove", + source=source_dir, + target=folded_dir, + package=package, + is_directory_link=True, + ) + ) + + # Create real directory + operations.append( + LinkOperation( + type="create_dir", + source=source_dir, + target=folded_dir, + package=package, + ) + ) + + # Create individual file symlinks for all files in source + if source_dir.exists() and source_dir.is_dir(): + for root, _dirs, files in os.walk(source_dir): + for fname in files: + src_file = Path(root) / fname + rel = src_file.relative_to(source_dir) + dst_file = folded_dir / rel + + operations.append( + LinkOperation( + type="create_symlink", + source=src_file, + target=dst_file, + package=package, + is_directory_link=False, + ) + ) + + return operations + + def plan_unlink(self, target: Path, package: str) -> List[LinkOperation]: + """Plan operations to remove a link (may include refolding).""" + operations = [] + + # Check if this is a directory link + if self.tree.is_directory_link(target): + # Remove all file links under this directory + to_remove = [] + for link_target in self.tree.links.keys(): + try: + link_target.relative_to(target) + to_remove.append(link_target) + except ValueError: + continue + + for link_target in to_remove: + operations.append( + LinkOperation( + type="remove", + source=self.tree.links[link_target], + target=link_target, + package=self.tree.packages[link_target], + is_directory_link=False, + ) + ) + + # Remove the link itself + if target in self.tree.links: + operations.append( + LinkOperation( + type="remove", + source=self.tree.links[target], + target=target, + package=package, + is_directory_link=self.tree.is_directory_link(target), + ) + ) + + return operations + + def detect_conflicts(self, operations: List[LinkOperation]) -> List[str]: + """Detect conflicts before executing operations. + + Returns a list of conflict error messages. + """ + conflicts = [] + + for op in operations: + if op.type == "create_symlink": + # Check if target already exists in tree (managed by flow) + if op.target in self.tree.links: + existing_pkg = self.tree.packages[op.target] + if existing_pkg != op.package: + conflicts.append( + f"Conflict: {op.target} is already linked by package '{existing_pkg}'" + ) + # Check if target exists on disk but not managed by flow + elif op.target.exists() or op.target.is_symlink(): + conflicts.append( + f"Conflict: {op.target} already exists and is not managed by flow" + ) + + # Check if target's parent is a file (can't create file in file) + if op.target.parent.exists() and op.target.parent.is_file(): + conflicts.append( + f"Conflict: {op.target.parent} is a file, cannot create {op.target}" + ) + + return conflicts + + def execute_operations( + self, operations: List[LinkOperation], dry_run: bool = False + ) -> None: + """Execute a list of operations atomically. + + If dry_run is True, only print what would be done. + """ + if dry_run: + for op in operations: + print(str(op)) + return + + # Execute operations + for op in operations: + if op.type == "create_symlink": + # Create parent directories + op.target.parent.mkdir(parents=True, exist_ok=True) + + if op.target.is_symlink(): + current = op.target.resolve(strict=False) + desired = op.source.resolve(strict=False) + if current == desired: + self.tree.add_link(op.target, op.source, op.package, op.is_directory_link) + continue + op.target.unlink() + elif op.target.exists(): + if op.target.is_file(): + op.target.unlink() + else: + raise RuntimeError(f"Cannot overwrite directory: {op.target}") + + # Create symlink + op.target.symlink_to(op.source) + + # Update tree + self.tree.add_link(op.target, op.source, op.package, op.is_directory_link) + + elif op.type == "create_dir": + op.target.mkdir(parents=True, exist_ok=True) + + elif op.type == "remove": + if op.target.exists() or op.target.is_symlink(): + op.target.unlink() + self.tree.remove_link(op.target) + + elif op.type == "remove_dir": + if op.target.exists() and op.target.is_dir(): + op.target.rmdir() + + def to_state(self) -> dict: + """Convert current tree to state format for persistence.""" + return self.tree.to_state() diff --git a/core/variables.py b/core/variables.py new file mode 100644 index 0000000..28b606b --- /dev/null +++ b/core/variables.py @@ -0,0 +1,38 @@ +"""Variable substitution for $VAR/${VAR} and {{var}} templates.""" + +import os +import re +from pathlib import Path +from typing import Dict + + +def substitute(text: str, variables: Dict[str, str]) -> str: + """Replace $VAR and ${VAR} with values from variables dict or env.""" + if not isinstance(text, str): + return text + + pattern = re.compile(r"\$(\w+)|\$\{([^}]+)\}") + + def _replace(match: re.Match[str]) -> str: + key = match.group(1) or match.group(2) or "" + if key in variables: + return str(variables[key]) + if key == "HOME": + return str(Path.home()) + if key in os.environ: + return os.environ[key] + return match.group(0) + + return pattern.sub(_replace, text) + + +def substitute_template(text: str, context: Dict[str, str]) -> str: + """Replace {{key}} placeholders with values from context dict.""" + if not isinstance(text, str): + return text + + def _replace(match: re.Match[str]) -> str: + key = match.group(1).strip() + return context.get(key, match.group(0)) + + return re.sub(r"\{\{(\w+)\}\}", _replace, text) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..60f70c3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "flow" +version = "0.1.0" +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"] + +[project.scripts] +flow = "flow.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/flow"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..335789e1b0af295b1e02dc8fd0c2f270918b5e2a GIT binary patch literal 140 zcmey&%ge<81WOfqGC}lX5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~ihenx(7s(wj+ zZep>1K~a8IYH~@jep*g`xqeA%F_0M_pP83g5+AQuP>55 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_action.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_action.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b0623ab43316047bcea3d6889febfcf901bb7c46 GIT binary patch literal 14659 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_action.cpython-313.pyc b/tests/__pycache__/test_action.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1bdfc6456019cae31748fb66030716b93ab69833 GIT binary patch literal 5650 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_bootstrap.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_bootstrap.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5f0c1cc630f2b21e967c2ed4ad3028939f8dc2fb GIT binary patch literal 18168 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 V*Q?w4uJxn5g%93`imh}U{|kjFP00WN literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_bootstrap.cpython-313.pyc b/tests/__pycache__/test_bootstrap.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3bcf76d8c00481cf266358101e83a60cef529754 GIT binary patch literal 5960 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e7381c36629aa2f5890359e2bdb24da1f0b9b28 GIT binary patch literal 33216 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_cli.cpython-313.pyc b/tests/__pycache__/test_cli.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9873893329db2cefd8049d76b0c8dcb7f1039730 GIT binary patch literal 7524 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 F{~st^dXWGC literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_commands.cpython-313.pyc b/tests/__pycache__/test_commands.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e79f0b3449ae1027c9f8e7cbb775c33c2daf452 GIT binary patch literal 3648 zcmcgvOK;mo5GF-ZqFp0kvmX;`4s3fXO z%Bhv-sj%T5d}sh4bF6RuDGYdV7X=FRaqz9NVFc)@GrN)^DUyqzMG5HRa(8xj=9}4> z)j@Z6rvT6I`ghsGups<~AJs>$G!DLm#xubXh+v3D=#ID;A|V~y(j940Ci0>}6kQk; z&IpD)EEvj_5T8lHtS=0Gky>Ab^{LR;QR`DPozZtn*J0K@+x1*M?GQcb~xZA%2BL*9Grv3GvNz?h+uTc5Q$`jh6N%U5}<-clCYtWNG2SOPz`my zi~HSU+U!MOcCxw_E-6)h2X~?IO!&MuW{6iR?GnfodkfMM5Ip~>Af$l8lY%WSg$$|T zTQcPO?~D*t66GG^INpt-OO4}EoH{b73&$N)@i9a6Vow{-Ws?r1IhZ5Z2cDD9$Tj3PBMJs8W zP9e8O&$c$XLFfc%=+4$GF9$=j|7ba5WaA;Tdq+q_RjQgMo?z3You*waOWd znF3rWC8%T-vwuZW_G%0oJ0ekFU3h8ma#1~<2QELzRx0J1MaY8LZ|HDM7k-56#zt{6 zQVF4}^>5jmkDhk~TGUsgFSO_pp?QSn4PnTG?*eQU&mL{u&whD9aLSI6Pj0fNWjr~u&w1~6hk^(3khAsrbt+Ch8JXjW`)V+s4j zPVb$?U8*eE68m4e) z9?A!(MZ^3J@dj53VMk$MMuq)j40AgU_v%%_qKkdgIBP zG5Z#j8KEWd8f;jt&<51(`fpxIR4+fxxGA5gTv{4vBUlN@RRpY!&1Cd4GGGbQU2%xF z0#_Y6@rLI)8x2x;eq0P(PRDSdFVyIG_lLefoAlL5Uz^<3`nUTk750B%qdVm!zp}|x zam`zC3gr&|8P7U2%)8oC!4E&5P{+pkHf%Ht;`yy3WGv7>tPwK2oeZ>bUmf?g@m=lY z_ND(KJeJ_9SYKmXhew1Ro7rg^nw>67^Rv_K3Fv0Aa6jve1)81}=2H0J*dV<=Xfd;8LWFS^$z@Paz&~Lw{z#oY58sKX&%n6*4jR*Dh zptEH?Sb!zT2M9M2K0>&I(5gJl-~a+7&-72YL>KjKn9mTgIqWQ9^3ShOQjKbD<1mSfj;93+8~a?M1s3M^LS%A!qj z+1ZtBD+r)Nf0RQJ9vZ-h9*hDlqMm%rp@$q(6a~1H1geV^D9{3hZ-sU9sqgJSDaMjq zrzl`8mXGsh-u&#$d~bf<+iq=*F!1~#_p97$m|^~bjQ#kmz;+e_Uot#H8J^{R@3GWp z{QKV}iZ8~z8=%1$rug3ry&I-s2$Nohw(tR<5k3f%<3m8Bd>Cje-vYFaj{t4wIiMYU z6zHLwzIf~M+3S*`DzSN)#^&?#okT`16vSdy!9Xc5sX4hA%ib%BghDq^zr^lE42t8B_B#NZ-SIO zG;GOcf5$Mh#Lk!s+9CHJN(!WXr&&HY({b82X~}x>K&Ll8Lrj{TWX)JUG;0hHa;a#e zB(`+Yio}*>`|8@$)=WOl*k#*aA$HZ}YThCz*9R;g;=>8w2z+hP&$sZAw11Q}I5K<4 zuF>;_opzl24_`>^R@na%%QI7Ksgvgp`WbubhwkT_OguWR6GWNr%gT>%qU+(Ttj_21 zk^)Hb<%)53x%1WG(!H^`63Z217@UYJx?f&Y<38OF2}mZMw26u$QPmKbM1j)1N!<@+ z#Um6!r-v<6N_ymC>7ELx6-25Z5;9^wFHosOF*3!;)NQTdjXQI#b2WKy9SGl1Wy zB#ke1HBzEyMzT-^XKm8<}&lMN%jw}_HBLy)dDkE0myM)ho=my_K}6y)$PN9VZkqueUgb=meAxsLOor z5l%Sz3XnDSiEnCZ&C-;kped)OY)xq>n>9mIj#6B`Db_`C=+`(sq)4i$s?<=C6i1fg z`blxYq}bi`C=ytm8H(LQyOHz&Sqti1K`!2w?v+F}vtU$1dm+KA%f=||1{m58M8PRx z=&qRAU$NbKiQ={bTde#7$O^MX+5yUd?cYQy$1i9X=Qc>jQBBo>OW%yGN3MciI()4B zT7?XhU(>jO8nNCg8CacgI5V(W7pJ)#B$Quk5C#^?Fb3s7f53RalX@ZaN4Kdz;0TbL z$4x^?%-j}X)4xacp$ChSyp${G{`uv3Q)kpG4*Du|rCzs8OLip!Y&%rgpX^i^2u53B zge$3!wQDnV3L|7SQx921=xmXmgiQ1eB&U#!AVH-=6G$ArKu3`s2eJdS$Lc|ARbn@j zz6E(ubu>fll=jxl2Kl5ZV*AP`VJ}fW*|?Vg#JZac2yeos!i|Fsgv~)h`DBAIu&^IF zKrG`~L0<^OS}VRa@ujSdraeP8ups60vhKU})9(jtqB#<%RFIdXop^l-QPBbf7WnQ; z>Z3okd*MpYLy8_WGwbYviPXyw`OHY|e_Hz}>^&J=#hU>Yme!9q$czW57rz-?k6gi} zwYMCvkiK$U|n5Li#Jgh4}2hy-ZhV^!KgZ#t;P={4Uf=|vP0Qx!tbg3Z}ry3oa8Yy-)IyB!K*BKo=CWYqG&fVD$ zXr35^do6A{Zp~zWR$9{847~vzq=zzcu?RQ8dN4yJ5diYkIlX4yjPLK-$Gp8ZZhGix zsMB-9gR7o#!((3XG@KQoor1DB2RG4%HI3r=84sk&9Uz`d=IX%s!%ORtGYEtJ`wNxM z6BTj-sK%YB5$mmz69@!10>Ybhahl6P0%UKH1{Ml=3J@MVGZ5f<_t{`T3@cq=#tVbs z{~-*5`+z~nvC@V4mgj1vLl{JC7?>s=HfO+KnX`_5Bg@Ld(;+}bn0#~&r(whFZPy)T?uQVXGkA7{11l*DA)gWQ?WWvkIvus{--j&W{W6~pywAw^|!qLj-TndazQ%tPfd6jqpBf z(_y>4_P=G+kobsv{lDnttDauwp1W>wEb9B}WhWQ(GPkR4`JeQ1noXhMqv5gr9EvUJ zmK54V8lGa%gBQGBX?Wz{gSjeVk7$wOqw=5AQDejOH@nBKvDveAz7UQn%`FT(I$G1M zfSR_MKYGTq{ke~4i``bcb$ddMlMBYPeb?~}+I8?9`)WTN&OK!}-SWAkVb|e&2Uw~+ zxtpSYXKwhOj@@#(U!j}c;qwN z%s&PN=tPz7s>VJz9;CP@MHi@X)8vo(4cW6g0f(v4<#vy=)EKnGh#rSV=YOb1U!A_P zqsj3$bKBl2NJV9lN&?LYN-ilOC}+s6XDJht&f3 ze~9XW9)h}zK$|=ELN-U~EgZtzNKnBw9C2v@JJRF1W%*~B-Fe2F@`jgr-Si|geY(cR z>bMC7-7ZYe2$y<~&~CQ~IYr22r9w$o;R1`UKt?=+E1!b<3S2I=b=~jL+6GsyZ5=)K zpjA6MvNHY8Xjd&7uSVlvomh|lXyxj6t=+ZOlhxLfU!7fVy}a`F-;eZENKg67>Qs&N zm~WNzpsSU|nSsr^IL+lCp?sx57+8?lI@*hO%<0woJLWSD*T#6q?8f08v&G>ZbGkto zSdiG_I%`}%*oxP=ft4#;T>BU2K0j9>6TiMy>l)FzMj*I)uF^HK&LtrDaL^zc2j?y8 zT_+P2F5$3dU=wnkcH%Ub!vt_@bF)Ku!>G-U17WD!;6NB^HpAX(Ap8(cdv%4FtkeE6 zZ2u{(>r{n|Vf#<5bKu7^_K-D*#*Nj8^{$h#3I`4i+Wbg){b>`J-rydf7teg=9J#70-8a_n&OM>ZenJ{qzPLK&I z-Jv-ZoGqOwfTS5`U|(;o^Sw6xlhD**cm)}b28I#SFo77itj2{B#RIv43-cn`>z-AR zvx|A@0{tb3prTf$fc)Ffvg}`(sedud&zMc-_~W3T?Rgyavo9k#hNK@!ACgxdM}6$@ Qomj6qc;@Pb@po07*)IaME;w0VgF8{z^hpeupxFS_uT`RHMjBB9J9_r3A>y zM}DT$97-pL9ymfz9655NH!03c(j5++X?w`6liWDc z0SyUZpkW~bG$PaitrMa^qXG|2$T} z4G3Jn|5axMG{$@lb22c}#3-x~tbCthlA#fTsL&*kF;?k963H0Wq^c<<;h^^-fT;*1 z;>)PKUNEFK zv)Q?5R+0@fHEo#I3kL6jnRq89^*~7Ev?gUIGSVRFtF-mzoqJAehAe63AauMnao}Lm zt@F&Az4n;oT$*rMClRKGBy}Yw1ew#+KQwif8V2tM@`58_Q?Vq=!mfK@o(={GpEdNm z%7dI_$bB7PY$uouP>(B$pgzv3>aU z;X?F0^^*GeQ}c%ke7lX>9lqUF)s2>)cA!4U*I2Xq&oFVLsNy0u0!s@sM7U3>R zS}AF#Jj&5-t3=ypQ4|6nfcXt{O%13AoHOG~=;jtspO`=3r38m>{e+?R(|~U=B~ygq zgRu@yE1G7Iz?~nrM!KiAk+G6BW|hrAe@+cv2Lz(N$xdJYV@Em8xEsEZkhhqy(;mb< zFt`Q8t?%0%AAyC<@YF&Wg89Z08ZSe5=ygXSdd>^x-|mrD7k6k+eJ(h~q5r=)ghcYTOFcmNoI(D(W@8xhIddyS&bt90*V$n%2+ zQPIsAtcX|^Op( zRrm*(eiR<_S0HeN)Hg0PJM|s;iz|EDp2eL#sr<-4vBu?Caxs>C-d%`&lRy79-n1M) zuoyq^{CFXLHhB;i&%nIMI%(p@b3D<`FxfOoL&!--p z`sL1YW6Ehv75JW)9R>b~jgHv-k+P2moyLO&{?JRdz#q2JVTV8Ldh|##M#7?)F=SCB z@rpkIy!#Qo?Yg%cx9)XIBlmoCQ>mLnFbyVGVec#`zvpF*%#5ZCVhN^D9L*Cz{^MAd t{gWB^m+4vyacuKijAQpv(nd)uB`uVEu@(!k``4lYwsSqovwg0>{{txFxdH$H literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c16fe754be83e2242dd7db5a151e7427a65bdc1a GIT binary patch literal 13603 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 VSABlp(TB3^n|jnmd`B6_{{a+OzHKiIK>Le&! z0Y{2BHma&{N##Qi9GhGJlwEBl=qR_Us&b1Ur(W7OYp*{5MLA@oefwtSy_q-f&F{TA zOe7=(?Z363bvugCU%V4EvB4ZJ0P_kdh$2N$#Jj?RNJW|Rp}V04Oz}dPh8H3bGK*QCJk zcdEUX#H{PfO69gc#mF5H2Hw^-ZAMC_TFBLvSidVags@WbbgegXRZNE4QX`;o>_+fNbG4-jgTiM35O zSS8dhQ$$I;$cSMY+NN&Vnqf0kJwvr3bm&&fHp{AIm%%;dWSH_DnWS5~VcDvY*C;oW z#S5mrs+Tk?w@yp!G&sdpbwi`MV3nlEVs^QbtAI~gD8E{?_@QK(ow0c&ra5i+GF91P z=lp(~gL7rwf<22lz6(7Cy8Kwyw?MYh8|m!M+U_~GFXc(o+qd74vpe};ukVowmrOW# z!XXp;qRxQHPBBW1}u~Co&$%I5^{y%poj@I>N_8;)m}Wu=o`Uf@W~{ zH$%y_>P-=YaLTAXoWZCeE{XS>p$)VU>I@0UoD^Ez9yX9UDYW4uGCsUo`*2Q)9Fd84 z&igXaBQo75$i&)ZmU&eC29});_|`x0fYQ~jb+W7?g+cNDnU;3IDdeNI`vRTTUx|OT z_CQN3;FQEiXm|UT4+c7|ztYpL4WR2?WRZIMP$jrY(lXU5rls2^-Fhgs11ek8?OX7z zt*>wtrq?ZvPJ@7zg29&L3I#x}WeeJdBrS8GSr4TOH6Lkt+sYMmnteWBRw_gt*C_mdvwAYnbtxYWJP z;_V)$2`-apITbv{;_d!op^|2>Xv1Bs%TUXjRZ;UA*jQ3+C`e@%17GCUOv`3*&glin z$hy6i3{j3jM(TH_o(X$k#Nz2mmSx(@r~QPQYfqiJp;KNveAy{D?l#&#Jy?6-o}Spg zyN`SJ@Suwa-=W@N{2uj&!|%rs>DzhiNn_i$n~BpI$$BK~;H*Qk{@{6+oZp@B$R!6~ za>%8oU~RK=te#=#JTm9tIfu;og2OHu-o+jncksAF##_o8UXZfiknSI?m(N~2^B`+K z@rdkT*&#BBzRbMH>^ymKbB|nb$%WmjM`j#64P3>7t?6Z$>IBum3EtmOOL~FMg6uUe{{s*JR!@Iz+U-ehf4z_6d$`}l{qIot zGXPc?fE7pGLp6xUIFHBKTAxQo96aKX5gwh(E;(0wdUH||9 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_console.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_console.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..170038b1f8588db001d8523194752e9f519da4c4 GIT binary patch literal 14562 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 ZKl2{;+4$kZ0m6F@-2y*c!+FL#{sjViJNEzp literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_dotfiles.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_dotfiles.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..616e5b0481020f5a68d7b7d9baf45754ffa6d9c1 GIT binary patch literal 10557 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_dotfiles.cpython-313.pyc b/tests/__pycache__/test_dotfiles.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe451b80fd284a4318e9bff9d831bd7ebbe332ff GIT binary patch literal 3316 zcma)9O>7&-6`tkJE|*&V=*N~^SwE{t{-I=1cI%(YZQ~}EWtWPTz-u}N)P`WGCAk&3 z%g!z(n=MefmpF|PFhGnlHznx7zUA0c(Vlwcf&wHjY{aMw6ajj0%~Nvfn;};s9jobp z`{vEt_hxp!_cOz0DAa?X{ad-G|6W4q869}d*Jhf30_Hwa5kV@a`j$AtLC-Jw;ymFM zD#J@SE)aq7z9o@}AQP7SafwLr00}U;xYQGui5w4-ARGIaLh&#OGhSMX#Cu7vf_^Su zd(B&ZuU7-Z=uCS^`qiFcLNQ7wrbiLJ+E2HKPsOndEHo>D;cb_?37)yl)RbM)3LAULd#Lk#qXr-9Q1*!DUe?^ z(`!+m+n-$3t+ctWkz_HIUQ1;)%MB$rQu#G+3`&7Fip7hjkklsfLz`o@v{6X#4lomb$b9|Ht}FNA1~nM!91~qz0*;8lu$jC46f5 zJ6CAysF8hF=(G1)AMvQ@kq|5$Eg)!gwsX#N<%Mz0S=VH51x>fFp(GFtJ$dtuN zg$`V)NK6)pF5WQ7n#*T(8x(<3H9cGIUnyA{`9+VBDrm}2ly^Z1qDm;N!B@IH8$`FY zBnv3smFz+hBG1l|AUK!|0I{gI6-99m#pZ093XCSbt;UMC-HZZ*s5b3HrZ(4|$ZX}x zju5H}j&N`rk2rW_^Lm~3)1I^zbcCsGeA2-u>!%jC@a3oSK;`Ww>;ZLU62*f)%{Y+z zXk|BV63B}X11egA`w_ffg{n7%G{~`+#ypI{(tDppQM^n-U_tsRISAw-M+DmW7^2|k zc7?ELB~=viJ?Pv~>XUC*kz^~@h(cB2vCaaipvRGk&DloeBw!);RkL<{O z)te1@w2nvX@+iEkNex-4W5tmbau`gz5TIYh5V!(NHv+Vx&--Bibbs*H+ngHGh9--(2W zk|U4&4xNptzdl99e6^c4*P`iDq1Sr$W##3>E zJAZQzYrrzF%yO3j%22#c!k|Zbfs})j3M~|^T0`go8Rf|!eLX}8oe%^xi(QE-CTUR` zhoM&rT9J2rAKcrJC+Vq1^i|Cw6!;jmPXMW)r;(9b*@;9SEIN_zf01w^=fMd$`2O;p z<=W}WvV#?xl9D4F+Qw53p4xnG3!nZcjoh}!ATg_#KDbcJHRPjpeAJPTe%mvCSc=8y zJ;<`o7*#nBQ#ktoNs4{jQ@#~y!P*|$Hz&@}e1o=Y$FI=YSxfMqRK=Y>Nbm#b2EHL! z{3(dm0dxi6EWtd*ODln0TYQb(mb`JR_vX`mS4R+%A$twcz=og-39&1q#O^U$QMlpX zT@ifER(!W(#oc&<*`dy`*(F0T{2e-Q*P@ts-;t0ySMAbcar%Zayc{y=geFtwWj6Fg4Q}~XzlxoBF%!7^Z^$Y33Z5z^Ew=(B4zo!1_vmNTuhI% zXrL9Rn9k#hmRTZc%@v9%omeghzohGL34ym=znvmk&9*G+131%KtrgCFESx*9UWC@n zd3db91A(F;N2(Dhix`c^x^qh z--j3AfV+F)(XkU-;n~kipW3xw-?OTx{&@D|vmgEDvr^@<6Q13HSl(#JV|6@Mm&bPG z12um`9!yq%v9FsVibO4NE&9vEo9D&9og2D{#ORRgPr_-QP9|Oa zD+{hCa4oTOdHt6y>_a6(w<$ES0$f=M25VO=-lh_F@YNS$RzX_s>TPSl^`~_iAvALc z{53#sU)MW&jV07AZvURV*uKpYnkTM5qu-)8Dduu^vazzx$_w+=L&WMp%b(JZ3+8Gm zuf0y*1Y;-y$a)LNbDrb4M`+<0lAoYMPtfEu6#5rh_!9l#37UZ)+*W+j`(t;;{^JXA g2bw(c^**QE3k7o%FT$7`eG!(pW6i-H?gU%yzs@}DLjV8( literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_dotfiles_folding.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_dotfiles_folding.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6cdb437946e26448cb113426e3a063db52499a8a GIT binary patch literal 28206 zcmeHwdu&_To!%vRNs+u1Nl{PBFUqoPQMM^-{E|K6S7z+7<*_EwH63r1M4%-~W=0~l z_fmc+okZRZcI2Jxj?!cbZCjwqViu6oMdKD&bh-_Sq|FvV8=$;H$x;ZDbpiqcD5*X=ZZW$?{n^Xe!s^#`mnY(DB)?!{$6&qLz4c29M
JqXF8TWcT7dbL2K7l5QJKiLT_VEq#H}p@W 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_dotfiles_folding.cpython-313.pyc b/tests/__pycache__/test_dotfiles_folding.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fd3bfb7aa207f18d00af32b77f26aa7611557427 GIT binary patch literal 12235 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 J#n#OS^S}O? literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_paths.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_paths.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5362077fd40985edd0dda6307e54cbeec9a88108 GIT binary patch literal 14095 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 Q;QjoG=fiD$2V25_0O^<2fB*mh literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_stow.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_stow.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6847b3cf5937927171c908d2a388f531e435758c GIT binary patch literal 36934 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_stow.cpython-313.pyc b/tests/__pycache__/test_stow.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99e7f4d9e0835c2b1ecdd02cf026ae879028d4b3 GIT binary patch literal 12063 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 KI$Z*t^M3=7j+-d} literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_variables.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_variables.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a239b011fff4c114b9dd6bd75a384d6b5aa2c27 GIT binary patch literal 8236 zcmeHMOK%(36`mPhK1Y;o`Ryb!Ma!}o*rZ53;z#6H<21Ev8PJ4)0y&tD$g#|poDuF& zeozqV0$CVE7TOe5yQww>q@B=xfc^whjG%OyT^B`wE(&YsRnNKeojq{tUpQ@bgHHwDzv1`mQ^GD1K!+ZN-$%_!mLp(RTrwNdC)7% zRkbo-R;p^jehPL8gABlL=NXTWGy~E)@PVYjFh6ELv<45L@6Yp>KI1q&dDiY#{42-J zIIo49BF<>eO?;r1%X(sP^7@^dCSfx8NT=oE3%i9W>--C0Vq%@I z^_@OD|7192Bqk;jSR6?iW{9fBLb@YW6_0JiAaTS4$d9Ue=+9VIGr>2l_&}$hyjNWqnpD z%bHSA&8TcB_f?$1?7hxbE$P}#N_A=;lmW#Rr7EkB0g{?hg_iXo#g}f1xQum|Ilq#% z6l>t9Ul}*RP`LyiW;XLcuc(=-UQvw9Jk@`#7OF-D?InYcW-uvxRx|&^Ji?yYXDCIz zTvjL@fJqUg23q@5?)&)BmH1j{BV0dop&7sU^waMSC+j4+{AEK-ZWH_5B*|yvu4Was zTIw2}1`W$!wsi&-=GcK1xp#JoZvhX#b^p}tG;Z7JBAVzDMZi#WsuaB%jiL}yAhu~7 z1-b)z07V}P@NJw2V44866Oyx3DX0b=ghrgr2g!YFLmOx6sY}iH- zU;d&Y_HPsW+$87krJx_kbC;pdYjTF*iZL6az}wz4N4&wmSHLKR$Gg;00GE`{;|p!wnH zKF=hi$czi`sjk570J^tIZ`*Bx>Ng_i-WbjIMs+9KuIT~L{n4X)Z^5DDlH;j~gr1t% zG(R|lh+~H@5+MNF4!GZ-GnaFGWVbkDbW;k z!=mk>Ikl>44}Dh9W&q46JSzy%5m?kBBn~0ASnKEXjI1U zKs@FCN#aP5qpP`Qd9PTN2Si*~Rx2(K7&bzb9!IgR%c+-Wbhh3s9=O(urg~%vd>~oE@hl!F={krq+&}> z;(ak?nI)vN=)f>+3`|KZqqeR3M0;e@bWJv@w4}|^pJQkE8Gi)vl>6?;@j5xZk^}MerR73X z1Ri&K^%N5g@$@#a&rNc=j@-_xf$|m(a_btN1`W#vtD%XryG3^ns9rtg4)qsc=}wDx zx3b>lBP`0j`ev3B>${o~xO`a!Ffw zY=$0R7?;OKQvwT5Cc#d%V{9PnaNcUSN9~@zz>XIB>sJklU32jMb^~$tPvu#~C>7`( zSYQy1gfb)fKAxx#T>1N>`t+|F*OX>_789OKon-2VGYv7bP3&`%WH91+6;R%4scU!| zG{Eq--k`!HyWoG1l!t%AOk7|oPoO0ef4x`-TOB^n-!;~GamS5uue*)AcWiSb7DN|k z8yBO?+eUr_GV#U5O35(5t1d0=j#>IkL@d0bjwEF8rAV}=mVcu@p?6_q3P(FhNn-Vb z4WStyK_LEUJC&U2;i3bk6AxPV)9XMyT9_+HR5p{2s&od)IdnpgNl_olv`Fy9_*@$I44 z%47aNzCGlNA6>H@-zHnw_3aTU`qq7WY_b-EZ&xKPQB@yTt(5u~fUGxl@@IF!unY?o z)CYRGs8WM|4y)u4$Q1@;AReplZ;*|-wcj@5SJBx6um_g!HN=5!VxOC207-{e0p+ch zx`wAg!}7hh&Y;2^44bJ#W>}VsdO?=Wi1Rs+?YWu9W$OcC+RAU>qh6Y-k7x-#L`U5! zGi=u}c4i*z@-3U~YgkTT0ADH6EVjMOcHY?51Itku2V;ScqII)3v(B-*w6-oHA6N9^ zLRq~+??F3kl5xfj5dRJEJpT`F@ZTKw6}Qb@eI63{Q_o|zIEkf!=dl2vd)~wIL(d}u QKWw-1{P`VFlWdLu0g+)ZO#lD@ literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_variables.cpython-313.pyc b/tests/__pycache__/test_variables.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..559e73329dda3315bf291166d1d0df7cc68c89b1 GIT binary patch literal 2529 zcmb_eO-~y~7@k?LZSb)4BP9@;x{EO%uE54l8>Jy61&OFi6jx${ls2ul#@-mKYcHBz z1F>b5AJ9WCk?5&Dnw$SXrT&Dq_R?;9>!Hf6f?H30XVzfjB}l2d(mXrs`Ffst-gm|) zk`zJk{4D=aS`Q-hC;K>kgl^^JF;re61yQ76MR^9NCwx=PWJXrOIER0|lU*ECIYkPci=$fU#EsOZbzn zvI^Qnig^18LIwC`9BH_S62WYZY-_r1$noszqjgv07~C_cUihcm!at5MWvd)vH$*kF zs#_*oNOrF!Qx+4&QSE8jdG20i4Qe0%f}!g@k6NYIpyGE=5tG6>|AGVlg}}e~s+_A7})x~SIaGJMdF4i(xn)PXm+^~GZ#TN^Hv=t}(aFCMMeg8X$c`}YRfZyRNePQZd= zjQMo{FSbZ?!;$70>30uj9U|MJ+##}`^qCc3ang`J=pm}0r_3JyC_*23yor^-CfZ=> zH}EK;5# zL(;aG?vS*9Th6?khY8O|Xm2|dJRnlYzN$liZru^$vwEy22W_hu71gw8sZyjd7$HM3 z#{s}RGWH^Wn1MKJPCI1A7H2wS#!v9fxBs3(?^kw9<;8m4FdGecAwV4E(0mNWESznI^c= zXwW&B$)_OxS_R#K>UY|6`VkCGKsn1px%oj`aHJbv4EB!qOZdNOz_B$w&g?Mmi@E97 zQtk`BMHrK0j8B6vdPpH_$ZRX-kOf;@=#T|J**Cj=2RB^I)*^7DmnyQQ?OR@+GvhDb z`T0v^s4z`l+cxxqMopT6IaoHBG60XF=JmGNF1EgRqqjvbHdLgFQ(c3%`ZNFPw*p`A3HpMc?5I&Dcey_g%0>Jbf;_-s=9WUGLmos zAae!a?*PX5Hx&O1-8&8mc=|Zfg%4S6>Npa>nd1?RXOD*jywq*Qc<}^2V_e0*I?p2- literal 0 HcmV?d00001 diff --git a/tests/test_action.py b/tests/test_action.py new file mode 100644 index 0000000..0919380 --- /dev/null +++ b/tests/test_action.py @@ -0,0 +1,115 @@ +"""Tests for flow.core.action.""" + +from flow.core.action import Action, ActionExecutor +from flow.core.console import ConsoleLogger + + +def test_action_defaults(): + a = Action(type="test", description="Test action") + assert a.status == "pending" + assert a.error is None + assert a.skip_on_error is True + assert a.os_filter is None + assert a.data == {} + + +def test_executor_register_and_execute(capsys): + console = ConsoleLogger() + executor = ActionExecutor(console) + results = [] + + def handler(data): + results.append(data["key"]) + + executor.register("test-action", handler) + + actions = [ + Action(type="test-action", description="Do thing", data={"key": "value1"}), + Action(type="test-action", description="Do another", data={"key": "value2"}), + ] + + executor.execute(actions, current_os="linux") + assert results == ["value1", "value2"] + assert actions[0].status == "completed" + assert actions[1].status == "completed" + + +def test_executor_dry_run(capsys): + console = ConsoleLogger() + executor = ActionExecutor(console) + executed = [] + + executor.register("test", lambda data: executed.append(1)) + + actions = [Action(type="test", description="Should not run")] + executor.execute(actions, dry_run=True) + assert executed == [] # Nothing executed + out = capsys.readouterr().out + assert "EXECUTION PLAN" in out + + +def test_executor_skip_on_error(capsys): + console = ConsoleLogger() + executor = ActionExecutor(console) + + def failing_handler(data): + raise RuntimeError("boom") + + executor.register("fail", failing_handler) + + actions = [ + Action(type="fail", description="Will fail", skip_on_error=True), + Action(type="fail", description="Should still run", skip_on_error=True), + ] + + executor.execute(actions, current_os="linux") + assert actions[0].status == "skipped" + assert actions[1].status == "skipped" + + +def test_executor_critical_failure_stops(capsys): + console = ConsoleLogger() + executor = ActionExecutor(console) + + def failing_handler(data): + raise RuntimeError("critical failure") + + executor.register("fail", failing_handler) + executor.register("ok", lambda data: None) + + actions = [ + Action(type="fail", description="Critical", skip_on_error=False), + Action(type="ok", description="Should not run"), + ] + + executor.execute(actions, current_os="linux") + assert actions[0].status == "failed" + assert actions[1].status == "pending" # Never reached + + +def test_executor_os_filter(capsys): + console = ConsoleLogger() + executor = ActionExecutor(console) + executed = [] + + executor.register("test", lambda data: executed.append(data.get("name"))) + + actions = [ + Action(type="test", description="Linux only", data={"name": "linux"}, os_filter="linux"), + Action(type="test", description="macOS only", data={"name": "macos"}, os_filter="macos"), + Action(type="test", description="Any OS", data={"name": "any"}), + ] + + executor.execute(actions, current_os="linux") + assert "linux" in executed + assert "any" in executed + assert "macos" not in executed + + +def test_executor_no_handler(capsys): + console = ConsoleLogger() + executor = ActionExecutor(console) + + actions = [Action(type="unknown", description="No handler registered")] + executor.execute(actions, current_os="linux") + assert actions[0].status == "skipped" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 0000000..dbfa285 --- /dev/null +++ b/tests/test_bootstrap.py @@ -0,0 +1,129 @@ +"""Tests for flow.commands.bootstrap — action planning.""" + +import pytest + +from flow.commands.bootstrap import _get_profiles, _plan_actions +from flow.core.config import AppConfig, FlowContext +from flow.core.console import ConsoleLogger +from flow.core.platform import PlatformInfo + + +@pytest.fixture +def ctx(): + return FlowContext( + config=AppConfig(), + manifest={ + "binaries": { + "neovim": { + "version": "0.10.4", + "source": "github:neovim/neovim", + "asset-pattern": "nvim-{{os}}-{{arch}}.tar.gz", + "platform-map": {"linux-arm64": {"os": "linux", "arch": "arm64"}}, + "install-script": "echo install", + }, + }, + }, + platform=PlatformInfo(os="linux", arch="arm64", platform="linux-arm64"), + console=ConsoleLogger(), + ) + + +def test_plan_empty_profile(ctx): + actions = _plan_actions(ctx, "test", {}, {}) + assert actions == [] + + +def test_plan_hostname(ctx): + actions = _plan_actions(ctx, "test", {"hostname": "myhost"}, {}) + types = [a.type for a in actions] + assert "set-hostname" in types + + +def test_plan_locale_and_shell(ctx): + actions = _plan_actions(ctx, "test", {"locale": "en_US.UTF-8", "shell": "zsh"}, {}) + types = [a.type for a in actions] + assert "set-locale" in types + assert "set-shell" in types + + +def test_plan_packages(ctx): + env_config = { + "packages": { + "standard": ["git", "zsh", "tmux"], + "binary": ["neovim"], + }, + } + actions = _plan_actions(ctx, "test", env_config, {}) + types = [a.type for a in actions] + assert "pm-update" in types + assert "install-packages" in types + assert "install-binary" in types + + +def test_plan_ssh_keygen(ctx): + env_config = { + "ssh_keygen": [ + {"type": "ed25519", "comment": "test@host", "filename": "id_ed25519"}, + ], + } + actions = _plan_actions(ctx, "test", env_config, {}) + types = [a.type for a in actions] + assert "generate-ssh-key" in types + + +def test_plan_runcmd(ctx): + env_config = {"runcmd": ["echo hello", "mkdir -p ~/tmp"]} + actions = _plan_actions(ctx, "test", env_config, {}) + run_cmds = [a for a in actions if a.type == "run-command"] + assert len(run_cmds) == 2 + + +def test_plan_requires(ctx): + env_config = {"requires": ["VAR1", "VAR2"]} + actions = _plan_actions(ctx, "test", env_config, {}) + checks = [a for a in actions if a.type == "check-variable"] + assert len(checks) == 2 + assert all(not a.skip_on_error for a in checks) + + +def test_plan_full_profile(ctx): + """Test planning with a realistic linux-vm profile.""" + env_config = { + "requires": ["TARGET_HOSTNAME"], + "os": "linux", + "hostname": "$TARGET_HOSTNAME", + "shell": "zsh", + "locale": "en_US.UTF-8", + "packages": { + "standard": ["zsh", "tmux", "git"], + "binary": ["neovim"], + }, + "ssh_keygen": [{"type": "ed25519", "comment": "test"}], + "configs": ["bin"], + "runcmd": ["mkdir -p ~/projects"], + } + actions = _plan_actions(ctx, "linux-vm", env_config, {"TARGET_HOSTNAME": "myvm"}) + assert len(actions) >= 8 + + types = [a.type for a in actions] + assert "check-variable" in types + assert "set-hostname" in types + assert "set-locale" in types + assert "set-shell" in types + assert "pm-update" in types + assert "install-packages" in types + assert "install-binary" in types + assert "generate-ssh-key" in types + assert "link-config" in types + assert "run-command" in types + + +def test_get_profiles_from_manifest(ctx): + ctx.manifest = {"profiles": {"linux": {"os": "linux"}}} + assert "linux" in _get_profiles(ctx) + + +def test_get_profiles_rejects_environments(ctx): + ctx.manifest = {"environments": {"legacy": {"os": "linux"}}} + with pytest.raises(RuntimeError, match="no longer supported"): + _get_profiles(ctx) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7aff0bb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,153 @@ +"""Tests for CLI routing and command registration.""" + +import os +import subprocess +import sys + + +def _clean_env(): + """Return env dict without DF_* variables that trigger enter's guard.""" + return {k: v for k, v in os.environ.items() if not k.startswith("DF_")} + + +def test_version(): + result = subprocess.run( + [sys.executable, "-m", "flow", "--version"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "0.1.0" in result.stdout + + +def test_help(): + result = subprocess.run( + [sys.executable, "-m", "flow", "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "enter" in result.stdout + assert "dev" in result.stdout + assert "dotfiles" in result.stdout + assert "bootstrap" in result.stdout + assert "package" in result.stdout + assert "sync" in result.stdout + assert "completion" in result.stdout + + +def test_enter_help(): + result = subprocess.run( + [sys.executable, "-m", "flow", "enter", "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "target" in result.stdout + assert "--dry-run" in result.stdout + + +def test_dotfiles_help(): + result = subprocess.run( + [sys.executable, "-m", "flow", "dotfiles", "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "init" in result.stdout + assert "link" in result.stdout + assert "unlink" in result.stdout + assert "status" in result.stdout + assert "sync" in result.stdout + + +def test_bootstrap_help(): + result = subprocess.run( + [sys.executable, "-m", "flow", "bootstrap", "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "run" in result.stdout + assert "list" in result.stdout + assert "show" in result.stdout + + +def test_package_help(): + result = subprocess.run( + [sys.executable, "-m", "flow", "package", "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "install" in result.stdout + assert "list" in result.stdout + assert "remove" in result.stdout + + +def test_sync_help(): + result = subprocess.run( + [sys.executable, "-m", "flow", "sync", "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "check" in result.stdout + assert "fetch" in result.stdout + assert "summary" in result.stdout + + +def test_dev_help(): + result = subprocess.run( + [sys.executable, "-m", "flow", "dev", "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + assert "create" in result.stdout + assert "exec" in result.stdout + assert "connect" in result.stdout + assert "list" in result.stdout + assert "stop" in result.stdout + assert "remove" in result.stdout + assert "respawn" in result.stdout + + +def test_enter_dry_run(): + result = subprocess.run( + [sys.executable, "-m", "flow", "enter", "--dry-run", "personal@orb"], + capture_output=True, text=True, env=_clean_env(), + ) + assert result.returncode == 0 + assert "ssh" in result.stdout + assert "personal.orb" in result.stdout + assert "tmux" in result.stdout + + +def test_enter_dry_run_no_tmux(): + result = subprocess.run( + [sys.executable, "-m", "flow", "enter", "--dry-run", "--no-tmux", "personal@orb"], + capture_output=True, text=True, env=_clean_env(), + ) + assert result.returncode == 0 + assert "ssh" in result.stdout + assert "tmux" not in result.stdout + + +def test_enter_dry_run_with_user(): + result = subprocess.run( + [sys.executable, "-m", "flow", "enter", "--dry-run", "root@personal@orb"], + capture_output=True, text=True, env=_clean_env(), + ) + assert result.returncode == 0 + assert "root@personal.orb" in result.stdout + + +def test_aliases(): + """Test that command aliases work.""" + for alias, cmd in [("dot", "dotfiles"), ("pkg", "package"), ("setup", "bootstrap")]: + result = subprocess.run( + [sys.executable, "-m", "flow", alias, "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0, f"Alias '{alias}' failed" + + +def test_dev_remove_alias(): + result = subprocess.run( + [sys.executable, "-m", "flow", "dev", "rm", "--help"], + capture_output=True, text=True, + ) + assert result.returncode == 0 diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..0ffc93e --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,59 @@ +"""Tests for command modules — registration and target parsing.""" + +from flow.commands.enter import _parse_target +from flow.commands.container import _cname, _parse_image_ref + + +class TestParseTarget: + def test_full_target(self): + user, ns, plat = _parse_target("root@personal@orb") + assert user == "root" + assert ns == "personal" + assert plat == "orb" + + def test_no_user(self): + user, ns, plat = _parse_target("personal@orb") + assert user is None + assert ns == "personal" + assert plat == "orb" + + def test_namespace_only(self): + user, ns, plat = _parse_target("personal") + assert user is None + assert ns == "personal" + assert plat is None + + +class TestCname: + def test_adds_prefix(self): + assert _cname("api") == "dev-api" + + def test_no_double_prefix(self): + assert _cname("dev-api") == "dev-api" + + +class TestParseImageRef: + def test_simple_image(self): + ref, repo, tag, label = _parse_image_ref("node") + assert ref == "registry.tomastm.com/node:latest" + assert tag == "latest" + + def test_tm0_shorthand(self): + ref, repo, tag, label = _parse_image_ref("tm0/node") + assert "registry.tomastm.com" in ref + assert "node" in ref + + def test_docker_shorthand(self): + ref, repo, tag, label = _parse_image_ref("docker/python") + assert "docker.io" in ref + assert "python" in ref + + def test_with_tag(self): + ref, repo, tag, label = _parse_image_ref("node:20") + assert tag == "20" + assert ":20" in ref + + def test_full_registry(self): + ref, repo, tag, label = _parse_image_ref("ghcr.io/user/image:v1") + assert ref == "ghcr.io/user/image:v1" + assert tag == "v1" diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 0000000..7745a06 --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,63 @@ +"""Tests for flow.commands.completion dynamic suggestions.""" + +from flow.commands import completion + + +def test_complete_top_level_prefix(): + out = completion.complete(["flow", "do"], 2) + assert "dotfiles" in out + assert "dot" in out + + +def test_complete_bootstrap_profiles(monkeypatch): + monkeypatch.setattr(completion, "_list_bootstrap_profiles", lambda: ["linux-vm", "macos-host"]) + out = completion.complete(["flow", "bootstrap", "show", "li"], 4) + assert out == ["linux-vm"] + + +def test_complete_package_install(monkeypatch): + monkeypatch.setattr(completion, "_list_manifest_packages", lambda: ["neovim", "fzf"]) + out = completion.complete(["flow", "package", "install", "n"], 4) + assert out == ["neovim"] + + +def test_complete_package_remove(monkeypatch): + monkeypatch.setattr(completion, "_list_installed_packages", lambda: ["hello", "jq"]) + out = completion.complete(["flow", "package", "remove", "h"], 4) + assert out == ["hello"] + + +def test_complete_dotfiles_profile_value(monkeypatch): + monkeypatch.setattr(completion, "_list_dotfiles_profiles", lambda: ["work", "personal"]) + out = completion.complete(["flow", "dotfiles", "link", "--profile", "w"], 5) + assert out == ["work"] + + +def test_complete_enter_targets(monkeypatch): + monkeypatch.setattr(completion, "_list_targets", lambda: ["personal@orb", "work@ec2"]) + out = completion.complete(["flow", "enter", "p"], 3) + assert out == ["personal@orb"] + + +def test_complete_dev_subcommands(): + out = completion.complete(["flow", "dev", "c"], 3) + assert out == ["connect", "create"] + + +def test_complete_completion_subcommands(): + out = completion.complete(["flow", "completion", "i"], 3) + assert out == ["install-zsh"] + + +def test_rc_snippet_is_idempotent(tmp_path): + rc_path = tmp_path / ".zshrc" + completion_dir = tmp_path / "completions" + + first = completion._ensure_rc_snippet(rc_path, completion_dir) + second = completion._ensure_rc_snippet(rc_path, completion_dir) + + assert first is True + assert second is False + text = rc_path.read_text() + assert text.count(completion.ZSH_RC_START) == 1 + assert text.count(completion.ZSH_RC_END) == 1 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9c84b32 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,70 @@ +"""Tests for flow.core.config.""" + +from pathlib import Path + +from flow.core.config import AppConfig, FlowContext, load_config, load_manifest + + +def test_load_config_missing_file(tmp_path): + cfg = load_config(tmp_path / "nonexistent") + assert isinstance(cfg, AppConfig) + assert cfg.dotfiles_url == "" + assert cfg.container_registry == "registry.tomastm.com" + + +def test_load_config_ini(tmp_path): + config_file = tmp_path / "config" + config_file.write_text(""" +[repository] +dotfiles_url=git@github.com:user/dots.git +dotfiles_branch=dev + +[paths] +projects_dir=~/code + +[defaults] +container_registry=my.registry.com +container_tag=v1 +tmux_session=main + +[targets] +personal=orb personal@orb +work=ec2 work.ec2.internal ~/.ssh/id_work +""") + cfg = load_config(config_file) + assert cfg.dotfiles_url == "git@github.com:user/dots.git" + assert cfg.dotfiles_branch == "dev" + assert cfg.projects_dir == "~/code" + assert cfg.container_registry == "my.registry.com" + assert cfg.container_tag == "v1" + assert cfg.tmux_session == "main" + assert len(cfg.targets) == 2 + assert cfg.targets[0].namespace == "personal" + assert cfg.targets[0].platform == "orb" + assert cfg.targets[0].ssh_host == "personal@orb" + assert cfg.targets[1].ssh_identity == "~/.ssh/id_work" + + +def test_load_manifest_missing_file(tmp_path): + result = load_manifest(tmp_path / "nonexistent.yaml") + assert result == {} + + +def test_load_manifest_valid(tmp_path): + manifest = tmp_path / "manifest.yaml" + manifest.write_text(""" +profiles: + linux-vm: + os: linux + hostname: test +""") + result = load_manifest(manifest) + assert "profiles" in result + assert result["profiles"]["linux-vm"]["os"] == "linux" + + +def test_load_manifest_non_dict(tmp_path): + manifest = tmp_path / "manifest.yaml" + manifest.write_text("- a\n- b\n") + result = load_manifest(manifest) + assert result == {} diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..fef2eb4 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,95 @@ +"""Tests for flow.core.console.""" + +from flow.core.console import ConsoleLogger + + +def test_console_info(capsys): + c = ConsoleLogger() + c.info("hello") + out = capsys.readouterr().out + assert "[INFO]" in out + assert "hello" in out + + +def test_console_warn(capsys): + c = ConsoleLogger() + c.warn("caution") + out = capsys.readouterr().out + assert "[WARN]" in out + assert "caution" in out + + +def test_console_error(capsys): + c = ConsoleLogger() + c.error("bad thing") + out = capsys.readouterr().out + assert "[ERROR]" in out + assert "bad thing" in out + + +def test_console_success(capsys): + c = ConsoleLogger() + c.success("done") + out = capsys.readouterr().out + assert "[SUCCESS]" in out + assert "done" in out + + +def test_console_step_lifecycle(capsys): + c = ConsoleLogger() + c.step_start(1, 3, "Test step") + c.step_command("echo hi") + c.step_output("hi") + c.step_complete("Done") + out = capsys.readouterr().out + assert "Step 1/3" in out + assert "$ echo hi" in out + assert "Done" in out + + +def test_console_step_skip(capsys): + c = ConsoleLogger() + c.start_time = 0 + c.step_skip("not needed") + out = capsys.readouterr().out + assert "Skipped" in out + + +def test_console_step_fail(capsys): + c = ConsoleLogger() + c.start_time = 0 + c.step_fail("exploded") + out = capsys.readouterr().out + assert "Failed" in out + + +def test_console_table(capsys): + c = ConsoleLogger() + c.table(["NAME", "VALUE"], [["foo", "bar"], ["baz", "qux"]]) + out = capsys.readouterr().out + assert "NAME" in out + assert "foo" in out + assert "baz" in out + + +def test_console_table_empty(capsys): + c = ConsoleLogger() + c.table(["NAME"], []) + out = capsys.readouterr().out + assert out == "" + + +def test_console_section_header(capsys): + c = ConsoleLogger() + c.section_header("Test", "sub") + out = capsys.readouterr().out + assert "TEST" in out + assert "sub" in out + + +def test_console_plan_header(capsys): + c = ConsoleLogger() + c.plan_header("My Plan", 5) + out = capsys.readouterr().out + assert "MY PLAN" in out + assert "5 actions" in out diff --git a/tests/test_dotfiles.py b/tests/test_dotfiles.py new file mode 100644 index 0000000..19981af --- /dev/null +++ b/tests/test_dotfiles.py @@ -0,0 +1,67 @@ +"""Tests for flow.commands.dotfiles — link/unlink/status logic.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +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.platform import PlatformInfo + + +@pytest.fixture +def dotfiles_tree(tmp_path): + """Create a sample dotfiles directory structure.""" + common = tmp_path / "common" + (common / "zsh").mkdir(parents=True) + (common / "zsh" / ".zshrc").write_text("# zshrc") + (common / "zsh" / ".zshenv").write_text("# zshenv") + (common / "tmux").mkdir(parents=True) + (common / "tmux" / ".tmux.conf").write_text("# tmux") + + profiles = tmp_path / "profiles" / "work" + (profiles / "git").mkdir(parents=True) + (profiles / "git" / ".gitconfig").write_text("[user]\nname = Work") + + return tmp_path + + +def test_discover_packages_common(dotfiles_tree): + packages = _discover_packages(dotfiles_tree) + assert "zsh" in packages + assert "tmux" in packages + assert "git" not in packages # git is only in profiles + + +def test_discover_packages_with_profile(dotfiles_tree): + packages = _discover_packages(dotfiles_tree, profile="work") + assert "zsh" in packages + assert "tmux" in packages + assert "git" in packages + + +def test_discover_packages_profile_overrides(dotfiles_tree): + # Add zsh to work profile + work_zsh = dotfiles_tree / "profiles" / "work" / "zsh" + work_zsh.mkdir(parents=True) + (work_zsh / ".zshrc").write_text("# work zshrc") + + packages = _discover_packages(dotfiles_tree, profile="work") + # Profile should override common + assert packages["zsh"] == work_zsh + + +def test_walk_package(dotfiles_tree): + home = Path("/tmp/fakehome") + source = dotfiles_tree / "common" / "zsh" + pairs = list(_walk_package(source, home)) + assert len(pairs) == 2 + sources = {str(s.name) for s, _ in pairs} + assert ".zshrc" in sources + assert ".zshenv" in sources + targets = {str(t) for _, t in pairs} + assert str(home / ".zshrc") in targets + assert str(home / ".zshenv") in targets diff --git a/tests/test_dotfiles_folding.py b/tests/test_dotfiles_folding.py new file mode 100644 index 0000000..c7b677f --- /dev/null +++ b/tests/test_dotfiles_folding.py @@ -0,0 +1,300 @@ +"""Integration tests for dotfiles tree folding behavior.""" + +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.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 + + +@pytest.fixture +def ctx(): + """Create a mock FlowContext.""" + return FlowContext( + config=AppConfig(), + manifest={}, + platform=PlatformInfo(), + console=ConsoleLogger(), + ) + + +@pytest.fixture +def dotfiles_with_nested(tmp_path): + """Create dotfiles with nested directory structure for folding tests.""" + common = tmp_path / "common" + + # nvim package with nested config + nvim = common / "nvim" / ".config" / "nvim" + nvim.mkdir(parents=True) + (nvim / "init.lua").write_text("-- init") + (nvim / "lua").mkdir() + (nvim / "lua" / "config.lua").write_text("-- config") + (nvim / "lua" / "plugins.lua").write_text("-- plugins") + + # zsh package with flat structure + zsh = common / "zsh" + zsh.mkdir(parents=True) + (zsh / ".zshrc").write_text("# zshrc") + (zsh / ".zshenv").write_text("# zshenv") + + return tmp_path + + +@pytest.fixture +def home_dir(tmp_path): + """Create a temporary home directory.""" + home = tmp_path / "home" + home.mkdir() + return home + + +def test_tree_folding_single_package(dotfiles_with_nested, home_dir): + """Test that a single package can be folded into directory symlink.""" + # Discover nvim package + packages = _discover_packages(dotfiles_with_nested) + nvim_source = packages["nvim"] + + # Build link tree + tree = LinkTree() + folder = TreeFolder(tree) + + # Plan links for all nvim files + operations = [] + for src, dst in _walk_package(nvim_source, home_dir): + ops = folder.plan_link(src, dst, "nvim") + operations.extend(ops) + + # Execute operations + folder.execute_operations(operations, dry_run=False) + + # Check that we created efficient symlinks + # In ideal case, we'd have one directory symlink instead of 3 file symlinks + nvim_config = home_dir / ".config" / "nvim" + + # Verify links work + assert (nvim_config / "init.lua").exists() + assert (nvim_config / "lua" / "config.lua").exists() + + +def test_tree_unfolding_conflict(dotfiles_with_nested, home_dir): + """Test that tree unfolds when second package needs same directory.""" + common = dotfiles_with_nested / "common" + + # Create second package that shares .config + tmux = common / "tmux" / ".config" / "tmux" + tmux.mkdir(parents=True) + (tmux / "tmux.conf").write_text("# tmux") + + # First, link nvim (can fold .config/nvim) + tree = LinkTree() + folder = TreeFolder(tree) + + nvim_source = common / "nvim" + for src, dst in _walk_package(nvim_source, home_dir): + ops = folder.plan_link(src, dst, "nvim") + folder.execute_operations(ops, dry_run=False) + + # Now link tmux (should unfold if needed) + tmux_source = common / "tmux" + for src, dst in _walk_package(tmux_source, home_dir): + ops = folder.plan_link(src, dst, "tmux") + folder.execute_operations(ops, dry_run=False) + + # Both packages should be linked + assert (home_dir / ".config" / "nvim" / "init.lua").exists() + assert (home_dir / ".config" / "tmux" / "tmux.conf").exists() + + +def test_state_format_with_directory_links(dotfiles_with_nested, home_dir): + """Test that state file correctly tracks directory vs file links.""" + tree = LinkTree() + + # Add a directory link + tree.add_link( + home_dir / ".config" / "nvim", + dotfiles_with_nested / "common" / "nvim" / ".config" / "nvim", + "nvim", + is_dir_link=True, + ) + + # Add a file link + tree.add_link( + home_dir / ".zshrc", + dotfiles_with_nested / "common" / "zsh" / ".zshrc", + "zsh", + is_dir_link=False, + ) + + # Convert to state + state = tree.to_state() + + # Verify format + assert state["version"] == 2 + nvim_link = state["links"]["nvim"][str(home_dir / ".config" / "nvim")] + assert nvim_link["is_directory_link"] is True + + zsh_link = state["links"]["zsh"][str(home_dir / ".zshrc")] + assert zsh_link["is_directory_link"] is False + + +def test_state_backward_compatibility_rejected(home_dir): + """Old state format should be rejected (no backward compatibility).""" + old_state = { + "links": { + "zsh": { + str(home_dir / ".zshrc"): str(home_dir.parent / "dotfiles" / "zsh" / ".zshrc"), + } + } + } + + with pytest.raises(RuntimeError, match="Unsupported linked state format"): + LinkTree.from_state(old_state) + + +def test_discover_packages_with_flow_package(tmp_path): + """Test discovering the flow package itself from dotfiles.""" + common = tmp_path / "common" + + # Create flow package + flow_pkg = common / "flow" / ".config" / "flow" + flow_pkg.mkdir(parents=True) + (flow_pkg / "manifest.yaml").write_text("profiles: {}") + (flow_pkg / "config").write_text("[repository]\n") + + packages = _discover_packages(tmp_path) + + # Flow package should be discovered like any other + assert "flow" in packages + assert packages["flow"] == common / "flow" + + +def test_walk_flow_package(tmp_path): + """Test walking the flow package structure.""" + flow_pkg = tmp_path / "flow" + flow_config = flow_pkg / ".config" / "flow" + flow_config.mkdir(parents=True) + (flow_config / "manifest.yaml").write_text("profiles: {}") + (flow_config / "config").write_text("[repository]\n") + + home = Path("/tmp/fakehome") + pairs = list(_walk_package(flow_pkg, home)) + + # Should find both files + assert len(pairs) == 2 + targets = [str(t) for _, t in pairs] + assert str(home / ".config" / "flow" / "manifest.yaml") in targets + assert str(home / ".config" / "flow" / "config") in targets + + +def test_conflict_detection_before_execution(dotfiles_with_nested, home_dir): + """Test that conflicts are detected before any changes are made.""" + # Create existing file that conflicts + existing = home_dir / ".zshrc" + existing.parent.mkdir(parents=True, exist_ok=True) + existing.write_text("# existing zshrc") + + # Try to link package that wants .zshrc + tree = LinkTree() + folder = TreeFolder(tree) + + zsh_source = dotfiles_with_nested / "common" / "zsh" + operations = [] + for src, dst in _walk_package(zsh_source, home_dir): + ops = folder.plan_link(src, dst, "zsh") + operations.extend(ops) + + # Should detect conflict + conflicts = folder.detect_conflicts(operations) + assert len(conflicts) > 0 + assert any("already exists" in c for c in conflicts) + + # Original file should be unchanged + assert existing.read_text() == "# existing zshrc" + + +def test_profile_switching_relink(tmp_path): + """Test switching between profiles maintains correct links.""" + # Create profiles + common = tmp_path / "common" + profiles = tmp_path / "profiles" + + # Common zsh + (common / "zsh").mkdir(parents=True) + (common / "zsh" / ".zshrc").write_text("# common zsh") + + # Work profile override + (profiles / "work" / "zsh").mkdir(parents=True) + (profiles / "work" / "zsh" / ".zshrc").write_text("# work zsh") + + # Personal profile override + (profiles / "personal" / "zsh").mkdir(parents=True) + (profiles / "personal" / "zsh" / ".zshrc").write_text("# personal zsh") + + # Test that profile discovery works correctly + work_packages = _discover_packages(tmp_path, profile="work") + personal_packages = _discover_packages(tmp_path, profile="personal") + + # Both should find zsh, but from different sources + assert "zsh" in work_packages + assert "zsh" in personal_packages + assert work_packages["zsh"] != personal_packages["zsh"] + + +def test_can_fold_empty_directory(): + """Test can_fold with empty directory.""" + tree = LinkTree() + target_dir = Path("/home/user/.config/nvim") + + # Empty directory - should be able to fold + assert tree.can_fold(target_dir, "nvim") + + +def test_can_fold_with_subdirectories(): + """Test can_fold with nested directory structure.""" + tree = LinkTree() + base = Path("/home/user/.config/nvim") + + # Add nested files from same package + tree.add_link(base / "init.lua", Path("/dotfiles/nvim/init.lua"), "nvim") + tree.add_link(base / "lua" / "config.lua", Path("/dotfiles/nvim/lua/config.lua"), "nvim") + tree.add_link(base / "lua" / "plugins" / "init.lua", Path("/dotfiles/nvim/lua/plugins/init.lua"), "nvim") + + # Should be able to fold at base level + assert tree.can_fold(base, "nvim") + + # Add file from different package + tree.add_link(base / "other.lua", Path("/dotfiles/other/other.lua"), "other") + + # Now cannot fold + assert not tree.can_fold(base, "nvim") + + +def test_execute_operations_creates_parent_dirs(tmp_path): + """Test that execute_operations creates necessary parent directories.""" + tree = LinkTree() + folder = TreeFolder(tree) + + source = tmp_path / "dotfiles" / "nvim" / ".config" / "nvim" / "init.lua" + target = tmp_path / "home" / ".config" / "nvim" / "init.lua" + + # Create source + source.parent.mkdir(parents=True) + source.write_text("-- init") + + # Target parent doesn't exist yet + assert not target.parent.exists() + + # Plan and execute + ops = folder.plan_link(source, target, "nvim") + folder.execute_operations(ops, dry_run=False) + + # Parent should be created + assert target.parent.exists() + assert target.is_symlink() diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..1d02d60 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,70 @@ +"""Tests for flow.core.paths.""" + +from pathlib import Path + +from flow.core.paths import ( + CONFIG_DIR, + CONFIG_FILE, + DATA_DIR, + DOTFILES_DIR, + INSTALLED_STATE, + LINKED_STATE, + MANIFEST_FILE, + PACKAGES_DIR, + SCRATCH_DIR, + STATE_DIR, + ensure_dirs, +) + + +def test_config_dir_under_home(): + assert ".config/devflow" in str(CONFIG_DIR) + + +def test_data_dir_under_home(): + assert ".local/share/devflow" in str(DATA_DIR) + + +def test_state_dir_under_home(): + assert ".local/state/devflow" in str(STATE_DIR) + + +def test_manifest_file_in_config_dir(): + assert MANIFEST_FILE == CONFIG_DIR / "manifest.yaml" + + +def test_config_file_in_config_dir(): + assert CONFIG_FILE == CONFIG_DIR / "config" + + +def test_dotfiles_dir(): + assert DOTFILES_DIR == DATA_DIR / "dotfiles" + + +def test_packages_dir(): + assert PACKAGES_DIR == DATA_DIR / "packages" + + +def test_scratch_dir(): + assert SCRATCH_DIR == DATA_DIR / "scratch" + + +def test_state_files(): + assert LINKED_STATE == STATE_DIR / "linked.json" + assert INSTALLED_STATE == STATE_DIR / "installed.json" + + +def test_ensure_dirs(tmp_path, monkeypatch): + monkeypatch.setattr("flow.core.paths.CONFIG_DIR", tmp_path / "config") + monkeypatch.setattr("flow.core.paths.DATA_DIR", tmp_path / "data") + monkeypatch.setattr("flow.core.paths.STATE_DIR", tmp_path / "state") + monkeypatch.setattr("flow.core.paths.PACKAGES_DIR", tmp_path / "data" / "packages") + monkeypatch.setattr("flow.core.paths.SCRATCH_DIR", tmp_path / "data" / "scratch") + + ensure_dirs() + + assert (tmp_path / "config").is_dir() + assert (tmp_path / "data").is_dir() + assert (tmp_path / "state").is_dir() + assert (tmp_path / "data" / "packages").is_dir() + assert (tmp_path / "data" / "scratch").is_dir() diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..edb9611 --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,32 @@ +"""Tests for flow.core.platform.""" + +import platform as _platform + +import pytest + +from flow.core.platform import PlatformInfo, detect_container_runtime, detect_platform + + +def test_detect_platform_returns_platforminfo(): + info = detect_platform() + assert isinstance(info, PlatformInfo) + assert info.os in ("linux", "macos") + assert info.arch in ("amd64", "arm64") + assert info.platform == f"{info.os}-{info.arch}" + + +def test_detect_platform_unsupported_os(monkeypatch): + monkeypatch.setattr(_platform, "system", lambda: "FreeBSD") + with pytest.raises(RuntimeError, match="Unsupported operating system"): + detect_platform() + + +def test_detect_platform_unsupported_arch(monkeypatch): + monkeypatch.setattr(_platform, "machine", lambda: "mips") + with pytest.raises(RuntimeError, match="Unsupported architecture"): + detect_platform() + + +def test_detect_container_runtime_returns_string_or_none(): + result = detect_container_runtime() + assert result is None or result in ("docker", "podman") diff --git a/tests/test_self_hosting.py b/tests/test_self_hosting.py new file mode 100644 index 0000000..978ae00 --- /dev/null +++ b/tests/test_self_hosting.py @@ -0,0 +1,215 @@ +"""Tests for self-hosting flow config from dotfiles repository.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + +from flow.core import paths as paths_module +from flow.core.config import load_config, load_manifest + + +@pytest.fixture +def mock_paths(tmp_path, monkeypatch): + """Mock path constants for testing.""" + config_dir = tmp_path / "config" + dotfiles_dir = tmp_path / "dotfiles" + + config_dir.mkdir() + dotfiles_dir.mkdir() + + test_paths = { + "config_dir": config_dir, + "dotfiles_dir": dotfiles_dir, + "local_config": config_dir / "config", + "local_manifest": config_dir / "manifest.yaml", + "dotfiles_config": dotfiles_dir / "flow" / ".config" / "flow" / "config", + "dotfiles_manifest": dotfiles_dir / "flow" / ".config" / "flow" / "manifest.yaml", + } + + # Patch at the paths module level + monkeypatch.setattr(paths_module, "CONFIG_FILE", test_paths["local_config"]) + monkeypatch.setattr(paths_module, "MANIFEST_FILE", test_paths["local_manifest"]) + monkeypatch.setattr(paths_module, "DOTFILES_CONFIG", test_paths["dotfiles_config"]) + monkeypatch.setattr(paths_module, "DOTFILES_MANIFEST", test_paths["dotfiles_manifest"]) + + return test_paths + + +def test_load_manifest_priority_dotfiles_first(mock_paths): + """Test that dotfiles manifest takes priority over local.""" + # Create both manifests + local_manifest = mock_paths["local_manifest"] + dotfiles_manifest = mock_paths["dotfiles_manifest"] + + local_manifest.write_text("profiles:\n local:\n os: linux") + + dotfiles_manifest.parent.mkdir(parents=True) + dotfiles_manifest.write_text("profiles:\n dotfiles:\n os: macos") + + # Should load from dotfiles + manifest = load_manifest() + assert "dotfiles" in manifest.get("profiles", {}) + assert "local" not in manifest.get("profiles", {}) + + +def test_load_manifest_fallback_to_local(mock_paths): + """Test fallback to local manifest when dotfiles doesn't exist.""" + local_manifest = mock_paths["local_manifest"] + local_manifest.write_text("profiles:\n local:\n os: linux") + + # Dotfiles manifest doesn't exist + manifest = load_manifest() + assert "local" in manifest.get("profiles", {}) + + +def test_load_manifest_empty_when_none_exist(mock_paths): + """Test empty dict returned when no manifests exist.""" + manifest = load_manifest() + assert manifest == {} + + +def test_load_config_priority_dotfiles_first(mock_paths): + """Test that dotfiles config takes priority over local.""" + local_config = mock_paths["local_config"] + dotfiles_config = mock_paths["dotfiles_config"] + + # Create local config + local_config.write_text( + "[repository]\n" + "dotfiles_url = https://github.com/user/dotfiles-local.git\n" + ) + + # Create dotfiles config + dotfiles_config.parent.mkdir(parents=True) + dotfiles_config.write_text( + "[repository]\n" + "dotfiles_url = https://github.com/user/dotfiles-from-repo.git\n" + ) + + # Should load from dotfiles + config = load_config() + assert "dotfiles-from-repo" in config.dotfiles_url + + +def test_load_config_fallback_to_local(mock_paths): + """Test fallback to local config when dotfiles doesn't exist.""" + local_config = mock_paths["local_config"] + local_config.write_text( + "[repository]\n" + "dotfiles_url = https://github.com/user/dotfiles-local.git\n" + ) + + # Dotfiles config doesn't exist + config = load_config() + assert "dotfiles-local" in config.dotfiles_url + + +def test_load_config_empty_when_none_exist(mock_paths): + """Test default config returned when no configs exist.""" + config = load_config() + assert config.dotfiles_url == "" + assert config.dotfiles_branch == "main" + + +def test_self_hosting_workflow(tmp_path, monkeypatch): + """Test complete self-hosting workflow. + + Simulates: + 1. User has dotfiles repo with flow config + 2. Flow links its own config from dotfiles + 3. Flow reads from self-hosted location + """ + # Setup paths + home = tmp_path / "home" + dotfiles = tmp_path / "dotfiles" + home.mkdir() + dotfiles.mkdir() + + # Create flow package in dotfiles + flow_pkg = dotfiles / "flow" / ".config" / "flow" + flow_pkg.mkdir(parents=True) + + # Create manifest in dotfiles + manifest_content = { + "profiles": { + "test-env": { + "os": "linux", + "packages": {"standard": ["git", "vim"]}, + } + } + } + (flow_pkg / "manifest.yaml").write_text(yaml.dump(manifest_content)) + + # Create config in dotfiles + (flow_pkg / "config").write_text( + "[repository]\n" + "dotfiles_url = https://github.com/user/dotfiles.git\n" + ) + + # Mock paths to use our temp directories + monkeypatch.setattr(paths_module, "DOTFILES_MANIFEST", flow_pkg / "manifest.yaml") + monkeypatch.setattr(paths_module, "DOTFILES_CONFIG", flow_pkg / "config") + monkeypatch.setattr(paths_module, "MANIFEST_FILE", home / ".config" / "devflow" / "manifest.yaml") + monkeypatch.setattr(paths_module, "CONFIG_FILE", home / ".config" / "devflow" / "config") + + # Load config and manifest - should come from dotfiles + manifest = load_manifest() + config = load_config() + + assert "test-env" in manifest.get("profiles", {}) + assert "github.com/user/dotfiles.git" in config.dotfiles_url + + +def test_manifest_cascade_with_symlink(tmp_path, monkeypatch): + """Test that loading works correctly when symlink is used.""" + # Setup + dotfiles = tmp_path / "dotfiles" + home_config = tmp_path / "home" / ".config" / "flow" + flow_pkg = dotfiles / "flow" / ".config" / "flow" + + flow_pkg.mkdir(parents=True) + home_config.mkdir(parents=True) + + # Create manifest in dotfiles + manifest_content = {"profiles": {"from-dotfiles": {"os": "linux"}}} + (flow_pkg / "manifest.yaml").write_text(yaml.dump(manifest_content)) + + # Create symlink from home config to dotfiles + manifest_link = home_config / "manifest.yaml" + manifest_link.symlink_to(flow_pkg / "manifest.yaml") + + # Mock paths + monkeypatch.setattr(paths_module, "DOTFILES_MANIFEST", flow_pkg / "manifest.yaml") + monkeypatch.setattr(paths_module, "MANIFEST_FILE", manifest_link) + + # Load - should work through symlink + manifest = load_manifest() + assert "from-dotfiles" in manifest.get("profiles", {}) + + +def test_config_priority_documentation(mock_paths): + """Document the config loading priority for users.""" + # This test serves as documentation of the cascade behavior + + # Priority 1: Dotfiles repo (self-hosted) + dotfiles_manifest = mock_paths["dotfiles_manifest"] + dotfiles_manifest.parent.mkdir(parents=True) + dotfiles_manifest.write_text("profiles:\n priority-1: {}") + + manifest = load_manifest() + assert "priority-1" in manifest.get("profiles", {}) + + # If we remove dotfiles, falls back to Priority 2: Local override + dotfiles_manifest.unlink() + local_manifest = mock_paths["local_manifest"] + local_manifest.write_text("profiles:\n priority-2: {}") + + manifest = load_manifest() + assert "priority-2" in manifest.get("profiles", {}) + + # If neither exists, Priority 3: Empty fallback + local_manifest.unlink() + manifest = load_manifest() + assert manifest == {} diff --git a/tests/test_stow.py b/tests/test_stow.py new file mode 100644 index 0000000..98e1d2c --- /dev/null +++ b/tests/test_stow.py @@ -0,0 +1,310 @@ +"""Tests for flow.core.stow — GNU Stow-style tree folding/unfolding.""" + +import os +from pathlib import Path + +import pytest + +from flow.core.stow import LinkOperation, LinkTree, TreeFolder + + +@pytest.fixture +def temp_home(tmp_path): + """Create a temporary home directory.""" + home = tmp_path / "home" + home.mkdir() + return home + + +@pytest.fixture +def temp_dotfiles(tmp_path): + """Create a temporary dotfiles repository.""" + dotfiles = tmp_path / "dotfiles" + dotfiles.mkdir() + return dotfiles + + +def test_linktree_add_remove(): + """Test basic LinkTree operations.""" + tree = LinkTree() + source = Path("/dotfiles/zsh/.zshrc") + target = Path("/home/user/.zshrc") + + tree.add_link(target, source, "zsh", is_dir_link=False) + assert target in tree.links + assert tree.links[target] == source + assert tree.packages[target] == "zsh" + assert not tree.is_directory_link(target) + + tree.remove_link(target) + assert target not in tree.links + assert target not in tree.packages + + +def test_linktree_directory_link(): + """Test directory link tracking.""" + tree = LinkTree() + source = Path("/dotfiles/nvim/.config/nvim") + target = Path("/home/user/.config/nvim") + + tree.add_link(target, source, "nvim", is_dir_link=True) + assert tree.is_directory_link(target) + + +def test_linktree_can_fold_single_package(): + """Test can_fold with single package.""" + tree = LinkTree() + target_dir = Path("/home/user/.config/nvim") + + # Add files from same package + tree.add_link(target_dir / "init.lua", Path("/dotfiles/nvim/.config/nvim/init.lua"), "nvim") + tree.add_link(target_dir / "lua" / "config.lua", Path("/dotfiles/nvim/.config/nvim/lua/config.lua"), "nvim") + + # Should be able to fold since all files are from same package + assert tree.can_fold(target_dir, "nvim") + + +def test_linktree_can_fold_multiple_packages(): + """Test can_fold with multiple packages.""" + tree = LinkTree() + target_dir = Path("/home/user/.config") + + # Add files from different packages + tree.add_link(target_dir / "nvim", Path("/dotfiles/nvim/.config/nvim"), "nvim", is_dir_link=True) + tree.add_link(target_dir / "tmux", Path("/dotfiles/tmux/.config/tmux"), "tmux", is_dir_link=True) + + # Cannot fold .config since it has files from multiple packages + assert not tree.can_fold(target_dir, "nvim") + + +def test_linktree_from_state_old_format_rejected(): + """Old state format should be rejected (no backward compatibility).""" + state = { + "links": { + "zsh": { + "/home/user/.zshrc": "/dotfiles/zsh/.zshrc", + "/home/user/.zshenv": "/dotfiles/zsh/.zshenv", + } + } + } + + with pytest.raises(RuntimeError, match="Unsupported linked state format"): + LinkTree.from_state(state) + + +def test_linktree_from_state_new_format(): + """Test loading from new state format (with is_directory_link).""" + state = { + "version": 2, + "links": { + "nvim": { + "/home/user/.config/nvim": { + "source": "/dotfiles/nvim/.config/nvim", + "is_directory_link": True, + } + } + } + } + + tree = LinkTree.from_state(state) + target = Path("/home/user/.config/nvim") + assert target in tree.links + assert tree.is_directory_link(target) + assert tree.packages[target] == "nvim" + + +def test_linktree_to_state(): + """Test converting LinkTree to state format.""" + tree = LinkTree() + tree.add_link( + Path("/home/user/.config/nvim"), + Path("/dotfiles/nvim/.config/nvim"), + "nvim", + is_dir_link=True, + ) + tree.add_link( + Path("/home/user/.zshrc"), + Path("/dotfiles/zsh/.zshrc"), + "zsh", + is_dir_link=False, + ) + + state = tree.to_state() + assert state["version"] == 2 + assert "nvim" in state["links"] + assert "zsh" in state["links"] + + nvim_link = state["links"]["nvim"]["/home/user/.config/nvim"] + assert nvim_link["is_directory_link"] is True + + zsh_link = state["links"]["zsh"]["/home/user/.zshrc"] + assert zsh_link["is_directory_link"] is False + + +def test_treefolder_plan_link_simple(temp_home, temp_dotfiles): + """Test planning a simple file link.""" + tree = LinkTree() + folder = TreeFolder(tree) + + source = temp_dotfiles / "zsh" / ".zshrc" + target = temp_home / ".zshrc" + + # Create source file + source.parent.mkdir(parents=True) + source.write_text("# zshrc") + + ops = folder.plan_link(source, target, "zsh") + assert len(ops) == 1 + assert ops[0].type == "create_symlink" + assert ops[0].source == source + assert ops[0].target == target + assert ops[0].package == "zsh" + + +def test_treefolder_detect_conflicts_existing_file(temp_home, temp_dotfiles): + """Test conflict detection for existing files.""" + tree = LinkTree() + folder = TreeFolder(tree) + + source = temp_dotfiles / "zsh" / ".zshrc" + target = temp_home / ".zshrc" + + # Create existing file + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("# existing") + + source.parent.mkdir(parents=True) + source.write_text("# zshrc") + + ops = folder.plan_link(source, target, "zsh") + conflicts = folder.detect_conflicts(ops) + + assert len(conflicts) == 1 + assert "already exists" in conflicts[0] + + +def test_treefolder_detect_conflicts_different_package(temp_home, temp_dotfiles): + """Test conflict detection for links from different packages.""" + tree = LinkTree() + target = temp_home / ".bashrc" + + # Add existing link from different package + tree.add_link(target, Path("/dotfiles/bash/.bashrc"), "bash") + + folder = TreeFolder(tree) + source = temp_dotfiles / "zsh" / ".bashrc" + source.parent.mkdir(parents=True) + source.write_text("# bashrc") + + ops = folder.plan_link(source, target, "zsh") + conflicts = folder.detect_conflicts(ops) + + assert len(conflicts) == 1 + assert "bash" in conflicts[0] + + +def test_treefolder_execute_operations_dry_run(temp_home, temp_dotfiles, capsys): + """Test dry-run mode.""" + tree = LinkTree() + folder = TreeFolder(tree) + + source = temp_dotfiles / "zsh" / ".zshrc" + target = temp_home / ".zshrc" + + source.parent.mkdir(parents=True) + source.write_text("# zshrc") + + ops = folder.plan_link(source, target, "zsh") + folder.execute_operations(ops, dry_run=True) + + # Check output + captured = capsys.readouterr() + assert "FILE LINK" in captured.out + assert str(target) in captured.out + + # No actual symlink created + assert not target.exists() + + +def test_treefolder_execute_operations_create_symlink(temp_home, temp_dotfiles): + """Test creating actual symlinks.""" + tree = LinkTree() + folder = TreeFolder(tree) + + source = temp_dotfiles / "zsh" / ".zshrc" + target = temp_home / ".zshrc" + + source.parent.mkdir(parents=True) + source.write_text("# zshrc") + + ops = folder.plan_link(source, target, "zsh") + folder.execute_operations(ops, dry_run=False) + + # Check symlink created + assert target.is_symlink() + assert target.resolve() == source.resolve() + + # Check tree updated + assert target in folder.tree.links + + +def test_treefolder_plan_unlink(temp_home, temp_dotfiles): + """Test planning unlink operations.""" + tree = LinkTree() + target = temp_home / ".zshrc" + source = temp_dotfiles / "zsh" / ".zshrc" + + tree.add_link(target, source, "zsh") + + folder = TreeFolder(tree) + ops = folder.plan_unlink(target, "zsh") + + assert len(ops) == 1 + assert ops[0].type == "remove" + assert ops[0].target == target + + +def test_treefolder_plan_unlink_directory_link(temp_home, temp_dotfiles): + """Test planning unlink for directory symlink.""" + tree = LinkTree() + target = temp_home / ".config" / "nvim" + source = temp_dotfiles / "nvim" / ".config" / "nvim" + + tree.add_link(target, source, "nvim", is_dir_link=True) + + folder = TreeFolder(tree) + ops = folder.plan_unlink(target, "nvim") + + # Should remove the directory link + assert len(ops) >= 1 + assert ops[-1].type == "remove" + assert ops[-1].is_directory_link + + +def test_linkoperation_str(): + """Test LinkOperation string representation.""" + op1 = LinkOperation( + type="create_symlink", + source=Path("/src"), + target=Path("/dst"), + package="test", + is_directory_link=False, + ) + assert "FILE LINK" in str(op1) + + op2 = LinkOperation( + type="create_symlink", + source=Path("/src"), + target=Path("/dst"), + package="test", + is_directory_link=True, + ) + assert "DIR LINK" in str(op2) + + op3 = LinkOperation( + type="unfold", + source=Path("/src"), + target=Path("/dst"), + package="test", + ) + assert "UNFOLD" in str(op3) diff --git a/tests/test_variables.py b/tests/test_variables.py new file mode 100644 index 0000000..682da23 --- /dev/null +++ b/tests/test_variables.py @@ -0,0 +1,52 @@ +"""Tests for flow.core.variables.""" + +from flow.core.variables import substitute, substitute_template + + +def test_substitute_dollar(): + result = substitute("hello $NAME", {"NAME": "world"}) + assert result == "hello world" + + +def test_substitute_braces(): + result = substitute("hello ${NAME}", {"NAME": "world"}) + assert result == "hello world" + + +def test_substitute_multiple(): + result = substitute("$A and ${B}", {"A": "1", "B": "2"}) + assert result == "1 and 2" + + +def test_substitute_home(): + result = substitute("dir=$HOME", {}) + assert "$HOME" not in result + + +def test_substitute_user(): + import os + result = substitute("u=$USER", {}) + assert result == f"u={os.getenv('USER', '')}" + + +def test_substitute_non_string(): + assert substitute(123, {}) == 123 + + +def test_substitute_template_basic(): + result = substitute_template("nvim-{{os}}-{{arch}}.tar.gz", {"os": "linux", "arch": "x86_64"}) + assert result == "nvim-linux-x86_64.tar.gz" + + +def test_substitute_template_missing_key(): + result = substitute_template("{{missing}}", {}) + assert result == "{{missing}}" + + +def test_substitute_template_non_string(): + assert substitute_template(42, {}) == 42 + + +def test_substitute_template_no_placeholders(): + result = substitute_template("plain text", {"os": "linux"}) + assert result == "plain text"