From 46372b3f2d0e4aeeffc2b0e34fa6baedc4670a6f Mon Sep 17 00:00:00 2001 From: Yunsung Lee Date: Fri, 20 Mar 2026 00:23:45 +0900 Subject: [PATCH] feat: add --yolo flag for full one-command install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a --yolo global flag that performs a maximal install in one shot: rich profile (auto-detected by OS), all packages, all tools, auto-yes. The flag sets four composable env vars (DOTFILES_PREFER_RICH, DOTFILES_ALL_PACKAGES, DOTFILES_ALL_TOOLS, DOTFILES_YES) consumed by the existing run_install() path — no parallel orchestration function. Python resolve_profile() gains a 7-line rich upgrade that respects SSH detection (ssh-server is never upgraded) and explicit --profile overrides. Explicit --skip-tools/--skip-apply flags win over yolo. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/dotfiles | 35 ++++++- scripts/dotfiles.py | 9 ++ tests/test_dotfiles_bin.py | 62 ++++++++++++ tests/test_resolve_profile.py | 176 ++++++++++++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 tests/test_resolve_profile.py diff --git a/bin/dotfiles b/bin/dotfiles index 22e1cfa..495e923 100755 --- a/bin/dotfiles +++ b/bin/dotfiles @@ -60,6 +60,7 @@ usage() { printf ' --non-interactive Fail instead of prompting in delegated commands\n' printf ' --skip-apply Skip the apply phase for install/update\n' printf ' --skip-tools Skip default agent-tool installation for install/update\n' + printf ' --yolo Full install: rich profile + all packages + all tools + auto-yes\n' printf ' --help, -h Show this help\n' printf '\n' printf '%bCommands:%b\n' "$CLR_CYAN" "$CLR_RESET" @@ -101,6 +102,9 @@ DOTFILES_NONINTERACTIVE=${DOTFILES_NONINTERACTIVE:-0} DOTFILES_SKIP_APPLY=${DOTFILES_SKIP_APPLY:-0} DOTFILES_SKIP_TOOLS=${DOTFILES_SKIP_TOOLS:-0} DOTFILES_CHECKOUT_ALREADY_UPDATED=${DOTFILES_CHECKOUT_ALREADY_UPDATED:-0} +DOTFILES_PREFER_RICH=${DOTFILES_PREFER_RICH:-0} +DOTFILES_ALL_PACKAGES=${DOTFILES_ALL_PACKAGES:-0} +DOTFILES_ALL_TOOLS=${DOTFILES_ALL_TOOLS:-0} while [ "$#" -gt 0 ]; do case "$1" in @@ -129,6 +133,13 @@ while [ "$#" -gt 0 ]; do DOTFILES_SKIP_TOOLS=1 shift ;; + --yolo) + DOTFILES_YES=1 + DOTFILES_PREFER_RICH=1 + DOTFILES_ALL_PACKAGES=1 + DOTFILES_ALL_TOOLS=1 + shift + ;; --help|-h) usage exit 0 @@ -151,7 +162,7 @@ if [ "$#" -gt 0 ]; then shift fi -export DOTFILES_DRY_RUN DOTFILES_YES DOTFILES_NONINTERACTIVE DOTFILES_SKIP_APPLY DOTFILES_SKIP_TOOLS DOTFILES_CHECKOUT_ALREADY_UPDATED DOTFILES_REPO_ROOT=$REPO_ROOT +export DOTFILES_DRY_RUN DOTFILES_YES DOTFILES_NONINTERACTIVE DOTFILES_SKIP_APPLY DOTFILES_SKIP_TOOLS DOTFILES_CHECKOUT_ALREADY_UPDATED DOTFILES_PREFER_RICH DOTFILES_ALL_PACKAGES DOTFILES_ALL_TOOLS DOTFILES_REPO_ROOT=$REPO_ROOT load_packages_support() { package_lib="$REPO_ROOT/scripts/sh/packages.sh" @@ -225,8 +236,17 @@ run_install() { fi if _dotfiles_is_truthy "$DOTFILES_SKIP_TOOLS"; then - dotfiles_skip "default agent tools (--skip-tools)" + dotfiles_skip "agent tools (--skip-tools)" _install_skip=$(( _install_skip + 1 )) + elif _dotfiles_is_truthy "$DOTFILES_ALL_TOOLS"; then + dotfiles_step "Installing ALL agent tools (yolo)" + if load_tools_support && dotfiles_install_all_tools; then + dotfiles_ok "All agent tools installed" + _install_ok=$(( _install_ok + 1 )) + else + dotfiles_fail "Agent tools installation had failures" + _install_fail=$(( _install_fail + 1 )) + fi else dotfiles_step "Installing default tools" if load_tools_support && dotfiles_install_default_tools; then @@ -238,6 +258,17 @@ run_install() { fi fi + if _dotfiles_is_truthy "$DOTFILES_ALL_PACKAGES"; then + dotfiles_step "Installing ALL packages (yolo)" + if load_packages_support && dotfiles_packages_main --all; then + dotfiles_ok "All packages installed" + _install_ok=$(( _install_ok + 1 )) + else + dotfiles_fail "Package installation had failures" + _install_fail=$(( _install_fail + 1 )) + fi + fi + # Summary line. dotfiles_header "Install complete" if [ -n "$CLR_GREEN" ]; then diff --git a/scripts/dotfiles.py b/scripts/dotfiles.py index 21aad45..75ce1b3 100755 --- a/scripts/dotfiles.py +++ b/scripts/dotfiles.py @@ -135,6 +135,15 @@ def resolve_profile(repo_root: Path, manifest: dict[str, Any], profile_name: str else: effective_name = default_profiles.get("linux", "linux-desktop") + # --yolo / DOTFILES_PREFER_RICH: upgrade to rich variant if available. + # Skipped when profile was explicitly specified or resolved to ssh-server. + if os.environ.get("DOTFILES_PREFER_RICH", "0") in ("1", "true", "yes"): + ssh_profile = default_profiles.get("ssh", "ssh-server") + if effective_name != ssh_profile: + rich_candidate = f"{effective_name}-rich" + if (repo_root / "profiles" / f"{rich_candidate}.json").is_file(): + effective_name = rich_candidate + cache: dict[str, dict[str, Any]] = {} resolving: set[str] = set() diff --git a/tests/test_dotfiles_bin.py b/tests/test_dotfiles_bin.py index b6999c7..a4927d9 100644 --- a/tests/test_dotfiles_bin.py +++ b/tests/test_dotfiles_bin.py @@ -59,5 +59,67 @@ def test_relative_symlinked_launcher_resolves_repo_root(self) -> None: self.assertIn(f"Usage: {launcher} [global-options] [command-args]", completed.stdout) +class YoloFlagTests(unittest.TestCase): + def run_bin(self, *args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + base_env = {**os.environ, "NO_COLOR": "1"} + if env: + base_env.update(env) + return subprocess.run( + [str(DOTFILES_BIN), *args], + capture_output=True, + text=True, + env=base_env, + ) + + def test_yolo_in_help(self) -> None: + completed = self.run_bin("--help") + + self.assertEqual( + completed.returncode, + 0, + msg=f"--help failed\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}", + ) + self.assertIn("--yolo", completed.stdout) + + def test_yolo_dry_run_all_phases(self) -> None: + completed = self.run_bin("--yolo", "--dry-run", "install") + + self.assertEqual( + completed.returncode, + 0, + msg=f"--yolo --dry-run install failed\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}", + ) + self.assertIn("ALL agent tools", completed.stdout) + self.assertIn("ALL packages", completed.stdout) + + def test_yolo_skip_tools_wins(self) -> None: + completed = self.run_bin("--yolo", "--skip-tools", "--dry-run", "install") + + self.assertEqual( + completed.returncode, + 0, + msg=( + "--yolo --skip-tools --dry-run install failed\n" + f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ), + ) + self.assertIn("agent tools (--skip-tools)", completed.stdout) + self.assertNotIn("ALL agent tools", completed.stdout) + + def test_yolo_skip_apply(self) -> None: + completed = self.run_bin("--yolo", "--skip-apply", "--dry-run", "install") + + self.assertEqual( + completed.returncode, + 0, + msg=( + "--yolo --skip-apply --dry-run install failed\n" + f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ), + ) + self.assertIn("ALL agent tools", completed.stdout) + self.assertIn("ALL packages", completed.stdout) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_resolve_profile.py b/tests/test_resolve_profile.py new file mode 100644 index 0000000..8c935d7 --- /dev/null +++ b/tests/test_resolve_profile.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import importlib.util +import json +import shutil +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = REPO_ROOT / "scripts" / "dotfiles.py" + + +def load_script_module(): + spec = importlib.util.spec_from_file_location("alohays_dotfiles_script", SCRIPT_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load scripts/dotfiles.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _write_json(path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + + +class ResolveProfileRichUpgradeTests(unittest.TestCase): + """Unit tests for the DOTFILES_PREFER_RICH upgrade logic in resolve_profile().""" + + @classmethod + def setUpClass(cls) -> None: + cls.script = load_script_module() + + def setUp(self) -> None: + self._tmp = Path(tempfile.mkdtemp(prefix="dotfiles-resolve-profile-")) + self.addCleanup(lambda: shutil.rmtree(self._tmp, ignore_errors=True)) + self._build_repo(self._tmp) + + def _build_repo(self, root: Path) -> None: + """Write a minimal repo layout with the profiles needed by the tests.""" + modules = [ + {"name": "core", "source_root": "modules/core"}, + {"name": "tmux", "source_root": "modules/tmux"}, + {"name": "nvim", "source_root": "modules/nvim"}, + {"name": "visual", "source_root": "modules/visual"}, + {"name": "terminal", "source_root": "modules/terminal"}, + {"name": "prompt", "source_root": "modules/prompt"}, + {"name": "ssh-server", "source_root": "modules/ssh-server"}, + ] + manifest = { + "schema_version": 1, + "default_profiles": { + "darwin": "macos-desktop", + "linux": "linux-desktop", + "ssh": "ssh-server", + }, + "modules": modules, + } + _write_json(root / "manifests" / "manifest.json", manifest) + + profiles_dir = root / "profiles" + _write_json( + profiles_dir / "base.json", + {"name": "base", "extends": [], "modules": ["core"]}, + ) + _write_json( + profiles_dir / "macos-desktop.json", + { + "name": "macos-desktop", + "extends": ["base"], + "modules": ["tmux", "nvim", "visual"], + }, + ) + _write_json( + profiles_dir / "macos-desktop-rich.json", + { + "name": "macos-desktop-rich", + "extends": ["macos-desktop"], + "modules": ["terminal", "prompt"], + }, + ) + _write_json( + profiles_dir / "linux-desktop.json", + { + "name": "linux-desktop", + "extends": ["base"], + "modules": ["tmux", "nvim", "visual"], + }, + ) + _write_json( + profiles_dir / "linux-desktop-rich.json", + { + "name": "linux-desktop-rich", + "extends": ["linux-desktop"], + "modules": ["terminal", "prompt"], + }, + ) + _write_json( + profiles_dir / "ssh-server.json", + { + "name": "ssh-server", + "extends": ["base"], + "modules": ["ssh-server", "tmux"], + }, + ) + + def _resolve_clean( + self, + profile_name: str | None, + *, + prefer_rich: str = "0", + platform_system: str = "Darwin", + ssh_connection: str | None = None, + display: str | None = None, + ) -> dict: + """Resolve a profile with a fully controlled environment.""" + patch_env: dict[str, str] = {"DOTFILES_PREFER_RICH": prefer_rich} + remove_keys = {"SSH_CONNECTION", "SSH_TTY", "DISPLAY", "WAYLAND_DISPLAY"} + + if ssh_connection is not None: + patch_env["SSH_CONNECTION"] = ssh_connection + remove_keys.discard("SSH_CONNECTION") + if display is not None: + patch_env["DISPLAY"] = display + remove_keys.discard("DISPLAY") + + manifest = self.script.load_manifest(self._tmp) + + import os as _os + saved = {k: _os.environ.pop(k, None) for k in remove_keys} + try: + with mock.patch("platform.system", return_value=platform_system), \ + mock.patch.dict(_os.environ, patch_env, clear=False): + return self.script.resolve_profile(self._tmp, manifest, profile_name) + finally: + for k, v in saved.items(): + if v is not None: + _os.environ[k] = v + + def test_prefer_rich_darwin_resolves_macos_desktop_rich(self) -> None: + result = self._resolve_clean(None, prefer_rich="1", platform_system="Darwin") + self.assertEqual(result["name"], "macos-desktop-rich") + + def test_prefer_rich_linux_no_ssh_resolves_linux_desktop_rich(self) -> None: + result = self._resolve_clean(None, prefer_rich="1", platform_system="Linux") + self.assertEqual(result["name"], "linux-desktop-rich") + + def test_prefer_rich_ssh_headless_resolves_ssh_server_not_upgraded(self) -> None: + result = self._resolve_clean( + None, + prefer_rich="1", + platform_system="Linux", + ssh_connection="10.0.0.1 22 10.0.0.2 54321", + ) + self.assertEqual(result["name"], "ssh-server") + + def test_prefer_rich_explicit_profile_name_not_upgraded(self) -> None: + result = self._resolve_clean("macos-desktop", prefer_rich="1", platform_system="Darwin") + self.assertEqual(result["name"], "macos-desktop") + + def test_no_prefer_rich_does_not_upgrade(self) -> None: + result = self._resolve_clean(None, prefer_rich="0", platform_system="Darwin") + self.assertEqual(result["name"], "macos-desktop") + + def test_prefer_rich_missing_rich_profile_returns_base_profile(self) -> None: + (self._tmp / "profiles" / "macos-desktop-rich.json").unlink() + result = self._resolve_clean(None, prefer_rich="1", platform_system="Darwin") + self.assertEqual(result["name"], "macos-desktop") + + +if __name__ == "__main__": + unittest.main(verbosity=2)