dotsync-py 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ *.egg
8
+ dist/
9
+ build/
10
+ .eggs/
11
+
12
+ # Virtual environments
13
+ .venv/
14
+ venv/
15
+ env/
16
+
17
+ # IDE
18
+ .idea/
19
+ .vscode/
20
+ *.swp
21
+ *.swo
22
+ *~
23
+
24
+ # Testing
25
+ .pytest_cache/
26
+ .coverage
27
+ htmlcov/
28
+ .tox/
29
+
30
+ # OS
31
+ .DS_Store
32
+ Thumbs.db
33
+
34
+ # dotsync
35
+ .brew-pending
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Scott Sheffield
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: dotsync-py
3
+ Version: 0.1.0
4
+ Summary: Fleet-style dotfiles manager with push cascading and status dashboard
5
+ Project-URL: Homepage, https://github.com/scott-shef/dotsync
6
+ Project-URL: Repository, https://github.com/scott-shef/dotsync
7
+ Project-URL: Issues, https://github.com/scott-shef/dotsync/issues
8
+ Author-email: Scott Sheffield <scott@shef.us>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,dotfiles,fleet,sync
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: System :: Systems Administration
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: click>=8.1
22
+ Requires-Dist: rich>=13.0
23
+ Requires-Dist: tomli-w>=1.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-mock>=3.12; extra == 'dev'
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # dotsync
30
+
31
+ Fleet-style dotfiles manager with push cascading and status dashboard.
32
+
33
+ ## What makes it different
34
+
35
+ - **Fleet dashboard** — see the sync status of all your machines at once
36
+ - **Push cascading** — push from one machine, auto-pull on all others via SSH
37
+ - **New-machine bootstrap** — SSH key setup, GitHub key, clone, link, brew
38
+ - **Simple config** — one TOML file, no templating engine
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pipx install dotsync
44
+ ```
45
+
46
+ ## Quick start
47
+
48
+ ```bash
49
+ # Bootstrap a new machine (generates SSH key, clones dotfiles, links, brews)
50
+ dotsync setup
51
+
52
+ # See fleet status
53
+ dotsync status
54
+
55
+ # Push changes to all machines
56
+ dotsync push
57
+
58
+ # Pull latest on this machine
59
+ dotsync pull
60
+
61
+ # Manage fleet
62
+ dotsync add work-laptop --ssh-alias work
63
+ dotsync remove old-desktop
64
+
65
+ # Retry failed brew packages
66
+ dotsync pending
67
+ ```
68
+
69
+ ## Config
70
+
71
+ dotsync reads `~/.dotfiles/.dotsync.toml`:
72
+
73
+ ```toml
74
+ [dotsync]
75
+ repo = "git@github.com:you/dotfiles.git"
76
+ dotfiles_path = "~/.dotfiles"
77
+
78
+ [links]
79
+ ".zshrc" = ".zshrc"
80
+ ".zprofile" = ".zprofile"
81
+ ".gitconfig" = ".gitconfig"
82
+ "ssh/config" = ".ssh/config"
83
+
84
+ [brew]
85
+ brewfile = "Brewfile"
86
+ pending_file = ".brew-pending"
87
+
88
+ [[machines]]
89
+ name = "work-mini"
90
+ ssh_alias = "work-mini"
91
+
92
+ [[machines]]
93
+ name = "home-mini"
94
+ ssh_alias = "home-mini"
95
+ ```
96
+
97
+ ## Status
98
+
99
+ Alpha — core scaffolding complete, implementation in progress.
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,75 @@
1
+ # dotsync
2
+
3
+ Fleet-style dotfiles manager with push cascading and status dashboard.
4
+
5
+ ## What makes it different
6
+
7
+ - **Fleet dashboard** — see the sync status of all your machines at once
8
+ - **Push cascading** — push from one machine, auto-pull on all others via SSH
9
+ - **New-machine bootstrap** — SSH key setup, GitHub key, clone, link, brew
10
+ - **Simple config** — one TOML file, no templating engine
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pipx install dotsync
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```bash
21
+ # Bootstrap a new machine (generates SSH key, clones dotfiles, links, brews)
22
+ dotsync setup
23
+
24
+ # See fleet status
25
+ dotsync status
26
+
27
+ # Push changes to all machines
28
+ dotsync push
29
+
30
+ # Pull latest on this machine
31
+ dotsync pull
32
+
33
+ # Manage fleet
34
+ dotsync add work-laptop --ssh-alias work
35
+ dotsync remove old-desktop
36
+
37
+ # Retry failed brew packages
38
+ dotsync pending
39
+ ```
40
+
41
+ ## Config
42
+
43
+ dotsync reads `~/.dotfiles/.dotsync.toml`:
44
+
45
+ ```toml
46
+ [dotsync]
47
+ repo = "git@github.com:you/dotfiles.git"
48
+ dotfiles_path = "~/.dotfiles"
49
+
50
+ [links]
51
+ ".zshrc" = ".zshrc"
52
+ ".zprofile" = ".zprofile"
53
+ ".gitconfig" = ".gitconfig"
54
+ "ssh/config" = ".ssh/config"
55
+
56
+ [brew]
57
+ brewfile = "Brewfile"
58
+ pending_file = ".brew-pending"
59
+
60
+ [[machines]]
61
+ name = "work-mini"
62
+ ssh_alias = "work-mini"
63
+
64
+ [[machines]]
65
+ name = "home-mini"
66
+ ssh_alias = "home-mini"
67
+ ```
68
+
69
+ ## Status
70
+
71
+ Alpha — core scaffolding complete, implementation in progress.
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dotsync-py"
7
+ version = "0.1.0"
8
+ description = "Fleet-style dotfiles manager with push cascading and status dashboard"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "Scott Sheffield", email = "scott@shef.us" },
14
+ ]
15
+ keywords = ["dotfiles", "sync", "fleet", "cli"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: MacOS",
22
+ "Operating System :: POSIX :: Linux",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: System :: Systems Administration",
25
+ ]
26
+ dependencies = [
27
+ "click>=8.1",
28
+ "rich>=13.0",
29
+ "tomli-w>=1.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/scott-shef/dotsync"
34
+ Repository = "https://github.com/scott-shef/dotsync"
35
+ Issues = "https://github.com/scott-shef/dotsync/issues"
36
+
37
+ [project.scripts]
38
+ dotsync = "dotsync.cli:cli"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/dotsync"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+
46
+ [project.optional-dependencies]
47
+ dev = [
48
+ "pytest>=8.0",
49
+ "pytest-mock>=3.12",
50
+ ]
@@ -0,0 +1,3 @@
1
+ """dotsync — Fleet-style dotfiles manager."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,74 @@
1
+ """Brew bundle with failure capture and pending package management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ from dotsync.config import load_config
11
+
12
+
13
+ def capture_failures(output: str, dotfiles_path: Path) -> None:
14
+ """Parse brew bundle output and save failed packages to .brew-pending."""
15
+ config = load_config()
16
+ pending_path = dotfiles_path / config.pending_file
17
+
18
+ # Extract failed package names from brew bundle output
19
+ # Typical failure line: "Installing <name> has failed!"
20
+ # or "Homebrew Bundle failed! N Brewfile dependencies failed to install."
21
+ failed = []
22
+ for line in output.splitlines():
23
+ line = line.strip()
24
+ if "has failed" in line.lower():
25
+ # Try to extract the package name
26
+ parts = line.split()
27
+ if len(parts) >= 2:
28
+ failed.append(parts[1])
29
+
30
+ if failed:
31
+ pending_path.write_text("\n".join(failed) + "\n")
32
+
33
+
34
+ def show_pending() -> None:
35
+ """Show pending (failed) brew packages and offer to install."""
36
+ console = Console()
37
+ config = load_config()
38
+ pending_path = config.dotfiles_dir / config.pending_file
39
+
40
+ if not pending_path.exists():
41
+ console.print("[green]No pending brew packages.[/green]")
42
+ return
43
+
44
+ packages = [p.strip() for p in pending_path.read_text().splitlines() if p.strip()]
45
+ if not packages:
46
+ console.print("[green]No pending brew packages.[/green]")
47
+ pending_path.unlink()
48
+ return
49
+
50
+ console.print(f"[yellow]Pending brew packages ({len(packages)}):[/yellow]")
51
+ for pkg in packages:
52
+ console.print(f" - {pkg}")
53
+
54
+ from rich.prompt import Confirm
55
+ if Confirm.ask("\nAttempt to install now?", default=True):
56
+ still_failed = []
57
+ for pkg in packages:
58
+ console.print(f" Installing {pkg}...", end=" ")
59
+ result = subprocess.run(
60
+ ["brew", "install", pkg],
61
+ capture_output=True, text=True, check=False,
62
+ )
63
+ if result.returncode == 0:
64
+ console.print("[green]ok[/green]")
65
+ else:
66
+ console.print("[red]failed[/red]")
67
+ still_failed.append(pkg)
68
+
69
+ if still_failed:
70
+ pending_path.write_text("\n".join(still_failed) + "\n")
71
+ console.print(f"\n[yellow]{len(still_failed)} packages still failing.[/yellow]")
72
+ else:
73
+ pending_path.unlink()
74
+ console.print("\n[green]All pending packages installed![/green]")
@@ -0,0 +1,74 @@
1
+ """Click CLI for dotsync."""
2
+
3
+ import click
4
+
5
+ from dotsync import __version__
6
+
7
+
8
+ @click.group()
9
+ @click.version_option(version=__version__, prog_name="dotsync")
10
+ def cli():
11
+ """Fleet-style dotfiles manager.
12
+
13
+ Sync dotfiles across machines with push cascading,
14
+ fleet status dashboard, and new-machine bootstrap.
15
+ """
16
+
17
+
18
+ @cli.command()
19
+ def status():
20
+ """Show fleet dashboard — status of all machines."""
21
+ from dotsync.sync import fleet_status
22
+
23
+ fleet_status()
24
+
25
+
26
+ @cli.command()
27
+ def push():
28
+ """Auto-commit, push, and cascade to fleet."""
29
+ from dotsync.sync import push_dotfiles
30
+
31
+ push_dotfiles()
32
+
33
+
34
+ @cli.command()
35
+ def pull():
36
+ """Pull latest changes and run setup locally."""
37
+ from dotsync.sync import pull_dotfiles
38
+
39
+ pull_dotfiles()
40
+
41
+
42
+ @cli.command()
43
+ def setup():
44
+ """Bootstrap this machine (SSH key, GitHub, clone, link, brew)."""
45
+ from dotsync.setup_machine import bootstrap
46
+
47
+ bootstrap()
48
+
49
+
50
+ @cli.command()
51
+ @click.argument("name")
52
+ @click.option("--ssh-alias", default=None, help="SSH alias for the machine (defaults to name).")
53
+ def add(name: str, ssh_alias: str | None):
54
+ """Add a machine to the fleet config."""
55
+ from dotsync.config import add_machine
56
+
57
+ add_machine(name, ssh_alias=ssh_alias or name)
58
+
59
+
60
+ @cli.command()
61
+ @click.argument("name")
62
+ def remove(name: str):
63
+ """Remove a machine from the fleet config."""
64
+ from dotsync.config import remove_machine
65
+
66
+ remove_machine(name)
67
+
68
+
69
+ @cli.command()
70
+ def pending():
71
+ """Show or install failed brew packages."""
72
+ from dotsync.brewfile import show_pending
73
+
74
+ show_pending()
@@ -0,0 +1,128 @@
1
+ """Load and save ~/.dotfiles/.dotsync.toml config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ import tomli_w
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else:
14
+ import tomli as tomllib
15
+
16
+
17
+ DEFAULT_CONFIG_PATH = Path.home() / ".dotfiles" / ".dotsync.toml"
18
+
19
+
20
+ @dataclass
21
+ class Machine:
22
+ name: str
23
+ ssh_alias: str
24
+
25
+
26
+ @dataclass
27
+ class Config:
28
+ repo: str = ""
29
+ dotfiles_path: str = "~/.dotfiles"
30
+ links: dict[str, str] = field(default_factory=dict)
31
+ brewfile: str = "Brewfile"
32
+ pending_file: str = ".brew-pending"
33
+ machines: list[Machine] = field(default_factory=list)
34
+
35
+ @property
36
+ def dotfiles_dir(self) -> Path:
37
+ return Path(self.dotfiles_path).expanduser()
38
+
39
+ @property
40
+ def config_path(self) -> Path:
41
+ return self.dotfiles_dir / ".dotsync.toml"
42
+
43
+
44
+ def load_config(path: Path | None = None) -> Config:
45
+ """Load config from TOML file. Returns defaults if file doesn't exist."""
46
+ config_path = path or DEFAULT_CONFIG_PATH
47
+ if not config_path.exists():
48
+ return Config()
49
+
50
+ with open(config_path, "rb") as f:
51
+ data = tomllib.load(f)
52
+
53
+ ds = data.get("dotsync", {})
54
+ brew = data.get("brew", {})
55
+ machines_raw = data.get("machines", [])
56
+
57
+ machines = [
58
+ Machine(name=m["name"], ssh_alias=m.get("ssh_alias", m["name"]))
59
+ for m in machines_raw
60
+ ]
61
+
62
+ return Config(
63
+ repo=ds.get("repo", ""),
64
+ dotfiles_path=ds.get("dotfiles_path", "~/.dotfiles"),
65
+ links=data.get("links", {}),
66
+ brewfile=brew.get("brewfile", "Brewfile"),
67
+ pending_file=brew.get("pending_file", ".brew-pending"),
68
+ machines=machines,
69
+ )
70
+
71
+
72
+ def save_config(config: Config, path: Path | None = None) -> None:
73
+ """Save config to TOML file."""
74
+ config_path = path or config.config_path
75
+
76
+ data: dict = {
77
+ "dotsync": {
78
+ "repo": config.repo,
79
+ "dotfiles_path": config.dotfiles_path,
80
+ },
81
+ "links": config.links,
82
+ "brew": {
83
+ "brewfile": config.brewfile,
84
+ "pending_file": config.pending_file,
85
+ },
86
+ "machines": [
87
+ {"name": m.name, "ssh_alias": m.ssh_alias}
88
+ for m in config.machines
89
+ ],
90
+ }
91
+
92
+ config_path.parent.mkdir(parents=True, exist_ok=True)
93
+ with open(config_path, "wb") as f:
94
+ tomli_w.dump(data, f)
95
+
96
+
97
+ def add_machine(name: str, ssh_alias: str | None = None) -> None:
98
+ """Add a machine to the config."""
99
+ from rich.console import Console
100
+
101
+ console = Console()
102
+ config = load_config()
103
+
104
+ if any(m.name == name for m in config.machines):
105
+ console.print(f"[yellow]Machine '{name}' already exists in config.[/yellow]")
106
+ return
107
+
108
+ config.machines.append(Machine(name=name, ssh_alias=ssh_alias or name))
109
+ save_config(config)
110
+ console.print(f"[green]Added machine '{name}' (ssh: {ssh_alias or name})[/green]")
111
+
112
+
113
+ def remove_machine(name: str) -> None:
114
+ """Remove a machine from the config."""
115
+ from rich.console import Console
116
+
117
+ console = Console()
118
+ config = load_config()
119
+
120
+ original_count = len(config.machines)
121
+ config.machines = [m for m in config.machines if m.name != name]
122
+
123
+ if len(config.machines) == original_count:
124
+ console.print(f"[yellow]Machine '{name}' not found in config.[/yellow]")
125
+ return
126
+
127
+ save_config(config)
128
+ console.print(f"[green]Removed machine '{name}'[/green]")
@@ -0,0 +1,49 @@
1
+ """Symlink dotfiles from the repo to ~."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+
9
+ from dotsync.config import load_config
10
+
11
+
12
+ def link_dotfiles() -> None:
13
+ """Create symlinks for all configured links."""
14
+ console = Console()
15
+ config = load_config()
16
+
17
+ if not config.links:
18
+ console.print("[yellow]No links configured in .dotsync.toml.[/yellow]")
19
+ return
20
+
21
+ dotfiles_dir = config.dotfiles_dir
22
+ home = Path.home()
23
+
24
+ for source_rel, target_rel in config.links.items():
25
+ source = dotfiles_dir / source_rel
26
+ target = home / target_rel
27
+
28
+ if not source.exists():
29
+ console.print(f" [red]skip[/red] {source_rel} — source not found")
30
+ continue
31
+
32
+ # Create parent directory if needed
33
+ target.parent.mkdir(parents=True, exist_ok=True)
34
+
35
+ if target.is_symlink():
36
+ if target.resolve() == source.resolve():
37
+ console.print(f" [dim]ok[/dim] {target_rel} → {source_rel}")
38
+ continue
39
+ # Symlink exists but points elsewhere — replace it
40
+ target.unlink()
41
+
42
+ if target.exists():
43
+ # Real file exists — back it up
44
+ backup = target.with_suffix(target.suffix + ".dotsync-backup")
45
+ console.print(f" [yellow]backup[/yellow] {target_rel} → {backup.name}")
46
+ target.rename(backup)
47
+
48
+ target.symlink_to(source)
49
+ console.print(f" [green]link[/green] {target_rel} → {source_rel}")
@@ -0,0 +1,134 @@
1
+ """New-machine bootstrap: SSH key, GitHub, clone, link, brew."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import subprocess
7
+ import webbrowser
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+ from rich.prompt import Confirm
12
+
13
+ from dotsync.config import load_config
14
+ from dotsync.linker import link_dotfiles
15
+
16
+
17
+ def _run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
18
+ return subprocess.run(cmd, capture_output=True, text=True, check=check, **kwargs)
19
+
20
+
21
+ def _generate_ssh_key(console: Console) -> Path:
22
+ """Generate an ED25519 SSH key if one doesn't exist."""
23
+ key_path = Path.home() / ".ssh" / "id_ed25519"
24
+ if key_path.exists():
25
+ console.print(f"[dim]SSH key already exists at {key_path}[/dim]")
26
+ return key_path
27
+
28
+ console.print("Generating SSH key...")
29
+ key_path.parent.mkdir(mode=0o700, exist_ok=True)
30
+ hostname = platform.node()
31
+ _run([
32
+ "ssh-keygen", "-t", "ed25519",
33
+ "-f", str(key_path),
34
+ "-N", "", # no passphrase
35
+ "-C", f"dotsync@{hostname}",
36
+ ])
37
+ console.print(f"[green]Created {key_path}[/green]")
38
+ return key_path
39
+
40
+
41
+ def _add_key_to_github(console: Console, key_path: Path) -> None:
42
+ """Show the public key and open GitHub settings to add it."""
43
+ pub_path = key_path.with_suffix(".pub")
44
+ pub_key = pub_path.read_text().strip()
45
+
46
+ console.print("\n[bold]Add this SSH key to GitHub:[/bold]")
47
+ console.print(f"\n {pub_key}\n")
48
+
49
+ if Confirm.ask("Open GitHub SSH settings in browser?", default=True):
50
+ webbrowser.open("https://github.com/settings/ssh/new")
51
+ console.print("[dim]Waiting for you to add the key... Press Enter when done.[/dim]")
52
+ input()
53
+
54
+
55
+ def _clone_dotfiles(console: Console, repo: str, dotfiles_path: Path) -> None:
56
+ """Clone the dotfiles repo if the directory doesn't exist."""
57
+ if dotfiles_path.exists():
58
+ console.print(f"[dim]Dotfiles directory already exists at {dotfiles_path}[/dim]")
59
+ return
60
+
61
+ console.print(f"Cloning {repo}...")
62
+ result = _run(
63
+ ["git", "clone", repo, str(dotfiles_path)],
64
+ check=False,
65
+ )
66
+ if result.returncode != 0:
67
+ console.print(f"[red]Clone failed:[/red] {result.stderr.strip()}")
68
+ console.print("[yellow]Make sure you've added your SSH key to GitHub.[/yellow]")
69
+ raise SystemExit(1)
70
+ console.print(f"[green]Cloned to {dotfiles_path}[/green]")
71
+
72
+
73
+ def _run_brew_bundle(console: Console, dotfiles_path: Path, brewfile: str) -> None:
74
+ """Run brew bundle if on macOS and Brewfile exists."""
75
+ if platform.system() != "Darwin":
76
+ console.print("[dim]Skipping brew (not macOS).[/dim]")
77
+ return
78
+
79
+ brewfile_path = dotfiles_path / brewfile
80
+ if not brewfile_path.exists():
81
+ console.print(f"[dim]No {brewfile} found, skipping brew.[/dim]")
82
+ return
83
+
84
+ console.print("Running brew bundle...")
85
+ result = _run(
86
+ ["brew", "bundle", "--file", str(brewfile_path)],
87
+ check=False,
88
+ )
89
+ if result.returncode != 0:
90
+ console.print("[yellow]Some brew packages failed. Run 'dotsync pending' to retry.[/yellow]")
91
+ # Capture failed packages
92
+ from dotsync.brewfile import capture_failures
93
+ capture_failures(result.stderr + result.stdout, dotfiles_path)
94
+ else:
95
+ console.print("[green]Brew bundle complete.[/green]")
96
+
97
+
98
+ def bootstrap() -> None:
99
+ """Full new-machine bootstrap flow."""
100
+ console = Console()
101
+ config = load_config()
102
+
103
+ console.print("[bold]dotsync setup — bootstrapping this machine[/bold]\n")
104
+
105
+ # Step 1: SSH key
106
+ key_path = _generate_ssh_key(console)
107
+
108
+ # Step 2: GitHub key
109
+ _add_key_to_github(console, key_path)
110
+
111
+ # Step 3: Clone dotfiles
112
+ if config.repo:
113
+ dotfiles_path = config.dotfiles_dir
114
+ _clone_dotfiles(console, config.repo, dotfiles_path)
115
+ else:
116
+ console.print("[yellow]No repo configured in .dotsync.toml. Skipping clone.[/yellow]")
117
+ console.print("[dim]Set dotsync.repo in your config after cloning manually.[/dim]")
118
+ return
119
+
120
+ # Step 4: Symlink dotfiles
121
+ console.print("\nLinking dotfiles...")
122
+ link_dotfiles()
123
+
124
+ # Step 5: Brew bundle
125
+ _run_brew_bundle(console, config.dotfiles_dir, config.brewfile)
126
+
127
+ # Step 6: Add this machine to config
128
+ hostname = platform.node().split(".")[0].lower()
129
+ if not any(m.name == hostname for m in config.machines):
130
+ if Confirm.ask(f"\nAdd this machine ('{hostname}') to fleet config?", default=True):
131
+ from dotsync.config import add_machine
132
+ add_machine(hostname)
133
+
134
+ console.print("\n[bold green]Setup complete![/bold green]")
@@ -0,0 +1,21 @@
1
+ """SSH key generation and remote command execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+
7
+
8
+ def run_remote(host: str, command: str, timeout: int = 10) -> subprocess.CompletedProcess:
9
+ """Run a command on a remote machine via SSH."""
10
+ return subprocess.run(
11
+ ["ssh", "-o", f"ConnectTimeout={timeout}", "-o", "BatchMode=yes", host, command],
12
+ capture_output=True,
13
+ text=True,
14
+ check=False,
15
+ )
16
+
17
+
18
+ def is_reachable(host: str, timeout: int = 3) -> bool:
19
+ """Check if a host is reachable via SSH."""
20
+ result = run_remote(host, "echo ok", timeout=timeout)
21
+ return result.returncode == 0
@@ -0,0 +1,147 @@
1
+ """Push, pull, cascade, and fleet status dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from dotsync.config import load_config
11
+
12
+
13
+ def _run(cmd: list[str], cwd: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
14
+ """Run a subprocess command and return the result."""
15
+ return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=check)
16
+
17
+
18
+ def _git(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess:
19
+ """Run a git command in the dotfiles directory."""
20
+ config = load_config()
21
+ return _run(["git", *args], cwd=cwd or str(config.dotfiles_dir))
22
+
23
+
24
+ def _get_changed_files(cwd: str) -> list[str]:
25
+ """Get list of changed files (staged + unstaged + untracked)."""
26
+ result = _run(["git", "status", "--porcelain"], cwd=cwd, check=False)
27
+ if result.returncode != 0:
28
+ return []
29
+ return [
30
+ line[3:].strip()
31
+ for line in result.stdout.strip().splitlines()
32
+ if line.strip()
33
+ ]
34
+
35
+
36
+ def _auto_commit(cwd: str) -> bool:
37
+ """Auto-commit changes with a generated message. Returns True if a commit was made."""
38
+ changed = _get_changed_files(cwd)
39
+ if not changed:
40
+ return False
41
+
42
+ # Stage everything
43
+ _run(["git", "add", "-A"], cwd=cwd)
44
+
45
+ # Generate commit message from changed filenames
46
+ if len(changed) <= 5:
47
+ files_desc = ", ".join(changed)
48
+ else:
49
+ files_desc = ", ".join(changed[:4]) + f" +{len(changed) - 4} more"
50
+
51
+ message = f"update {files_desc}"
52
+ _run(["git", "commit", "-m", message], cwd=cwd)
53
+ return True
54
+
55
+
56
+ def fleet_status() -> None:
57
+ """Show fleet dashboard with status of all machines."""
58
+ console = Console()
59
+ config = load_config()
60
+
61
+ if not config.machines:
62
+ console.print("[yellow]No machines configured. Run 'dotsync add <name>' to add one.[/yellow]")
63
+ return
64
+
65
+ table = Table(title="dotsync fleet status")
66
+ table.add_column("Machine", style="cyan")
67
+ table.add_column("SSH Alias", style="dim")
68
+ table.add_column("Reachable", justify="center")
69
+ table.add_column("Git Status", style="yellow")
70
+
71
+ for machine in config.machines:
72
+ # Check if machine is reachable via SSH
73
+ result = _run(
74
+ ["ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes",
75
+ machine.ssh_alias, "echo ok"],
76
+ check=False,
77
+ )
78
+ reachable = "[green]yes[/green]" if result.returncode == 0 else "[red]no[/red]"
79
+
80
+ # Try to get git status on remote
81
+ git_status = "—"
82
+ if result.returncode == 0:
83
+ gs = _run(
84
+ ["ssh", "-o", "ConnectTimeout=3", machine.ssh_alias,
85
+ f"cd {config.dotfiles_path} && git status --porcelain 2>/dev/null | wc -l"],
86
+ check=False,
87
+ )
88
+ if gs.returncode == 0:
89
+ count = gs.stdout.strip()
90
+ git_status = "clean" if count == "0" else f"{count} changed"
91
+
92
+ table.add_row(machine.name, machine.ssh_alias, reachable, git_status)
93
+
94
+ console.print(table)
95
+
96
+
97
+ def push_dotfiles() -> None:
98
+ """Auto-commit local changes, push to remote, then cascade pull to fleet."""
99
+ console = Console()
100
+ config = load_config()
101
+ cwd = str(config.dotfiles_dir)
102
+
103
+ # Auto-commit
104
+ if _auto_commit(cwd):
105
+ console.print("[green]Committed local changes.[/green]")
106
+ else:
107
+ console.print("[dim]No local changes to commit.[/dim]")
108
+
109
+ # Push to remote
110
+ console.print("Pushing to remote...")
111
+ result = _run(["git", "push"], cwd=cwd, check=False)
112
+ if result.returncode != 0:
113
+ console.print(f"[red]Push failed:[/red] {result.stderr.strip()}")
114
+ return
115
+ console.print("[green]Pushed.[/green]")
116
+
117
+ # Cascade to fleet
118
+ if not config.machines:
119
+ return
120
+
121
+ console.print("\nCascading to fleet...")
122
+ for machine in config.machines:
123
+ console.print(f" {machine.name}...", end=" ")
124
+ r = _run(
125
+ ["ssh", "-o", "ConnectTimeout=5", machine.ssh_alias,
126
+ f"cd {config.dotfiles_path} && git pull --ff-only 2>&1"],
127
+ check=False,
128
+ )
129
+ if r.returncode == 0:
130
+ console.print("[green]ok[/green]")
131
+ else:
132
+ console.print(f"[red]failed[/red] — {r.stderr.strip() or r.stdout.strip()}")
133
+
134
+
135
+ def pull_dotfiles() -> None:
136
+ """Pull latest changes and report."""
137
+ console = Console()
138
+ config = load_config()
139
+ cwd = str(config.dotfiles_dir)
140
+
141
+ console.print("Pulling latest changes...")
142
+ result = _run(["git", "pull", "--ff-only"], cwd=cwd, check=False)
143
+ if result.returncode != 0:
144
+ console.print(f"[red]Pull failed:[/red] {result.stderr.strip()}")
145
+ return
146
+
147
+ console.print(f"[green]{result.stdout.strip()}[/green]")
File without changes
@@ -0,0 +1,29 @@
1
+ """Common test fixtures for dotsync."""
2
+
3
+ import pytest
4
+
5
+ from dotsync.config import Config, Machine
6
+
7
+
8
+ @pytest.fixture
9
+ def sample_config(tmp_path):
10
+ """A Config pointing at a temp directory with sample machines."""
11
+ dotfiles = tmp_path / "dotfiles"
12
+ dotfiles.mkdir()
13
+ return Config(
14
+ repo="git@github.com:test/dotfiles.git",
15
+ dotfiles_path=str(dotfiles),
16
+ links={".zshrc": ".zshrc", ".gitconfig": ".gitconfig"},
17
+ machines=[
18
+ Machine(name="work-mini", ssh_alias="work-mini"),
19
+ Machine(name="home-mini", ssh_alias="home-mini"),
20
+ ],
21
+ )
22
+
23
+
24
+ @pytest.fixture
25
+ def empty_config(tmp_path):
26
+ """A Config with no machines configured."""
27
+ dotfiles = tmp_path / "dotfiles"
28
+ dotfiles.mkdir()
29
+ return Config(dotfiles_path=str(dotfiles))
@@ -0,0 +1,61 @@
1
+ """Test that the CLI loads and --help works for each command."""
2
+
3
+ from click.testing import CliRunner
4
+
5
+ from dotsync.cli import cli
6
+
7
+
8
+ runner = CliRunner()
9
+
10
+
11
+ def test_cli_help():
12
+ result = runner.invoke(cli, ["--help"])
13
+ assert result.exit_code == 0
14
+ assert "Fleet-style dotfiles manager" in result.output
15
+
16
+
17
+ def test_cli_version():
18
+ result = runner.invoke(cli, ["--version"])
19
+ assert result.exit_code == 0
20
+ assert "0.1.0" in result.output
21
+
22
+
23
+ def test_status_help():
24
+ result = runner.invoke(cli, ["status", "--help"])
25
+ assert result.exit_code == 0
26
+ assert "fleet dashboard" in result.output.lower()
27
+
28
+
29
+ def test_push_help():
30
+ result = runner.invoke(cli, ["push", "--help"])
31
+ assert result.exit_code == 0
32
+ assert "cascade" in result.output.lower()
33
+
34
+
35
+ def test_pull_help():
36
+ result = runner.invoke(cli, ["pull", "--help"])
37
+ assert result.exit_code == 0
38
+
39
+
40
+ def test_setup_help():
41
+ result = runner.invoke(cli, ["setup", "--help"])
42
+ assert result.exit_code == 0
43
+ assert "bootstrap" in result.output.lower()
44
+
45
+
46
+ def test_add_help():
47
+ result = runner.invoke(cli, ["add", "--help"])
48
+ assert result.exit_code == 0
49
+ assert "machine" in result.output.lower()
50
+
51
+
52
+ def test_remove_help():
53
+ result = runner.invoke(cli, ["remove", "--help"])
54
+ assert result.exit_code == 0
55
+ assert "machine" in result.output.lower()
56
+
57
+
58
+ def test_pending_help():
59
+ result = runner.invoke(cli, ["pending", "--help"])
60
+ assert result.exit_code == 0
61
+ assert "brew" in result.output.lower()
@@ -0,0 +1,43 @@
1
+ """Test config loading and saving."""
2
+
3
+ from dotsync.config import Config, Machine, load_config, save_config
4
+
5
+
6
+ def test_save_and_load_roundtrip(tmp_path):
7
+ config_path = tmp_path / ".dotsync.toml"
8
+
9
+ original = Config(
10
+ repo="git@github.com:user/dots.git",
11
+ dotfiles_path=str(tmp_path),
12
+ links={".zshrc": ".zshrc"},
13
+ machines=[Machine(name="test-box", ssh_alias="test-box")],
14
+ )
15
+
16
+ save_config(original, path=config_path)
17
+ assert config_path.exists()
18
+
19
+ loaded = load_config(path=config_path)
20
+ assert loaded.repo == original.repo
21
+ assert loaded.links == original.links
22
+ assert len(loaded.machines) == 1
23
+ assert loaded.machines[0].name == "test-box"
24
+
25
+
26
+ def test_load_missing_config_returns_defaults(tmp_path):
27
+ config = load_config(path=tmp_path / "nonexistent.toml")
28
+ assert config.repo == ""
29
+ assert config.machines == []
30
+ assert config.dotfiles_path == "~/.dotfiles"
31
+
32
+
33
+ def test_add_machine_duplicate(tmp_path, capsys):
34
+ config_path = tmp_path / ".dotsync.toml"
35
+ config = Config(
36
+ dotfiles_path=str(tmp_path),
37
+ machines=[Machine(name="box1", ssh_alias="box1")],
38
+ )
39
+ save_config(config, path=config_path)
40
+
41
+ # Loading from default path won't work in tests, so we test the logic directly
42
+ loaded = load_config(path=config_path)
43
+ assert any(m.name == "box1" for m in loaded.machines)
@@ -0,0 +1,72 @@
1
+ """Test symlink creation."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ from dotsync.config import Config
7
+ from dotsync.linker import link_dotfiles
8
+
9
+
10
+ def test_link_creates_symlinks(tmp_path):
11
+ dotfiles = tmp_path / "dotfiles"
12
+ dotfiles.mkdir()
13
+ home = tmp_path / "home"
14
+ home.mkdir()
15
+
16
+ # Create source files
17
+ (dotfiles / ".zshrc").write_text("# zshrc")
18
+ (dotfiles / ".gitconfig").write_text("# gitconfig")
19
+
20
+ config = Config(
21
+ dotfiles_path=str(dotfiles),
22
+ links={".zshrc": ".zshrc", ".gitconfig": ".gitconfig"},
23
+ )
24
+
25
+ with patch("dotsync.linker.load_config", return_value=config), \
26
+ patch("dotsync.linker.Path.home", return_value=home):
27
+ link_dotfiles()
28
+
29
+ assert (home / ".zshrc").is_symlink()
30
+ assert (home / ".zshrc").resolve() == (dotfiles / ".zshrc").resolve()
31
+ assert (home / ".gitconfig").is_symlink()
32
+
33
+
34
+ def test_link_backs_up_existing_files(tmp_path):
35
+ dotfiles = tmp_path / "dotfiles"
36
+ dotfiles.mkdir()
37
+ home = tmp_path / "home"
38
+ home.mkdir()
39
+
40
+ (dotfiles / ".zshrc").write_text("# new zshrc")
41
+ (home / ".zshrc").write_text("# old zshrc")
42
+
43
+ config = Config(
44
+ dotfiles_path=str(dotfiles),
45
+ links={".zshrc": ".zshrc"},
46
+ )
47
+
48
+ with patch("dotsync.linker.load_config", return_value=config), \
49
+ patch("dotsync.linker.Path.home", return_value=home):
50
+ link_dotfiles()
51
+
52
+ assert (home / ".zshrc").is_symlink()
53
+ assert (home / ".zshrc.dotsync-backup").exists()
54
+ assert (home / ".zshrc.dotsync-backup").read_text() == "# old zshrc"
55
+
56
+
57
+ def test_link_skips_missing_source(tmp_path):
58
+ dotfiles = tmp_path / "dotfiles"
59
+ dotfiles.mkdir()
60
+ home = tmp_path / "home"
61
+ home.mkdir()
62
+
63
+ config = Config(
64
+ dotfiles_path=str(dotfiles),
65
+ links={".missing": ".missing"},
66
+ )
67
+
68
+ with patch("dotsync.linker.load_config", return_value=config), \
69
+ patch("dotsync.linker.Path.home", return_value=home):
70
+ link_dotfiles()
71
+
72
+ assert not (home / ".missing").exists()