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 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
+ ![Python](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fmdeous%2Fdots%2Fmaster%2Fpyproject.toml)
19
+ ![License](https://img.shields.io/github/license/mdeous/dots)
20
+ [![CI](https://github.com/mdeous/dots/actions/workflows/ci.yml/badge.svg)](https://github.com/mdeous/dots/actions/workflows/ci.yml)
21
+ [![CodeQL](https://github.com/mdeous/dots/actions/workflows/github-code-scanning/codeql/badge.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dots = dots.cli:main
@@ -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.