dotsman 1.0.0__py3-none-any.whl
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.
- dots/__init__.py +1 -0
- dots/cli.py +174 -0
- dots/crypto.py +96 -0
- dots/errors.py +80 -0
- dots/fs.py +111 -0
- dots/repo.py +519 -0
- dots/ui.py +103 -0
- dotsman-1.0.0.dist-info/METADATA +181 -0
- dotsman-1.0.0.dist-info/RECORD +12 -0
- dotsman-1.0.0.dist-info/WHEEL +4 -0
- dotsman-1.0.0.dist-info/entry_points.txt +2 -0
- dotsman-1.0.0.dist-info/licenses/LICENSE +27 -0
dots/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "1.0.0"
|
dots/cli.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from dots import VERSION
|
|
10
|
+
from dots.errors import DotsError
|
|
11
|
+
from dots.repo import DotRepository
|
|
12
|
+
from dots.ui import UI
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="dots",
|
|
16
|
+
help="Configuration files management tool.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
add_completion=False,
|
|
19
|
+
pretty_exceptions_show_locals=False,
|
|
20
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def version_callback(value: bool) -> None:
|
|
25
|
+
if value:
|
|
26
|
+
typer.echo(f"dots {VERSION}")
|
|
27
|
+
raise typer.Exit
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def config_callback(value: Path) -> Path:
|
|
31
|
+
return value.expanduser()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_repo(ctx: typer.Context) -> DotRepository:
|
|
35
|
+
state: State = ctx.obj
|
|
36
|
+
return DotRepository.load(state.config, state.ui)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class State:
|
|
40
|
+
def __init__(self, config: Path, verbose: bool) -> None:
|
|
41
|
+
self.config = config
|
|
42
|
+
self.verbose = verbose
|
|
43
|
+
self.ui = UI(verbose=verbose)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.callback()
|
|
47
|
+
def app_main(
|
|
48
|
+
ctx: typer.Context,
|
|
49
|
+
config: Annotated[
|
|
50
|
+
Path,
|
|
51
|
+
typer.Option(
|
|
52
|
+
"--config",
|
|
53
|
+
"-c",
|
|
54
|
+
help="configuration file",
|
|
55
|
+
callback=config_callback,
|
|
56
|
+
),
|
|
57
|
+
] = Path("~/.dots.conf"),
|
|
58
|
+
verbose: Annotated[
|
|
59
|
+
bool,
|
|
60
|
+
typer.Option("--verbose", "-v", help="display debug information"),
|
|
61
|
+
] = False,
|
|
62
|
+
version: Annotated[
|
|
63
|
+
bool,
|
|
64
|
+
typer.Option(
|
|
65
|
+
"--version",
|
|
66
|
+
"-V",
|
|
67
|
+
help="display program version and exit",
|
|
68
|
+
callback=version_callback,
|
|
69
|
+
is_eager=True,
|
|
70
|
+
),
|
|
71
|
+
] = False,
|
|
72
|
+
) -> None:
|
|
73
|
+
_ = version # consumed by version_callback; silence unused-parameter checks
|
|
74
|
+
ctx.obj = State(config=config, verbose=verbose)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("add")
|
|
78
|
+
def cmd_add(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
file: Annotated[Path, typer.Argument(help="path of the file to add")],
|
|
81
|
+
encrypt: Annotated[
|
|
82
|
+
bool,
|
|
83
|
+
typer.Option("--encrypt", "-e", help="encrypt the file with age"),
|
|
84
|
+
] = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Add a file to the repository.
|
|
88
|
+
"""
|
|
89
|
+
load_repo(ctx).add(file, encrypt=encrypt)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("remove")
|
|
93
|
+
def cmd_remove(
|
|
94
|
+
ctx: typer.Context,
|
|
95
|
+
file: Annotated[Path, typer.Argument(help="path of the file to remove")],
|
|
96
|
+
) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Remove a file from the repository.
|
|
99
|
+
"""
|
|
100
|
+
load_repo(ctx).remove(file)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("list")
|
|
104
|
+
def cmd_list(ctx: typer.Context) -> None:
|
|
105
|
+
"""
|
|
106
|
+
List repository contents.
|
|
107
|
+
"""
|
|
108
|
+
load_repo(ctx).sync(list_only=True)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command("sync")
|
|
112
|
+
def cmd_sync(
|
|
113
|
+
ctx: typer.Context,
|
|
114
|
+
force_relink: Annotated[
|
|
115
|
+
bool,
|
|
116
|
+
typer.Option(
|
|
117
|
+
"--force-relink",
|
|
118
|
+
"-r",
|
|
119
|
+
help="if a link points to another file, overwrite without asking",
|
|
120
|
+
),
|
|
121
|
+
] = False,
|
|
122
|
+
force_add: Annotated[
|
|
123
|
+
bool,
|
|
124
|
+
typer.Option(
|
|
125
|
+
"--force-add",
|
|
126
|
+
"-a",
|
|
127
|
+
help="if a file already exists, overwrite the repository version",
|
|
128
|
+
),
|
|
129
|
+
] = False,
|
|
130
|
+
force_link: Annotated[
|
|
131
|
+
bool,
|
|
132
|
+
typer.Option(
|
|
133
|
+
"--force-link",
|
|
134
|
+
"-l",
|
|
135
|
+
help="if a file already exists, overwrite the local version",
|
|
136
|
+
),
|
|
137
|
+
] = False,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Synchronize repository content with the filesystem.
|
|
141
|
+
"""
|
|
142
|
+
if force_add and force_link:
|
|
143
|
+
raise typer.BadParameter("--force-add and --force-link are mutually exclusive")
|
|
144
|
+
load_repo(ctx).sync(
|
|
145
|
+
force_relink=force_relink,
|
|
146
|
+
force_add=force_add,
|
|
147
|
+
force_link=force_link,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Command aliases (hidden so they don't clutter --help)
|
|
152
|
+
app.command("a", hidden=True)(cmd_add)
|
|
153
|
+
app.command("rm", hidden=True)(cmd_remove)
|
|
154
|
+
app.command("rem", hidden=True)(cmd_remove)
|
|
155
|
+
app.command("del", hidden=True)(cmd_remove)
|
|
156
|
+
app.command("delete", hidden=True)(cmd_remove)
|
|
157
|
+
app.command("l", hidden=True)(cmd_list)
|
|
158
|
+
app.command("ls", hidden=True)(cmd_list)
|
|
159
|
+
app.command("s", hidden=True)(cmd_sync)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main() -> None:
|
|
163
|
+
"""
|
|
164
|
+
Script entry point. Wraps ``app`` so ``DotsError`` produces clean output.
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
app()
|
|
168
|
+
except DotsError as e:
|
|
169
|
+
UI().error(str(e))
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
dots/crypto.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pyrage import decrypt, encrypt, x25519
|
|
8
|
+
|
|
9
|
+
from dots.errors import CryptoError
|
|
10
|
+
|
|
11
|
+
AGE_EXTENSION = ".age"
|
|
12
|
+
DECRYPTED_DIR = ".decrypted"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class AgeKeyPair:
|
|
17
|
+
identity: x25519.Identity
|
|
18
|
+
recipient: x25519.Recipient
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_identity(identity_path: Path) -> AgeKeyPair:
|
|
22
|
+
"""
|
|
23
|
+
Read an age identity file and return the keypair.
|
|
24
|
+
|
|
25
|
+
The file format is the standard age key file: comment lines start
|
|
26
|
+
with ``#``, the secret key line starts with ``AGE-SECRET-KEY-``.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
text = identity_path.read_text().strip()
|
|
30
|
+
except OSError as e:
|
|
31
|
+
raise CryptoError(f"cannot read identity file: {e}", identity_path) from e
|
|
32
|
+
|
|
33
|
+
for line in text.splitlines():
|
|
34
|
+
line = line.strip()
|
|
35
|
+
if not line or line.startswith("#"):
|
|
36
|
+
continue
|
|
37
|
+
if line.startswith("AGE-SECRET-KEY-"):
|
|
38
|
+
try:
|
|
39
|
+
identity = x25519.Identity.from_str(line)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
raise CryptoError(f"invalid age identity: {e}", identity_path) from e
|
|
42
|
+
return AgeKeyPair(identity=identity, recipient=identity.to_public())
|
|
43
|
+
|
|
44
|
+
raise CryptoError("no AGE-SECRET-KEY found in identity file", identity_path)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def age_encrypt(plaintext: bytes, recipient: x25519.Recipient) -> bytes:
|
|
48
|
+
try:
|
|
49
|
+
return encrypt(plaintext, [recipient])
|
|
50
|
+
except Exception as e:
|
|
51
|
+
raise CryptoError(f"encryption failed: {e}") from e
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def age_decrypt(ciphertext: bytes, identity: x25519.Identity) -> bytes:
|
|
55
|
+
try:
|
|
56
|
+
return decrypt(ciphertext, [identity])
|
|
57
|
+
except Exception as e:
|
|
58
|
+
raise CryptoError(f"decryption failed: {e}") from e
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def content_hash(data: bytes) -> str:
|
|
62
|
+
return hashlib.sha256(data).hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_age_file(path: Path) -> bool:
|
|
66
|
+
return path.suffix == AGE_EXTENSION
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def age_to_decrypted_path(repo_path: Path, age_file: Path) -> Path:
|
|
70
|
+
"""
|
|
71
|
+
Map a ``.age`` repo file to its decrypted working copy path.
|
|
72
|
+
|
|
73
|
+
Example: ``repo/.config/secret.age`` -> ``repo/.decrypted/.config/secret``
|
|
74
|
+
"""
|
|
75
|
+
relpath = age_file.relative_to(repo_path)
|
|
76
|
+
return repo_path / DECRYPTED_DIR / relpath.with_suffix("")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def age_to_home_path(repo_path: Path, home_path: Path, age_file: Path) -> Path:
|
|
80
|
+
"""
|
|
81
|
+
Map a ``.age`` repo file to the home symlink path.
|
|
82
|
+
|
|
83
|
+
Example: ``repo/.config/secret.age`` -> ``home/.config/secret``
|
|
84
|
+
"""
|
|
85
|
+
relpath = age_file.relative_to(repo_path)
|
|
86
|
+
return home_path / relpath.with_suffix("")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def home_to_age_path(repo_path: Path, home_path: Path, target: Path) -> Path:
|
|
90
|
+
"""
|
|
91
|
+
Map a home file to its ``.age`` repo path.
|
|
92
|
+
|
|
93
|
+
Example: ``home/.config/secret`` -> ``repo/.config/secret.age``
|
|
94
|
+
"""
|
|
95
|
+
relpath = target.relative_to(home_path)
|
|
96
|
+
return repo_path / (relpath.as_posix() + AGE_EXTENSION)
|
dots/errors.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DotsError(Exception):
|
|
5
|
+
"""
|
|
6
|
+
Base class for all dots errors the CLI is expected to handle.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(DotsError):
|
|
11
|
+
"""
|
|
12
|
+
Configuration file is missing, unreadable, or invalid.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RepoError(DotsError):
|
|
17
|
+
"""
|
|
18
|
+
Dotfiles repository is missing or malformed.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NotInHomeError(DotsError):
|
|
23
|
+
"""
|
|
24
|
+
Target file is not under the user's home directory.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, path: Path, home: Path) -> None:
|
|
28
|
+
super().__init__(f"{path} is not inside {home}")
|
|
29
|
+
self.path = path
|
|
30
|
+
self.home = home
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotInRepoError(DotsError):
|
|
34
|
+
"""
|
|
35
|
+
An operation expected a file inside the repository.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, path: Path) -> None:
|
|
39
|
+
super().__init__(f"{path} is not a repository file")
|
|
40
|
+
self.path = path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AlreadyInRepoError(DotsError):
|
|
44
|
+
"""
|
|
45
|
+
Trying to add a file that is already tracked.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, path: Path) -> None:
|
|
49
|
+
super().__init__(f"{path} is already in the repository")
|
|
50
|
+
self.path = path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InvalidTargetError(DotsError):
|
|
54
|
+
"""
|
|
55
|
+
Target file is of an unexpected kind (e.g. a dangling symlink).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, message: str, path: Path) -> None:
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.path = path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FsError(DotsError):
|
|
64
|
+
"""
|
|
65
|
+
Atomic filesystem primitive fails and has been rolled back.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, message: str, path: Path) -> None:
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
self.path = path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CryptoError(DotsError):
|
|
74
|
+
"""
|
|
75
|
+
Age encryption or decryption failure.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, message: str, path: Path | None = None) -> None:
|
|
79
|
+
super().__init__(message)
|
|
80
|
+
self.path = path
|
dots/fs.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dots.errors import FsError
|
|
9
|
+
|
|
10
|
+
TMP_TEMPLATE = ".{name}.dots-{pid}.tmp"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def tmp_path(target: Path) -> Path:
|
|
14
|
+
return target.with_name(TMP_TEMPLATE.format(name=target.name, pid=os.getpid()))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_inside(child: Path, parent: Path) -> bool:
|
|
18
|
+
"""
|
|
19
|
+
Return True if ``child`` is at or under ``parent``.
|
|
20
|
+
|
|
21
|
+
Both paths are made absolute, but symlinks are NOT followed.
|
|
22
|
+
"""
|
|
23
|
+
return child.absolute().is_relative_to(parent.absolute())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ensure_parent_dir(path: Path) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Create all missing parent directories of ``path``.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
except OSError as e:
|
|
33
|
+
raise FsError(f"failed to create parent directory for {path}", path) from e
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def atomic_symlink(target: Path, link: Path) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Atomically create or replace a symlink at ``link`` pointing to ``target``.
|
|
39
|
+
|
|
40
|
+
Works whether ``link`` is currently missing, a regular file, or an existing
|
|
41
|
+
symlink.
|
|
42
|
+
"""
|
|
43
|
+
ensure_parent_dir(link)
|
|
44
|
+
tmp = tmp_path(link)
|
|
45
|
+
try:
|
|
46
|
+
tmp.symlink_to(target)
|
|
47
|
+
except OSError as e:
|
|
48
|
+
raise FsError(f"failed to create symlink at {link}", link) from e
|
|
49
|
+
try:
|
|
50
|
+
tmp.replace(link)
|
|
51
|
+
except OSError as e:
|
|
52
|
+
with contextlib.suppress(FileNotFoundError):
|
|
53
|
+
tmp.unlink()
|
|
54
|
+
raise FsError(f"failed to install symlink at {link}", link) from e
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def atomic_copy(src: Path, dst: Path) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Atomically install a copy of ``src`` at ``dst``.
|
|
60
|
+
|
|
61
|
+
Copies to a sibling temp next to ``dst`` then ``os.replace`` to commit.
|
|
62
|
+
``src`` is never touched - the caller decides whether to remove it.
|
|
63
|
+
On any failure, ``dst`` is left in its previous state.
|
|
64
|
+
"""
|
|
65
|
+
ensure_parent_dir(dst)
|
|
66
|
+
tmp = tmp_path(dst)
|
|
67
|
+
try:
|
|
68
|
+
shutil.copy2(src, tmp)
|
|
69
|
+
except OSError as e:
|
|
70
|
+
with contextlib.suppress(FileNotFoundError):
|
|
71
|
+
tmp.unlink()
|
|
72
|
+
raise FsError(f"failed to stage copy at {dst}", dst) from e
|
|
73
|
+
try:
|
|
74
|
+
tmp.replace(dst)
|
|
75
|
+
except OSError as e:
|
|
76
|
+
with contextlib.suppress(FileNotFoundError):
|
|
77
|
+
tmp.unlink()
|
|
78
|
+
raise FsError(f"failed to install file at {dst}", dst) from e
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def atomic_write(dst: Path, data: bytes) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Atomically write ``data`` to ``dst``.
|
|
84
|
+
|
|
85
|
+
Writes to a sibling temp file then ``os.replace`` to commit.
|
|
86
|
+
On failure, ``dst`` is left in its previous state.
|
|
87
|
+
"""
|
|
88
|
+
ensure_parent_dir(dst)
|
|
89
|
+
tmp = tmp_path(dst)
|
|
90
|
+
try:
|
|
91
|
+
tmp.write_bytes(data)
|
|
92
|
+
except OSError as e:
|
|
93
|
+
with contextlib.suppress(FileNotFoundError):
|
|
94
|
+
tmp.unlink()
|
|
95
|
+
raise FsError(f"failed to write to {dst}", dst) from e
|
|
96
|
+
try:
|
|
97
|
+
tmp.replace(dst)
|
|
98
|
+
except OSError as e:
|
|
99
|
+
with contextlib.suppress(FileNotFoundError):
|
|
100
|
+
tmp.unlink()
|
|
101
|
+
raise FsError(f"failed to install file at {dst}", dst) from e
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def safe_unlink(path: Path) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Remove ``path`` if it exists. Tolerant of missing paths.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
path.unlink(missing_ok=True)
|
|
110
|
+
except OSError as e:
|
|
111
|
+
raise FsError(f"failed to remove {path}", path) from e
|
dots/repo.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import enum
|
|
5
|
+
import fnmatch
|
|
6
|
+
import platform
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from configparser import ConfigParser
|
|
9
|
+
from configparser import Error as ConfigParserError
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from git import Repo as GitRepo
|
|
14
|
+
from git.exc import GitError
|
|
15
|
+
|
|
16
|
+
from dots import crypto, fs
|
|
17
|
+
from dots.crypto import AgeKeyPair
|
|
18
|
+
from dots.errors import (
|
|
19
|
+
AlreadyInRepoError,
|
|
20
|
+
ConfigError,
|
|
21
|
+
CryptoError,
|
|
22
|
+
DotsError,
|
|
23
|
+
InvalidTargetError,
|
|
24
|
+
NotInHomeError,
|
|
25
|
+
NotInRepoError,
|
|
26
|
+
RepoError,
|
|
27
|
+
)
|
|
28
|
+
from dots.ui import UI
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SyncOutcome(enum.Enum):
|
|
32
|
+
OK = "ok"
|
|
33
|
+
INSTALLED = "installed"
|
|
34
|
+
REPLACED = "replaced"
|
|
35
|
+
MISSING = "missing"
|
|
36
|
+
CONFLICT = "conflict"
|
|
37
|
+
SKIPPED = "skipped"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class DotConfig:
|
|
42
|
+
repo_dir: Path
|
|
43
|
+
ignored: tuple[str, ...]
|
|
44
|
+
age_identity: Path | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config(config_path: Path) -> DotConfig:
|
|
48
|
+
"""
|
|
49
|
+
Parse the config file and return a ``DotConfig``.
|
|
50
|
+
|
|
51
|
+
Selects a hostname-specific section if one is present, otherwise falls back
|
|
52
|
+
to ``[DEFAULT]``. Uses defaults if file is missing.
|
|
53
|
+
"""
|
|
54
|
+
cfg = ConfigParser(defaults={"repo_dir": "~/dots", "ignored_files": ""})
|
|
55
|
+
if config_path.is_file():
|
|
56
|
+
try:
|
|
57
|
+
cfg.read(config_path)
|
|
58
|
+
except ConfigParserError as e:
|
|
59
|
+
raise ConfigError(f"invalid config file {config_path}: {e}") from e
|
|
60
|
+
|
|
61
|
+
hostname = platform.node()
|
|
62
|
+
section_name = hostname if hostname in cfg else "DEFAULT"
|
|
63
|
+
section = cfg[section_name]
|
|
64
|
+
|
|
65
|
+
repo_dir_raw = section.get("repo_dir")
|
|
66
|
+
if not repo_dir_raw:
|
|
67
|
+
raise ConfigError(f"missing 'repo_dir' in config section [{section_name}]")
|
|
68
|
+
repo_dir = Path(repo_dir_raw).expanduser().resolve()
|
|
69
|
+
|
|
70
|
+
raw_ignored = section.get("ignored_files", "")
|
|
71
|
+
ignored = tuple(p.strip() for p in raw_ignored.split(",") if p.strip())
|
|
72
|
+
|
|
73
|
+
raw_age_identity = section.get("age_identity", "")
|
|
74
|
+
age_identity: Path | None = None
|
|
75
|
+
if raw_age_identity.strip():
|
|
76
|
+
age_identity = Path(raw_age_identity.strip()).expanduser().resolve()
|
|
77
|
+
|
|
78
|
+
return DotConfig(repo_dir=repo_dir, ignored=ignored, age_identity=age_identity)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class DotRepository:
|
|
83
|
+
"""
|
|
84
|
+
A dotfiles repository.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
path: Path
|
|
88
|
+
home: Path
|
|
89
|
+
ignored: tuple[str, ...]
|
|
90
|
+
git: GitRepo | None
|
|
91
|
+
ui: UI
|
|
92
|
+
age_keypair: AgeKeyPair | None = None
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def load(cls, config_path: Path, ui: UI) -> DotRepository:
|
|
96
|
+
ui.debug(f"Loading configuration from {config_path}")
|
|
97
|
+
config = load_config(config_path)
|
|
98
|
+
if not config.repo_dir.is_dir():
|
|
99
|
+
raise RepoError(f"no dots repository found at {config.repo_dir}")
|
|
100
|
+
|
|
101
|
+
git_repo: GitRepo | None = None
|
|
102
|
+
if (config.repo_dir / ".git").is_dir():
|
|
103
|
+
try:
|
|
104
|
+
git_repo = GitRepo(config.repo_dir)
|
|
105
|
+
except GitError as e:
|
|
106
|
+
ui.warning(f"could not open git repo at {config.repo_dir}: {e}")
|
|
107
|
+
|
|
108
|
+
age_keypair: AgeKeyPair | None = None
|
|
109
|
+
if config.age_identity is not None:
|
|
110
|
+
try:
|
|
111
|
+
age_keypair = crypto.load_identity(config.age_identity)
|
|
112
|
+
except CryptoError as e:
|
|
113
|
+
ui.warning(f"age identity unavailable: {e}")
|
|
114
|
+
|
|
115
|
+
return cls(
|
|
116
|
+
path=config.repo_dir,
|
|
117
|
+
home=Path.home().resolve(),
|
|
118
|
+
ignored=config.ignored,
|
|
119
|
+
git=git_repo,
|
|
120
|
+
ui=ui,
|
|
121
|
+
age_keypair=age_keypair,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def add(self, target: Path, *, encrypt: bool = False) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Move ``target`` into the repository and replace it with a symlink.
|
|
127
|
+
|
|
128
|
+
When ``encrypt`` is True the file is stored as an age-encrypted
|
|
129
|
+
``.age`` file and a decrypted working copy in ``.decrypted/`` is
|
|
130
|
+
used as the symlink target.
|
|
131
|
+
|
|
132
|
+
Safety: the file is copied into the repo first, then the symlink is
|
|
133
|
+
installed atomically. If the symlink step fails the stray copy is
|
|
134
|
+
removed - the user's file is never in an unreachable state.
|
|
135
|
+
"""
|
|
136
|
+
target = target.absolute()
|
|
137
|
+
self.ui.debug(f"Adding '{target}' to the repository...")
|
|
138
|
+
self.validate_addable(target)
|
|
139
|
+
|
|
140
|
+
if encrypt:
|
|
141
|
+
self._add_encrypted(target)
|
|
142
|
+
else:
|
|
143
|
+
self._add_plain(target)
|
|
144
|
+
|
|
145
|
+
def _add_plain(self, target: Path) -> None:
|
|
146
|
+
relpath = target.relative_to(self.home)
|
|
147
|
+
repo_file = self.path / relpath
|
|
148
|
+
|
|
149
|
+
self.ui.debug(f"Copying {target} to {repo_file}")
|
|
150
|
+
fs.atomic_copy(target, repo_file)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
self.ui.debug(f"Creating symlink at {target}")
|
|
154
|
+
fs.atomic_symlink(repo_file, target)
|
|
155
|
+
except DotsError:
|
|
156
|
+
with contextlib.suppress(DotsError):
|
|
157
|
+
fs.safe_unlink(repo_file)
|
|
158
|
+
raise
|
|
159
|
+
|
|
160
|
+
self.git_commit_safe(f"added {relpath.as_posix()}")
|
|
161
|
+
self.ui.installed(target)
|
|
162
|
+
|
|
163
|
+
def _add_encrypted(self, target: Path) -> None:
|
|
164
|
+
if self.age_keypair is None:
|
|
165
|
+
raise CryptoError("no age identity configured")
|
|
166
|
+
|
|
167
|
+
relpath = target.relative_to(self.home)
|
|
168
|
+
age_file = crypto.home_to_age_path(self.path, self.home, target)
|
|
169
|
+
decrypted_path = crypto.age_to_decrypted_path(self.path, age_file)
|
|
170
|
+
|
|
171
|
+
plaintext = target.read_bytes()
|
|
172
|
+
ciphertext = crypto.age_encrypt(plaintext, self.age_keypair.recipient)
|
|
173
|
+
|
|
174
|
+
self.ui.debug(f"Encrypting {target} to {age_file}")
|
|
175
|
+
fs.atomic_write(age_file, ciphertext)
|
|
176
|
+
|
|
177
|
+
self.ui.debug(f"Writing decrypted copy to {decrypted_path}")
|
|
178
|
+
fs.atomic_write(decrypted_path, plaintext)
|
|
179
|
+
|
|
180
|
+
self.ensure_decrypted_gitignored()
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
self.ui.debug(f"Creating symlink at {target}")
|
|
184
|
+
fs.atomic_symlink(decrypted_path, target)
|
|
185
|
+
except DotsError:
|
|
186
|
+
with contextlib.suppress(DotsError):
|
|
187
|
+
fs.safe_unlink(age_file)
|
|
188
|
+
with contextlib.suppress(DotsError):
|
|
189
|
+
fs.safe_unlink(decrypted_path)
|
|
190
|
+
raise
|
|
191
|
+
|
|
192
|
+
self.git_commit_safe(f"added {relpath.as_posix()} (encrypted)")
|
|
193
|
+
self.ui.installed(target, encrypted=True)
|
|
194
|
+
|
|
195
|
+
def remove(self, target: Path) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Restore a repository file to its original location and un-track it.
|
|
198
|
+
|
|
199
|
+
Safety: the repo content is copied back to the home location first (an
|
|
200
|
+
atomic ``os.replace`` that swaps the symlink for a regular file). Only
|
|
201
|
+
after that commit step is the repo copy deleted.
|
|
202
|
+
"""
|
|
203
|
+
target = target.absolute()
|
|
204
|
+
self.ui.debug(f"Removing '{target}' from the repository...")
|
|
205
|
+
|
|
206
|
+
repo_file = target.resolve()
|
|
207
|
+
if not fs.is_inside(repo_file, self.path):
|
|
208
|
+
raise NotInRepoError(target)
|
|
209
|
+
|
|
210
|
+
# detect encrypted files: symlink target is inside .decrypted/
|
|
211
|
+
decrypted_dir = self.path / crypto.DECRYPTED_DIR
|
|
212
|
+
if fs.is_inside(repo_file, decrypted_dir):
|
|
213
|
+
self._remove_encrypted(target, repo_file)
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
relpath = repo_file.relative_to(self.path)
|
|
217
|
+
home_file = self.home / relpath
|
|
218
|
+
|
|
219
|
+
if not home_file.is_symlink():
|
|
220
|
+
raise InvalidTargetError(f"expected symlink at {home_file}", home_file)
|
|
221
|
+
if home_file.resolve() != repo_file:
|
|
222
|
+
raise InvalidTargetError(f"{home_file} does not point to {repo_file}", home_file)
|
|
223
|
+
|
|
224
|
+
# install a regular-file copy at home_file (atomically replaces the symlink)
|
|
225
|
+
self.ui.debug(f"Restoring {repo_file} to {home_file}")
|
|
226
|
+
fs.atomic_copy(repo_file, home_file)
|
|
227
|
+
|
|
228
|
+
# drop the repo-side copy
|
|
229
|
+
self.ui.debug(f"Removing repo file {repo_file}")
|
|
230
|
+
fs.safe_unlink(repo_file)
|
|
231
|
+
|
|
232
|
+
# clean empty parent dirs in the repo
|
|
233
|
+
self.rm_empty_folders(repo_file.parent)
|
|
234
|
+
|
|
235
|
+
# commit to git
|
|
236
|
+
self.git_commit_safe(f"removed {relpath.as_posix()}")
|
|
237
|
+
self.ui.removed(target)
|
|
238
|
+
|
|
239
|
+
def _remove_encrypted(self, target: Path, decrypted_file: Path) -> None:
|
|
240
|
+
decrypted_dir = self.path / crypto.DECRYPTED_DIR
|
|
241
|
+
rel_in_decrypted = decrypted_file.relative_to(decrypted_dir)
|
|
242
|
+
age_file = self.path / (rel_in_decrypted.as_posix() + crypto.AGE_EXTENSION)
|
|
243
|
+
home_file = self.home / rel_in_decrypted
|
|
244
|
+
|
|
245
|
+
if not home_file.is_symlink():
|
|
246
|
+
raise InvalidTargetError(f"expected symlink at {home_file}", home_file)
|
|
247
|
+
if home_file.resolve() != decrypted_file:
|
|
248
|
+
raise InvalidTargetError(f"{home_file} does not point to {decrypted_file}", home_file)
|
|
249
|
+
|
|
250
|
+
# restore decrypted content to home location
|
|
251
|
+
self.ui.debug(f"Restoring {decrypted_file} to {home_file}")
|
|
252
|
+
fs.atomic_copy(decrypted_file, home_file)
|
|
253
|
+
|
|
254
|
+
# remove .age file and decrypted copy
|
|
255
|
+
self.ui.debug(f"Removing {age_file}")
|
|
256
|
+
fs.safe_unlink(age_file)
|
|
257
|
+
self.ui.debug(f"Removing {decrypted_file}")
|
|
258
|
+
fs.safe_unlink(decrypted_file)
|
|
259
|
+
|
|
260
|
+
# clean empty dirs in both repo and .decrypted
|
|
261
|
+
self.rm_empty_folders(age_file.parent)
|
|
262
|
+
self.rm_empty_folders(decrypted_file.parent)
|
|
263
|
+
|
|
264
|
+
self.git_commit_safe(f"removed {rel_in_decrypted.as_posix()} (encrypted)")
|
|
265
|
+
self.ui.removed(target, encrypted=True)
|
|
266
|
+
|
|
267
|
+
def sync(
|
|
268
|
+
self,
|
|
269
|
+
*,
|
|
270
|
+
list_only: bool = False,
|
|
271
|
+
force_relink: bool = False,
|
|
272
|
+
force_add: bool = False,
|
|
273
|
+
force_link: bool = False,
|
|
274
|
+
) -> None:
|
|
275
|
+
if not list_only:
|
|
276
|
+
self.ui.section("Syncing dotfiles", emoji="📦")
|
|
277
|
+
else:
|
|
278
|
+
self.ui.section("Listing dotfiles", emoji="📦")
|
|
279
|
+
|
|
280
|
+
changed = 0
|
|
281
|
+
unchanged = 0
|
|
282
|
+
|
|
283
|
+
for repo_file in self.iter_repo_files():
|
|
284
|
+
relpath = repo_file.relative_to(self.path)
|
|
285
|
+
if self.is_ignored(relpath):
|
|
286
|
+
self.ui.debug(f"Ignored: {relpath.as_posix()}")
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
if crypto.is_age_file(repo_file):
|
|
290
|
+
outcome = self.sync_one_encrypted(
|
|
291
|
+
repo_file=repo_file,
|
|
292
|
+
list_only=list_only,
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
link_path = self.home / relpath
|
|
296
|
+
outcome = self.sync_one(
|
|
297
|
+
repo_file=repo_file,
|
|
298
|
+
link_path=link_path,
|
|
299
|
+
list_only=list_only,
|
|
300
|
+
force_relink=force_relink,
|
|
301
|
+
force_add=force_add,
|
|
302
|
+
force_link=force_link,
|
|
303
|
+
)
|
|
304
|
+
if outcome == SyncOutcome.OK:
|
|
305
|
+
unchanged += 1
|
|
306
|
+
elif outcome in (SyncOutcome.INSTALLED, SyncOutcome.REPLACED):
|
|
307
|
+
changed += 1
|
|
308
|
+
|
|
309
|
+
self.ui.summary(changed=changed, unchanged=unchanged)
|
|
310
|
+
|
|
311
|
+
def validate_addable(self, target: Path) -> None:
|
|
312
|
+
if target.is_symlink():
|
|
313
|
+
if fs.is_inside(target.resolve(), self.path):
|
|
314
|
+
raise AlreadyInRepoError(target)
|
|
315
|
+
raise InvalidTargetError(f"cannot add a symlink: {target}", target)
|
|
316
|
+
if not target.exists():
|
|
317
|
+
raise InvalidTargetError(f"file not found: {target}", target)
|
|
318
|
+
if not target.is_file():
|
|
319
|
+
raise InvalidTargetError(f"not a regular file: {target}", target)
|
|
320
|
+
if not fs.is_inside(target, self.home):
|
|
321
|
+
raise NotInHomeError(target, self.home)
|
|
322
|
+
if fs.is_inside(target, self.path):
|
|
323
|
+
raise InvalidTargetError(f"file is already inside the repository: {target}", target)
|
|
324
|
+
|
|
325
|
+
def iter_repo_files(self) -> Iterator[Path]:
|
|
326
|
+
"""
|
|
327
|
+
Yield every regular file under ``self.path``, skipping ``.git``
|
|
328
|
+
and ``.decrypted``.
|
|
329
|
+
"""
|
|
330
|
+
for p in sorted(self.path.rglob("*")):
|
|
331
|
+
if not p.is_file():
|
|
332
|
+
continue
|
|
333
|
+
parts = p.relative_to(self.path).parts
|
|
334
|
+
if ".git" in parts:
|
|
335
|
+
continue
|
|
336
|
+
if parts[0] == crypto.DECRYPTED_DIR:
|
|
337
|
+
continue
|
|
338
|
+
yield p
|
|
339
|
+
|
|
340
|
+
def is_ignored(self, relpath: Path) -> bool:
|
|
341
|
+
"""
|
|
342
|
+
Match ``relpath`` (repo-relative) against the ignore patterns.
|
|
343
|
+
|
|
344
|
+
Patterns starting with ``/`` are anchored to the repo root and match
|
|
345
|
+
the full relative path. All other patterns match the filename only.
|
|
346
|
+
"""
|
|
347
|
+
rel_str = "/" + relpath.as_posix()
|
|
348
|
+
for pattern in self.ignored:
|
|
349
|
+
match_target = rel_str if pattern.startswith("/") else relpath.name
|
|
350
|
+
if fnmatch.fnmatch(match_target, pattern):
|
|
351
|
+
return True
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
def sync_one(
|
|
355
|
+
self,
|
|
356
|
+
*,
|
|
357
|
+
repo_file: Path,
|
|
358
|
+
link_path: Path,
|
|
359
|
+
list_only: bool,
|
|
360
|
+
force_relink: bool,
|
|
361
|
+
force_add: bool,
|
|
362
|
+
force_link: bool,
|
|
363
|
+
) -> SyncOutcome:
|
|
364
|
+
if not (link_path.is_symlink() or link_path.exists()):
|
|
365
|
+
if list_only:
|
|
366
|
+
self.ui.missing(link_path)
|
|
367
|
+
return SyncOutcome.MISSING
|
|
368
|
+
fs.atomic_symlink(repo_file, link_path)
|
|
369
|
+
self.ui.installed(link_path)
|
|
370
|
+
return SyncOutcome.INSTALLED
|
|
371
|
+
|
|
372
|
+
if link_path.is_symlink():
|
|
373
|
+
link_target = link_path.resolve()
|
|
374
|
+
if link_target == repo_file:
|
|
375
|
+
self.ui.ok(link_path)
|
|
376
|
+
return SyncOutcome.OK
|
|
377
|
+
state = "valid" if link_target.exists() else "broken"
|
|
378
|
+
self.ui.conflict(link_path, target=link_target, reason=f"{state} link")
|
|
379
|
+
if list_only:
|
|
380
|
+
return SyncOutcome.CONFLICT
|
|
381
|
+
if not force_relink and not self.ui.ask_yesno("Overwrite existing link?", default=False):
|
|
382
|
+
return SyncOutcome.SKIPPED
|
|
383
|
+
fs.atomic_symlink(repo_file, link_path)
|
|
384
|
+
self.ui.replaced(link_path)
|
|
385
|
+
return SyncOutcome.REPLACED
|
|
386
|
+
|
|
387
|
+
self.ui.conflict(link_path, reason="file exists")
|
|
388
|
+
if list_only:
|
|
389
|
+
return SyncOutcome.CONFLICT
|
|
390
|
+
if force_add:
|
|
391
|
+
self.force_add(repo_file, link_path)
|
|
392
|
+
return SyncOutcome.REPLACED
|
|
393
|
+
if force_link:
|
|
394
|
+
self.force_link(repo_file, link_path)
|
|
395
|
+
return SyncOutcome.REPLACED
|
|
396
|
+
if self.ui.ask_yesno("Replace repository file?", default=False):
|
|
397
|
+
self.force_add(repo_file, link_path)
|
|
398
|
+
return SyncOutcome.REPLACED
|
|
399
|
+
if self.ui.ask_yesno("Replace local file?", default=False):
|
|
400
|
+
self.force_link(repo_file, link_path)
|
|
401
|
+
return SyncOutcome.REPLACED
|
|
402
|
+
return SyncOutcome.SKIPPED
|
|
403
|
+
|
|
404
|
+
def force_add(self, repo_file: Path, link_path: Path) -> None:
|
|
405
|
+
"""
|
|
406
|
+
Promote the user's local file to become the new repo version.
|
|
407
|
+
"""
|
|
408
|
+
self.ui.debug(f"Force-add: {link_path} -> {repo_file}")
|
|
409
|
+
fs.atomic_copy(link_path, repo_file)
|
|
410
|
+
fs.atomic_symlink(repo_file, link_path)
|
|
411
|
+
self.git_commit_safe(f"force-updated {repo_file.relative_to(self.path).as_posix()}")
|
|
412
|
+
self.ui.replaced(repo_file)
|
|
413
|
+
|
|
414
|
+
def force_link(self, repo_file: Path, link_path: Path) -> None:
|
|
415
|
+
"""
|
|
416
|
+
Replace the user's local file with a symlink pointing at the repo.
|
|
417
|
+
"""
|
|
418
|
+
self.ui.debug(f"Force-link: {link_path} -> {repo_file}")
|
|
419
|
+
fs.atomic_symlink(repo_file, link_path)
|
|
420
|
+
self.ui.replaced(link_path)
|
|
421
|
+
|
|
422
|
+
def sync_one_encrypted(
|
|
423
|
+
self,
|
|
424
|
+
*,
|
|
425
|
+
repo_file: Path,
|
|
426
|
+
list_only: bool,
|
|
427
|
+
) -> SyncOutcome:
|
|
428
|
+
if self.age_keypair is None:
|
|
429
|
+
self.ui.warning(f"skipping encrypted file (no age key): {repo_file.name}")
|
|
430
|
+
return SyncOutcome.SKIPPED
|
|
431
|
+
|
|
432
|
+
decrypted_path = crypto.age_to_decrypted_path(self.path, repo_file)
|
|
433
|
+
home_path = crypto.age_to_home_path(self.path, self.home, repo_file)
|
|
434
|
+
|
|
435
|
+
if not decrypted_path.exists():
|
|
436
|
+
if list_only:
|
|
437
|
+
self.ui.missing(home_path, encrypted=True)
|
|
438
|
+
return SyncOutcome.MISSING
|
|
439
|
+
ciphertext = repo_file.read_bytes()
|
|
440
|
+
plaintext = crypto.age_decrypt(ciphertext, self.age_keypair.identity)
|
|
441
|
+
fs.atomic_write(decrypted_path, plaintext)
|
|
442
|
+
self.ensure_decrypted_gitignored()
|
|
443
|
+
fs.atomic_symlink(decrypted_path, home_path)
|
|
444
|
+
self.ui.installed(home_path, encrypted=True)
|
|
445
|
+
return SyncOutcome.INSTALLED
|
|
446
|
+
|
|
447
|
+
if not list_only:
|
|
448
|
+
# integrity check: has the user modified the decrypted copy?
|
|
449
|
+
ciphertext = repo_file.read_bytes()
|
|
450
|
+
repo_plaintext = crypto.age_decrypt(ciphertext, self.age_keypair.identity)
|
|
451
|
+
disk_plaintext = decrypted_path.read_bytes()
|
|
452
|
+
|
|
453
|
+
if crypto.content_hash(repo_plaintext) != crypto.content_hash(disk_plaintext):
|
|
454
|
+
new_ciphertext = crypto.age_encrypt(disk_plaintext, self.age_keypair.recipient)
|
|
455
|
+
fs.atomic_write(repo_file, new_ciphertext)
|
|
456
|
+
relpath = repo_file.relative_to(self.path)
|
|
457
|
+
self.git_commit_safe(f"re-encrypted {relpath.as_posix()}")
|
|
458
|
+
self.ui.replaced(home_path, encrypted=True)
|
|
459
|
+
return SyncOutcome.REPLACED
|
|
460
|
+
|
|
461
|
+
# ensure symlink is correct
|
|
462
|
+
if home_path.is_symlink() and home_path.resolve() == decrypted_path.resolve():
|
|
463
|
+
self.ui.ok(home_path, encrypted=True)
|
|
464
|
+
return SyncOutcome.OK
|
|
465
|
+
|
|
466
|
+
if list_only:
|
|
467
|
+
self.ui.missing(home_path, encrypted=True)
|
|
468
|
+
return SyncOutcome.MISSING
|
|
469
|
+
|
|
470
|
+
self.ensure_decrypted_gitignored()
|
|
471
|
+
fs.atomic_symlink(decrypted_path, home_path)
|
|
472
|
+
self.ui.installed(home_path, encrypted=True)
|
|
473
|
+
return SyncOutcome.INSTALLED
|
|
474
|
+
|
|
475
|
+
def ensure_decrypted_gitignored(self) -> None:
|
|
476
|
+
gitignore = self.path / ".gitignore"
|
|
477
|
+
marker = f"/{crypto.DECRYPTED_DIR}/"
|
|
478
|
+
if gitignore.is_file():
|
|
479
|
+
content = gitignore.read_text()
|
|
480
|
+
if marker in content:
|
|
481
|
+
return
|
|
482
|
+
if not content.endswith("\n"):
|
|
483
|
+
content += "\n"
|
|
484
|
+
content += f"{marker}\n"
|
|
485
|
+
else:
|
|
486
|
+
content = f"{marker}\n"
|
|
487
|
+
fs.atomic_write(gitignore, content.encode())
|
|
488
|
+
|
|
489
|
+
def rm_empty_folders(self, leaf: Path) -> None:
|
|
490
|
+
"""
|
|
491
|
+
Iteratively delete empty directories up to ``self.path`` (exclusive).
|
|
492
|
+
"""
|
|
493
|
+
while leaf != self.path and fs.is_inside(leaf, self.path):
|
|
494
|
+
try:
|
|
495
|
+
if any(leaf.iterdir()):
|
|
496
|
+
return
|
|
497
|
+
except OSError:
|
|
498
|
+
return
|
|
499
|
+
if not self.ui.ask_yesno(f"Delete empty folder '{leaf}'?", default=True):
|
|
500
|
+
return
|
|
501
|
+
self.ui.debug(f"Deleting empty folder: {leaf}")
|
|
502
|
+
try:
|
|
503
|
+
leaf.rmdir()
|
|
504
|
+
except OSError as e:
|
|
505
|
+
self.ui.warning(f"Failed to delete {leaf}: {e}")
|
|
506
|
+
return
|
|
507
|
+
leaf = leaf.parent
|
|
508
|
+
|
|
509
|
+
def git_commit_safe(self, msg: str) -> None:
|
|
510
|
+
"""
|
|
511
|
+
Commit all repo changes - non-fatal on failure.
|
|
512
|
+
"""
|
|
513
|
+
if self.git is None:
|
|
514
|
+
return
|
|
515
|
+
try:
|
|
516
|
+
self.git.git.add(all=True)
|
|
517
|
+
self.git.git.commit(message=f"[dots] {msg}")
|
|
518
|
+
except GitError as e:
|
|
519
|
+
self.ui.warning(f"git commit failed (filesystem change was applied): {e}")
|
dots/ui.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.markup import escape
|
|
10
|
+
from rich.theme import Theme
|
|
11
|
+
|
|
12
|
+
THEME = Theme(
|
|
13
|
+
{
|
|
14
|
+
"ok": "green",
|
|
15
|
+
"add": "bold green",
|
|
16
|
+
"replace": "cyan",
|
|
17
|
+
"missing": "red",
|
|
18
|
+
"conflict": "yellow",
|
|
19
|
+
"arrow": "dim",
|
|
20
|
+
"debug": "dim magenta",
|
|
21
|
+
"error": "bold red",
|
|
22
|
+
"meta": "dim",
|
|
23
|
+
"section": "bold",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_console(*, stderr: bool) -> Console:
|
|
29
|
+
return Console(theme=THEME, stderr=stderr, highlight=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class UI:
|
|
34
|
+
verbose: bool = False
|
|
35
|
+
out: Console = field(init=False, repr=False, default_factory=lambda: make_console(stderr=False))
|
|
36
|
+
err: Console = field(init=False, repr=False, default_factory=lambda: make_console(stderr=True))
|
|
37
|
+
|
|
38
|
+
# lower-level (free-form messages)
|
|
39
|
+
|
|
40
|
+
def debug(self, msg: str) -> None:
|
|
41
|
+
if self.verbose:
|
|
42
|
+
self.out.print(f" [debug]•[/] [meta]{escape(msg)}[/]")
|
|
43
|
+
|
|
44
|
+
def info(self, msg: str) -> None:
|
|
45
|
+
self.out.print(escape(msg))
|
|
46
|
+
|
|
47
|
+
def notice(self, msg: str) -> None:
|
|
48
|
+
self.out.print(f"[bold]{escape(msg)}[/]")
|
|
49
|
+
|
|
50
|
+
def warning(self, msg: str) -> None:
|
|
51
|
+
self.err.print(f" [conflict]![/] {escape(msg)}")
|
|
52
|
+
|
|
53
|
+
def error(self, msg: str) -> None:
|
|
54
|
+
self.err.print(f"[error]✗ {escape(msg)}[/]")
|
|
55
|
+
|
|
56
|
+
# semantic helpers
|
|
57
|
+
|
|
58
|
+
def ok(self, path: Path, *, encrypted: bool = False) -> None:
|
|
59
|
+
tag = " [meta](encrypted)[/]" if encrypted else ""
|
|
60
|
+
self.out.print(f" [ok]✓[/] {escape(str(path))}{tag}")
|
|
61
|
+
|
|
62
|
+
def installed(self, path: Path, *, encrypted: bool = False) -> None:
|
|
63
|
+
tag = " [meta](encrypted)[/]" if encrypted else ""
|
|
64
|
+
self.out.print(f" [add]+[/] [bold]{escape(str(path))}[/]{tag}")
|
|
65
|
+
|
|
66
|
+
def replaced(self, path: Path, *, encrypted: bool = False) -> None:
|
|
67
|
+
tag = " [meta](encrypted)[/]" if encrypted else ""
|
|
68
|
+
self.out.print(f" [replace]↻[/] {escape(str(path))}{tag}")
|
|
69
|
+
|
|
70
|
+
def missing(self, path: Path, *, encrypted: bool = False) -> None:
|
|
71
|
+
extra = ", encrypted" if encrypted else ""
|
|
72
|
+
self.out.print(f" [missing]✗[/] {escape(str(path))} [meta](missing{extra})[/]")
|
|
73
|
+
|
|
74
|
+
def removed(self, path: Path, *, encrypted: bool = False) -> None:
|
|
75
|
+
tag = " [meta](encrypted)[/]" if encrypted else ""
|
|
76
|
+
self.out.print(f" [missing]-[/] {escape(str(path))}{tag}")
|
|
77
|
+
|
|
78
|
+
def conflict(self, path: Path, *, target: Path | None = None, reason: str = "") -> None:
|
|
79
|
+
parts = [f" [conflict]![/] {escape(str(path))}"]
|
|
80
|
+
if target is not None:
|
|
81
|
+
parts.append(f" [arrow]→[/] [meta]{escape(str(target))}[/]")
|
|
82
|
+
if reason:
|
|
83
|
+
parts.append(f" [meta]({escape(reason)})[/]")
|
|
84
|
+
self.err.print("".join(parts))
|
|
85
|
+
|
|
86
|
+
# formatting helpers
|
|
87
|
+
|
|
88
|
+
def section(self, title: str, *, emoji: str = "") -> None:
|
|
89
|
+
prefix = f"{emoji} " if emoji else ""
|
|
90
|
+
self.out.print(f"\n{prefix}[section]{escape(title)}[/]\n")
|
|
91
|
+
|
|
92
|
+
def summary(self, *, changed: int, unchanged: int) -> None:
|
|
93
|
+
if changed == 0:
|
|
94
|
+
self.out.print(f"\n[meta]{unchanged} unchanged[/]")
|
|
95
|
+
return
|
|
96
|
+
self.out.print(f"\n🎉 [bold]{changed}[/] changed, [meta]{unchanged} unchanged[/]")
|
|
97
|
+
|
|
98
|
+
# prompts
|
|
99
|
+
|
|
100
|
+
def ask_yesno(self, prompt: str, *, default: bool = False) -> bool:
|
|
101
|
+
if not sys.stdin.isatty():
|
|
102
|
+
return default
|
|
103
|
+
return typer.confirm(prompt, default=default)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotsman
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Configuration files management tool
|
|
5
|
+
Project-URL: Repository, https://github.com/mdeous/dots
|
|
6
|
+
Author-email: Mathieu Deous <mat.deous@gmail.com>
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: gitpython>=3.1.46
|
|
11
|
+
Requires-Dist: pyrage>=1.3.0
|
|
12
|
+
Requires-Dist: rich>=15.0.0
|
|
13
|
+
Requires-Dist: typer>=0.24.1
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# dots
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+

|
|
20
|
+
[](https://github.com/mdeous/dots/actions/workflows/ci.yml)
|
|
21
|
+
[](https://github.com/mdeous/dots/actions/workflows/github-code-scanning/codeql)
|
|
22
|
+
|
|
23
|
+
Yet another dotfiles management tool.
|
|
24
|
+
|
|
25
|
+
## :sparkles: Features
|
|
26
|
+
|
|
27
|
+
- :link: Dotfiles stored in a central folder and symlinked to their real location
|
|
28
|
+
- :repeat: Automatic git versioning on every change
|
|
29
|
+
- :computer: Per-machine configuration via hostname-based config sections
|
|
30
|
+
- :warning: Conflict detection and resolution during sync
|
|
31
|
+
- :lock: Encryption for sensitive files
|
|
32
|
+
|
|
33
|
+
## :clipboard: Requirements
|
|
34
|
+
|
|
35
|
+
- Python 3.12+
|
|
36
|
+
- git
|
|
37
|
+
|
|
38
|
+
## :package: Installation
|
|
39
|
+
|
|
40
|
+
Clone the repository and install with [uv](https://docs.astral.sh/uv/):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/mdeous/dots.git
|
|
44
|
+
cd dots
|
|
45
|
+
uv sync
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or with pip:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/mdeous/dots.git
|
|
52
|
+
cd dots
|
|
53
|
+
pip install .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## :gear: Configuration
|
|
57
|
+
|
|
58
|
+
The configuration file should be located at `~/.dots.conf`.
|
|
59
|
+
|
|
60
|
+
Settings are organized in sections named after the machine's hostname. The `[DEFAULT]` section provides fallback values used when no hostname-specific section exists.
|
|
61
|
+
|
|
62
|
+
### Available settings
|
|
63
|
+
|
|
64
|
+
| Key | Description | Default |
|
|
65
|
+
| --------------- | ------------------------------------------------------------------------- | -------- |
|
|
66
|
+
| `repo_dir` | Path to the dotfiles repository | `~/dots` |
|
|
67
|
+
| `age_identity` | Path to an [age](https://age-encryption.org) identity file for encryption | _none_ |
|
|
68
|
+
| `ignored_files` | Comma-separated list of glob patterns to skip during sync | _none_ |
|
|
69
|
+
|
|
70
|
+
### Example
|
|
71
|
+
|
|
72
|
+
```ini
|
|
73
|
+
[DEFAULT]
|
|
74
|
+
repo_dir = ~/dots
|
|
75
|
+
|
|
76
|
+
[work-laptop]
|
|
77
|
+
repo_dir = ~/dotfiles
|
|
78
|
+
ignored_files = .bashrc, .config/personal/*
|
|
79
|
+
|
|
80
|
+
[home-desktop]
|
|
81
|
+
age_identity = ~/.age/dots.key
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## :rocket: Usage
|
|
85
|
+
|
|
86
|
+
### Global options
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
dots [--config PATH] [--verbose] [--version] COMMAND
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
| Option | Short | Description |
|
|
93
|
+
| ----------- | ----- | --------------------------------------------- |
|
|
94
|
+
| `--config` | `-c` | Path to config file (default: `~/.dots.conf`) |
|
|
95
|
+
| `--verbose` | `-v` | Display debug information |
|
|
96
|
+
| `--version` | `-V` | Display version and exit |
|
|
97
|
+
|
|
98
|
+
### `dots add <file>`
|
|
99
|
+
|
|
100
|
+
Add a file to the repository. The file is copied into the repo and replaced with a symlink.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
dots add ~/.bashrc
|
|
104
|
+
dots add ~/.config/git/config
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Option | Short | Description |
|
|
108
|
+
| ----------- | ----- | ------------------------------------------------ |
|
|
109
|
+
| `--encrypt` | `-e` | Encrypt the file with age before storing in repo |
|
|
110
|
+
|
|
111
|
+
When `--encrypt` is used, the file is stored as a `.age` file in the repo (encrypted at rest), with a decrypted working copy in a gitignored `.decrypted/` directory. The symlink points to the decrypted copy so the file remains usable. Requires `age_identity` to be set in the config.
|
|
112
|
+
|
|
113
|
+
### `dots remove <file>`
|
|
114
|
+
|
|
115
|
+
Remove a file from the repository. The symlink is replaced with the original file.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
dots remove ~/.bashrc
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `dots list`
|
|
122
|
+
|
|
123
|
+
List all files in the repository and their sync status.
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
dots list
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `dots sync`
|
|
130
|
+
|
|
131
|
+
Synchronize the repository with the filesystem. Creates missing symlinks and detects conflicts.
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
dots sync
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Option | Short | Description |
|
|
138
|
+
| ---------------- | ----- | ---------------------------------------------- |
|
|
139
|
+
| `--force-relink` | `-r` | Overwrite links that point to the wrong target |
|
|
140
|
+
| `--force-add` | `-a` | Overwrite the repo version with the local file |
|
|
141
|
+
| `--force-link` | `-l` | Overwrite the local file with the repo version |
|
|
142
|
+
|
|
143
|
+
`--force-add` and `--force-link` are mutually exclusive. Without force flags, `dots` prompts interactively when conflicts are found.
|
|
144
|
+
|
|
145
|
+
## :lock: Encryption
|
|
146
|
+
|
|
147
|
+
Sensitive dotfiles can be encrypted at rest using [age](https://age-encryption.org). Encrypted files are safe to push to public repositories.
|
|
148
|
+
|
|
149
|
+
### Setup
|
|
150
|
+
|
|
151
|
+
1. Generate an age identity:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
age-keygen -o ~/.age/dots.key
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
2. Add the identity path to your config (`~/.dots.conf`):
|
|
158
|
+
|
|
159
|
+
```ini
|
|
160
|
+
[DEFAULT]
|
|
161
|
+
age_identity = ~/.age/dots.key
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
3. Add files with encryption:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
dots add --encrypt ~/.config/secrets.env
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### How it works
|
|
171
|
+
|
|
172
|
+
- Encrypted files are stored in the repo with a `.age` extension (e.g., `secrets.env.age`)
|
|
173
|
+
- Decrypted working copies live in a gitignored `.decrypted/` directory inside the repo
|
|
174
|
+
- Symlinks point to the decrypted copies, so files are usable as normal
|
|
175
|
+
- `dots sync` detects changes to decrypted files and re-encrypts them automatically
|
|
176
|
+
- On a new machine, `dots sync` decrypts all `.age` files and creates symlinks
|
|
177
|
+
- If the age identity is not configured, encrypted files are skipped with a warning during sync
|
|
178
|
+
|
|
179
|
+
## :scroll: License
|
|
180
|
+
|
|
181
|
+
BSD 3-Clause. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
dots/__init__.py,sha256=lzGFsymf0DtA_1oAZcPbeQ557iY-1BRkekhfA2qaFh8,18
|
|
2
|
+
dots/cli.py,sha256=nJDTzy5yuO_EUMYKhiJmRc3izKoZ39VttK1-7CfSA4Q,4217
|
|
3
|
+
dots/crypto.py,sha256=oHPEu_X_IiQv4X8Um3hEi5FqHWCo68jU4-J-ZPLuo4Y,2888
|
|
4
|
+
dots/errors.py,sha256=WpNSiB5SlQbBxMH8k8o7vgn9x6KUxCnjG8JeB5x4_qc,1777
|
|
5
|
+
dots/fs.py,sha256=aH297gyvo0OxuZ5a7u82x5Uu-juYDUJvVkYM8NP_Lcs,3229
|
|
6
|
+
dots/repo.py,sha256=wvH1cJ4ikHCJfSkdaljwGix_ANlUx0ZrQGFmNlHJw7Q,18966
|
|
7
|
+
dots/ui.py,sha256=K9wwx57yYsxzHUA8r5Y_uEzH5JsFLOYOfyvA5zZoQMI,3501
|
|
8
|
+
dotsman-1.0.0.dist-info/METADATA,sha256=wS0lN_1dt-CSoOal6INVTfi71PF2kNN_qKrY36lToRY,5799
|
|
9
|
+
dotsman-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
dotsman-1.0.0.dist-info/entry_points.txt,sha256=NzIsHc5V1yvSAgw1O1ROrfjpgQOn5DYojoYJtfVOqQ0,39
|
|
11
|
+
dotsman-1.0.0.dist-info/licenses/LICENSE,sha256=HA9_W45keV-IlxU3Lhrgf01OFERlx0GbFSS3UU053mQ,1468
|
|
12
|
+
dotsman-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Copyright (c) 2017 Mathieu Deous
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
|
5
|
+
modification, are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
* Redistributions of source code must retain the above copyright
|
|
8
|
+
notice, this list of conditions and the following disclaimer.
|
|
9
|
+
|
|
10
|
+
* Redistributions in binary form must reproduce the above copyright
|
|
11
|
+
notice, this list of conditions and the following disclaimer in the
|
|
12
|
+
documentation and/or other materials provided with the distribution.
|
|
13
|
+
|
|
14
|
+
* Neither the name of Mathieu Deous nor the names of the project contributors
|
|
15
|
+
may be used to endorse or promote products derived from this software
|
|
16
|
+
without specific prior written permission.
|
|
17
|
+
|
|
18
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
19
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
20
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
21
|
+
DISCLAIMED. IN NO EVENT SHALL MATHIEU DEOUS BE LIABLE FOR ANY
|
|
22
|
+
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
23
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
24
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
|
25
|
+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
26
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
27
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|