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.
- dotsync_py-0.1.0/.gitignore +35 -0
- dotsync_py-0.1.0/LICENSE +21 -0
- dotsync_py-0.1.0/PKG-INFO +103 -0
- dotsync_py-0.1.0/README.md +75 -0
- dotsync_py-0.1.0/pyproject.toml +50 -0
- dotsync_py-0.1.0/src/dotsync/__init__.py +3 -0
- dotsync_py-0.1.0/src/dotsync/brewfile.py +74 -0
- dotsync_py-0.1.0/src/dotsync/cli.py +74 -0
- dotsync_py-0.1.0/src/dotsync/config.py +128 -0
- dotsync_py-0.1.0/src/dotsync/linker.py +49 -0
- dotsync_py-0.1.0/src/dotsync/setup_machine.py +134 -0
- dotsync_py-0.1.0/src/dotsync/ssh.py +21 -0
- dotsync_py-0.1.0/src/dotsync/sync.py +147 -0
- dotsync_py-0.1.0/tests/__init__.py +0 -0
- dotsync_py-0.1.0/tests/conftest.py +29 -0
- dotsync_py-0.1.0/tests/test_cli.py +61 -0
- dotsync_py-0.1.0/tests/test_config.py +43 -0
- dotsync_py-0.1.0/tests/test_linker.py +72 -0
|
@@ -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
|
dotsync_py-0.1.0/LICENSE
ADDED
|
@@ -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,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()
|