dotman-git 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.
- dot_man/__init__.py +4 -0
- dot_man/backups.py +211 -0
- dot_man/branch_ops.py +347 -0
- dot_man/cli/__init__.py +113 -0
- dot_man/cli/add_cmd.py +167 -0
- dot_man/cli/audit_cmd.py +141 -0
- dot_man/cli/backup_cmd.py +105 -0
- dot_man/cli/branch_cmd.py +103 -0
- dot_man/cli/clean_cmd.py +97 -0
- dot_man/cli/common.py +548 -0
- dot_man/cli/completions_cmd.py +127 -0
- dot_man/cli/config_cmd.py +979 -0
- dot_man/cli/deploy_cmd.py +169 -0
- dot_man/cli/discover_cmd.py +105 -0
- dot_man/cli/doctor_cmd.py +229 -0
- dot_man/cli/edit_cmd.py +177 -0
- dot_man/cli/encrypt_cmd.py +205 -0
- dot_man/cli/export_cmd.py +146 -0
- dot_man/cli/import_cmd.py +315 -0
- dot_man/cli/init_cmd.py +532 -0
- dot_man/cli/interface.py +56 -0
- dot_man/cli/log_cmd.py +339 -0
- dot_man/cli/main.py +36 -0
- dot_man/cli/navigate_cmd.py +903 -0
- dot_man/cli/onboarding.py +546 -0
- dot_man/cli/profile_cmd.py +313 -0
- dot_man/cli/remote_cmd.py +454 -0
- dot_man/cli/restore_cmd.py +82 -0
- dot_man/cli/revert_cmd.py +86 -0
- dot_man/cli/show_cmd.py +29 -0
- dot_man/cli/status_cmd.py +185 -0
- dot_man/cli/switch_cmd.py +387 -0
- dot_man/cli/tag_cmd.py +164 -0
- dot_man/cli/template_cmd.py +244 -0
- dot_man/cli/tui_cmd.py +44 -0
- dot_man/cli/verify_cmd.py +156 -0
- dot_man/completions/_dot-man.zsh +28 -0
- dot_man/completions/dot-man.bash +15 -0
- dot_man/completions/dot-man.fish +58 -0
- dot_man/completions/install.sh +26 -0
- dot_man/config.py +23 -0
- dot_man/config_detector.py +426 -0
- dot_man/constants.py +109 -0
- dot_man/core.py +614 -0
- dot_man/dotman_config.py +516 -0
- dot_man/encryption.py +173 -0
- dot_man/exceptions.py +255 -0
- dot_man/files.py +443 -0
- dot_man/global_config.py +305 -0
- dot_man/hooks.py +232 -0
- dot_man/interactive.py +460 -0
- dot_man/lock.py +64 -0
- dot_man/merge.py +440 -0
- dot_man/operations.py +212 -0
- dot_man/py.typed +1 -0
- dot_man/save_deploy_ops.py +466 -0
- dot_man/secrets.py +473 -0
- dot_man/section.py +207 -0
- dot_man/status_ops.py +229 -0
- dot_man/tui_log.py +91 -0
- dot_man/ui.py +127 -0
- dot_man/utils.py +132 -0
- dot_man/vault.py +317 -0
- dotman_git-1.0.0.dist-info/METADATA +678 -0
- dotman_git-1.0.0.dist-info/RECORD +69 -0
- dotman_git-1.0.0.dist-info/WHEEL +5 -0
- dotman_git-1.0.0.dist-info/entry_points.txt +3 -0
- dotman_git-1.0.0.dist-info/licenses/LICENSE +21 -0
- dotman_git-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Encryption command for encrypting/decrypting sensitive dotfiles."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .. import ui
|
|
9
|
+
from ..encryption import (
|
|
10
|
+
EncryptionError,
|
|
11
|
+
EncryptionManager,
|
|
12
|
+
detect_available_encryption,
|
|
13
|
+
)
|
|
14
|
+
from .common import error, require_init, success, warn
|
|
15
|
+
from .interface import cli as main
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@main.command("encrypt")
|
|
19
|
+
@click.argument("action", type=click.Choice(["encrypt", "decrypt", "status"]))
|
|
20
|
+
@click.argument("section", required=False)
|
|
21
|
+
@click.option(
|
|
22
|
+
"--method",
|
|
23
|
+
"-m",
|
|
24
|
+
type=click.Choice(["gpg", "age"]),
|
|
25
|
+
default="gpg",
|
|
26
|
+
help="Encryption method to use",
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--recipient",
|
|
30
|
+
"-r",
|
|
31
|
+
help="Encryption recipient (GPG key ID or AGE recipient)",
|
|
32
|
+
)
|
|
33
|
+
@require_init
|
|
34
|
+
def encrypt_cmd(action: str, section: str | None, method: str, recipient: str | None):
|
|
35
|
+
"""Encrypt or decrypt sensitive dotfile sections.
|
|
36
|
+
|
|
37
|
+
Actions:
|
|
38
|
+
encrypt - Encrypt a section's files
|
|
39
|
+
decrypt - Decrypt a section's files
|
|
40
|
+
status - Show encryption status of all sections
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
dot-man encrypt status # Show encryption status
|
|
44
|
+
dot-man encrypt encrypt ssh-config # Encrypt ssh-config section
|
|
45
|
+
dot-man encrypt decrypt ssh-config # Decrypt ssh-config section
|
|
46
|
+
dot-man encrypt encrypt secrets -r age1... # Use AGE encryption
|
|
47
|
+
"""
|
|
48
|
+
available = detect_available_encryption()
|
|
49
|
+
if not available:
|
|
50
|
+
error(
|
|
51
|
+
"No encryption tools available. Install GPG or AGE:\n"
|
|
52
|
+
" GPG: brew install gpg\n"
|
|
53
|
+
" AGE: brew install age",
|
|
54
|
+
exit_code=1,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if method not in available:
|
|
58
|
+
warn(f"{method} not available. Using {available[0]} instead.")
|
|
59
|
+
method = available[0] # type: ignore[assignment]
|
|
60
|
+
|
|
61
|
+
if action == "status":
|
|
62
|
+
_show_encryption_status()
|
|
63
|
+
elif action == "encrypt":
|
|
64
|
+
_encrypt_section(section, method, recipient) # type: ignore[arg-type]
|
|
65
|
+
elif action == "decrypt":
|
|
66
|
+
_decrypt_section(section, method, recipient) # type: ignore[arg-type]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _show_encryption_status():
|
|
70
|
+
"""Show encryption status of all sections."""
|
|
71
|
+
from ..operations import get_operations
|
|
72
|
+
|
|
73
|
+
ops = get_operations()
|
|
74
|
+
|
|
75
|
+
ui.console.print("[bold]Encryption Status:[/bold]")
|
|
76
|
+
ui.console.print()
|
|
77
|
+
|
|
78
|
+
has_encrypted = False
|
|
79
|
+
|
|
80
|
+
for section_name in ops.get_sections():
|
|
81
|
+
section = ops.get_section(section_name)
|
|
82
|
+
if section.encrypted:
|
|
83
|
+
has_encrypted = True
|
|
84
|
+
ui.console.print(f" [green]✓[/green] [{section_name}]")
|
|
85
|
+
ui.console.print(f" Method: {section.encryption_method}")
|
|
86
|
+
if section.encryption_recipient:
|
|
87
|
+
ui.console.print(f" Recipient: {section.encryption_recipient}")
|
|
88
|
+
else:
|
|
89
|
+
ui.console.print(f" [dim]-[/dim] [{section_name}] (not encrypted)")
|
|
90
|
+
|
|
91
|
+
if not has_encrypted:
|
|
92
|
+
ui.console.print("[dim]No encrypted sections configured[/dim]")
|
|
93
|
+
ui.console.print()
|
|
94
|
+
ui.console.print("To encrypt a section, add to dot-man.toml:")
|
|
95
|
+
ui.console.print(" [cyan][ssh-config][/cyan]")
|
|
96
|
+
ui.console.print(" [cyan]encrypted = true[/cyan]")
|
|
97
|
+
ui.console.print(' [cyan]encryption_method = "gpg"[/cyan]')
|
|
98
|
+
ui.console.print(' [cyan]encryption_recipient = "your@email.com"[/cyan]')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _encrypt_section(
|
|
102
|
+
section_name: str | None, method: Literal["gpg", "age"], recipient: str | None
|
|
103
|
+
):
|
|
104
|
+
"""Encrypt a section's files."""
|
|
105
|
+
if not section_name:
|
|
106
|
+
error("Section name required for encryption", exit_code=1)
|
|
107
|
+
|
|
108
|
+
from ..dotman_config import DotManConfig
|
|
109
|
+
from ..operations import get_operations
|
|
110
|
+
|
|
111
|
+
ops = get_operations()
|
|
112
|
+
config = DotManConfig()
|
|
113
|
+
|
|
114
|
+
assert section_name is not None
|
|
115
|
+
section = ops.get_section(section_name)
|
|
116
|
+
if not section:
|
|
117
|
+
error(f"Section not found: {section_name}", exit_code=1)
|
|
118
|
+
|
|
119
|
+
if recipient is None:
|
|
120
|
+
if section.encryption_recipient:
|
|
121
|
+
recipient = section.encryption_recipient
|
|
122
|
+
else:
|
|
123
|
+
error(
|
|
124
|
+
"No recipient specified. Use --recipient or set encryption_recipient in config",
|
|
125
|
+
exit_code=1,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
ui.console.print(f"[dim]Encrypting section: {section_name}[/dim]")
|
|
129
|
+
|
|
130
|
+
enc = EncryptionManager(method)
|
|
131
|
+
|
|
132
|
+
for path_str in section.paths:
|
|
133
|
+
local_path = Path(path_str).expanduser()
|
|
134
|
+
repo_dir_str = ops.git.repo.working_dir
|
|
135
|
+
assert repo_dir_str is not None
|
|
136
|
+
repo_path = section.get_repo_path(local_path, Path(repo_dir_str))
|
|
137
|
+
|
|
138
|
+
if not local_path.exists():
|
|
139
|
+
warn(f"File not found: {local_path}")
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
encrypted_path = repo_path.with_suffix(repo_path.suffix + ".gpg")
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
enc.encrypt_file(local_path, encrypted_path, recipient)
|
|
146
|
+
ui.console.print(f" [green]✓[/green] Encrypted: {local_path.name}")
|
|
147
|
+
except EncryptionError as e:
|
|
148
|
+
warn(f"Failed to encrypt {local_path.name}: {e}")
|
|
149
|
+
|
|
150
|
+
config.update_section(
|
|
151
|
+
section_name,
|
|
152
|
+
encrypted=True,
|
|
153
|
+
encryption_method=method,
|
|
154
|
+
encryption_recipient=recipient,
|
|
155
|
+
)
|
|
156
|
+
config.save()
|
|
157
|
+
|
|
158
|
+
success(f"Encrypted section '{section_name}'")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _decrypt_section(
|
|
162
|
+
section_name: str | None, method: Literal["gpg", "age"], recipient: str | None
|
|
163
|
+
):
|
|
164
|
+
"""Decrypt a section's files."""
|
|
165
|
+
if not section_name:
|
|
166
|
+
error("Section name required for decryption", exit_code=1)
|
|
167
|
+
|
|
168
|
+
from ..dotman_config import DotManConfig
|
|
169
|
+
from ..operations import get_operations
|
|
170
|
+
|
|
171
|
+
ops = get_operations()
|
|
172
|
+
config = DotManConfig()
|
|
173
|
+
|
|
174
|
+
assert section_name is not None
|
|
175
|
+
section = ops.get_section(section_name)
|
|
176
|
+
if not section:
|
|
177
|
+
error(f"Section not found: {section_name}", exit_code=1)
|
|
178
|
+
|
|
179
|
+
ui.console.print(f"[dim]Decrypting section: {section_name}[/dim]")
|
|
180
|
+
|
|
181
|
+
enc = EncryptionManager(method)
|
|
182
|
+
|
|
183
|
+
for path_str in section.paths:
|
|
184
|
+
local_path = Path(path_str).expanduser()
|
|
185
|
+
repo_dir_str = ops.git.repo.working_dir
|
|
186
|
+
assert repo_dir_str is not None
|
|
187
|
+
repo_path = section.get_repo_path(local_path, Path(repo_dir_str))
|
|
188
|
+
|
|
189
|
+
encrypted_path = repo_path.with_suffix(repo_path.suffix + ".gpg")
|
|
190
|
+
|
|
191
|
+
if not encrypted_path.exists():
|
|
192
|
+
warn(f"Encrypted file not found: {encrypted_path}")
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
enc.decrypt_file(encrypted_path, local_path, recipient)
|
|
197
|
+
ui.console.print(f" [green]✓[/green] Decrypted: {local_path.name}")
|
|
198
|
+
except EncryptionError as e:
|
|
199
|
+
warn(f"Failed to decrypt {local_path.name}: {e}")
|
|
200
|
+
|
|
201
|
+
assert section_name is not None
|
|
202
|
+
config.update_section(section_name, encrypted=False)
|
|
203
|
+
config.save()
|
|
204
|
+
|
|
205
|
+
success(f"Decrypted section '{section_name}'")
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Export command for exporting dotfiles to portable formats."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tarfile
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from .. import ui
|
|
12
|
+
from ..constants import REPO_DIR
|
|
13
|
+
from .common import error, require_init, success
|
|
14
|
+
from .interface import cli as main
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@main.command("export")
|
|
18
|
+
@click.argument("format", type=click.Choice(["tar", "zip", "json"]))
|
|
19
|
+
@click.argument("output", type=click.Path())
|
|
20
|
+
@click.option(
|
|
21
|
+
"--branch",
|
|
22
|
+
"-b",
|
|
23
|
+
default=None,
|
|
24
|
+
help="Export specific branch (default: current branch)",
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--include-secrets",
|
|
28
|
+
is_flag=True,
|
|
29
|
+
help="Include decrypted secrets in export (WARNING: not secure)",
|
|
30
|
+
)
|
|
31
|
+
@require_init
|
|
32
|
+
def export_cmd(format: str, output: str, branch: str | None, include_secrets: bool):
|
|
33
|
+
"""Export dotfiles to a portable archive format.
|
|
34
|
+
|
|
35
|
+
Supported formats:
|
|
36
|
+
tar - Tar archive (.tar.gz)
|
|
37
|
+
zip - Zip archive (.zip)
|
|
38
|
+
json - JSON manifest with file contents
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
dot-man export tar backup.tar.gz # Export as tar.gz
|
|
42
|
+
dot-man export zip dots.zip # Export as zip
|
|
43
|
+
dot-man export json manifest.json # Export as JSON
|
|
44
|
+
dot-man export tar archive.tar.gz --branch work # Export specific branch
|
|
45
|
+
"""
|
|
46
|
+
from ..operations import get_operations
|
|
47
|
+
|
|
48
|
+
ops = get_operations()
|
|
49
|
+
current_branch = branch or ops.current_branch
|
|
50
|
+
|
|
51
|
+
ui.console.print(f"[dim]Exporting branch: {current_branch}[/dim]")
|
|
52
|
+
|
|
53
|
+
if format == "tar":
|
|
54
|
+
_export_tar(output, current_branch, include_secrets)
|
|
55
|
+
elif format == "zip":
|
|
56
|
+
_export_zip(output, current_branch, include_secrets)
|
|
57
|
+
elif format == "json":
|
|
58
|
+
_export_json(output, current_branch, include_secrets)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _export_tar(output: str, branch: str, include_secrets: bool):
|
|
62
|
+
"""Export to tar.gz archive."""
|
|
63
|
+
output_path = Path(output).expanduser().resolve()
|
|
64
|
+
|
|
65
|
+
if not str(output_path).endswith((".tar.gz", ".tgz")):
|
|
66
|
+
output_path = output_path.with_suffix(".tar.gz")
|
|
67
|
+
|
|
68
|
+
ui.console.print(f"[dim]Creating tar archive: {output_path}[/dim]")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
with tarfile.open(output_path, "w:gz") as tar:
|
|
72
|
+
tar.add(REPO_DIR, arcname="dotman-export")
|
|
73
|
+
|
|
74
|
+
success(f"Exported to {output_path}")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
error(f"Export failed: {e}", exit_code=1)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _export_zip(output: str, branch: str, include_secrets: bool):
|
|
80
|
+
"""Export to zip archive."""
|
|
81
|
+
output_path = Path(output).expanduser().resolve()
|
|
82
|
+
|
|
83
|
+
if not str(output_path).endswith(".zip"):
|
|
84
|
+
output_path = output_path.with_suffix(".zip")
|
|
85
|
+
|
|
86
|
+
ui.console.print(f"[dim]Creating zip archive: {output_path}[/dim]")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
|
90
|
+
for root, dirs, files in os.walk(REPO_DIR):
|
|
91
|
+
for file in files:
|
|
92
|
+
file_path = Path(root) / file
|
|
93
|
+
arcname = file_path.relative_to(REPO_DIR)
|
|
94
|
+
zipf.write(file_path, arcname)
|
|
95
|
+
|
|
96
|
+
success(f"Exported to {output_path}")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
error(f"Export failed: {e}", exit_code=1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _export_json(output: str, branch: str, include_secrets: bool):
|
|
102
|
+
"""Export to JSON manifest."""
|
|
103
|
+
output_path = Path(output).expanduser().resolve()
|
|
104
|
+
|
|
105
|
+
if not str(output_path).endswith(".json"):
|
|
106
|
+
output_path = output_path.with_suffix(".json")
|
|
107
|
+
|
|
108
|
+
ui.console.print(f"[dim]Creating JSON manifest: {output_path}[/dim]")
|
|
109
|
+
|
|
110
|
+
from ..operations import get_operations
|
|
111
|
+
|
|
112
|
+
ops = get_operations()
|
|
113
|
+
manifest: dict = {
|
|
114
|
+
"version": "1.0",
|
|
115
|
+
"branch": branch,
|
|
116
|
+
"exported_at": str(Path().home()),
|
|
117
|
+
"files": [], # type: ignore[list-item]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for section_name in ops.get_sections():
|
|
121
|
+
section = ops.get_section(section_name)
|
|
122
|
+
for path_str in section.paths:
|
|
123
|
+
path = Path(path_str).expanduser()
|
|
124
|
+
if path.exists():
|
|
125
|
+
file_entry = {
|
|
126
|
+
"section": section_name,
|
|
127
|
+
"local_path": str(path),
|
|
128
|
+
"repo_path": str(path_str),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if include_secrets:
|
|
132
|
+
try:
|
|
133
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
134
|
+
file_entry["content"] = f.read()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
manifest["files"].append(file_entry)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
with open(output_path, "w") as f:
|
|
142
|
+
json.dump(manifest, f, indent=2)
|
|
143
|
+
|
|
144
|
+
success(f"Exported {len(manifest['files'])} files to {output_path}")
|
|
145
|
+
except Exception as e:
|
|
146
|
+
error(f"Export failed: {e}", exit_code=1)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Import command for migrating from other dotfile managers."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .. import ui
|
|
9
|
+
from ..core import GitManager
|
|
10
|
+
from .common import error, require_init, warn
|
|
11
|
+
from .interface import cli as main
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@main.command("import")
|
|
15
|
+
@click.argument("source", type=click.Choice(["chezmoi", "yadm", "stow", "all"]))
|
|
16
|
+
@click.option(
|
|
17
|
+
"--path",
|
|
18
|
+
"-p",
|
|
19
|
+
type=click.Path(),
|
|
20
|
+
default=None,
|
|
21
|
+
help="Custom source path (default: auto-detect)",
|
|
22
|
+
)
|
|
23
|
+
@click.option(
|
|
24
|
+
"--dry-run",
|
|
25
|
+
is_flag=True,
|
|
26
|
+
help="Show what would be imported without importing",
|
|
27
|
+
)
|
|
28
|
+
@require_init
|
|
29
|
+
def import_cmd(source: str, path: str | None, dry_run: bool):
|
|
30
|
+
"""Import dotfiles from other dotfile managers.
|
|
31
|
+
|
|
32
|
+
Supported sources:
|
|
33
|
+
chezmoi - Import from chezmoi managed dotfiles
|
|
34
|
+
yadm - Import from yadm managed dotfiles
|
|
35
|
+
stow - Import from GNU Stow packages
|
|
36
|
+
all - Auto-detect and import from any supported source
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
dot-man import chezmoi # Import from chezmoi
|
|
40
|
+
dot-man import yadm # Import from yadm
|
|
41
|
+
dot-man import stow # Import from Stow packages
|
|
42
|
+
dot-man import all --dry-run # Auto-detect and preview
|
|
43
|
+
"""
|
|
44
|
+
git = GitManager()
|
|
45
|
+
|
|
46
|
+
if source == "all":
|
|
47
|
+
_import_all(path, dry_run, git)
|
|
48
|
+
elif source == "chezmoi":
|
|
49
|
+
_import_chezmoi(path, dry_run, git)
|
|
50
|
+
elif source == "yadm":
|
|
51
|
+
_import_yadm(path, dry_run, git)
|
|
52
|
+
elif source == "stow":
|
|
53
|
+
_import_stow(path, dry_run, git)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _import_all(custom_path: str | None, dry_run: bool, git: GitManager):
|
|
57
|
+
"""Auto-detect and import from any supported source."""
|
|
58
|
+
sources_found = []
|
|
59
|
+
|
|
60
|
+
chezmoi_path = _detect_chezmoi()
|
|
61
|
+
if chezmoi_path:
|
|
62
|
+
sources_found.append(("chezmoi", chezmoi_path))
|
|
63
|
+
|
|
64
|
+
yadm_path = _detect_yadm()
|
|
65
|
+
if yadm_path:
|
|
66
|
+
sources_found.append(("yadm", yadm_path))
|
|
67
|
+
|
|
68
|
+
stow_path = _detect_stow()
|
|
69
|
+
if stow_path:
|
|
70
|
+
sources_found.append(("stow", stow_path))
|
|
71
|
+
|
|
72
|
+
if not sources_found:
|
|
73
|
+
error(
|
|
74
|
+
"No dotfile manager sources detected. Use --path to specify a custom path.",
|
|
75
|
+
exit_code=1,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
ui.console.print("[bold]Detected dotfile sources:[/bold]")
|
|
79
|
+
for src, src_path in sources_found:
|
|
80
|
+
ui.console.print(f" • {src}: {src_path}")
|
|
81
|
+
|
|
82
|
+
if dry_run:
|
|
83
|
+
ui.console.print("\n[dim]Dry-run mode - no changes will be made[/dim]")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
for src, src_path in sources_found:
|
|
87
|
+
ui.console.print(f"\n[bold]Importing from {src}...[/bold]")
|
|
88
|
+
if src == "chezmoi":
|
|
89
|
+
_import_chezmoi(src_path, False, git)
|
|
90
|
+
elif src == "yadm":
|
|
91
|
+
_import_yadm(src_path, False, git)
|
|
92
|
+
elif src == "stow":
|
|
93
|
+
_import_stow(src_path, False, git)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _detect_chezmoi() -> str | None:
|
|
97
|
+
"""Detect chezmoi source directory."""
|
|
98
|
+
home = Path.home()
|
|
99
|
+
|
|
100
|
+
chezmoi_dirs = [
|
|
101
|
+
home / ".local" / "share" / "chezmoi",
|
|
102
|
+
home / ".config" / "chezmoi",
|
|
103
|
+
home / "Library" / "Application Support" / "chezmoi", # macOS
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
for d in chezmoi_dirs:
|
|
107
|
+
if d.exists() and (d / ".git").exists() or any(d.iterdir()):
|
|
108
|
+
return str(d)
|
|
109
|
+
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _detect_yadm() -> str | None:
|
|
114
|
+
"""Detect yadm managed dotfiles."""
|
|
115
|
+
home = Path.home()
|
|
116
|
+
|
|
117
|
+
yadm_paths = [
|
|
118
|
+
home / ".yadm.git",
|
|
119
|
+
home / ".config" / "yadm" / "repo.git",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
for p in yadm_paths:
|
|
123
|
+
if p.exists():
|
|
124
|
+
return str(home / ".yadm")
|
|
125
|
+
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _detect_stow() -> str | None:
|
|
130
|
+
"""Detect GNU Stow packages in common locations."""
|
|
131
|
+
home = Path.home()
|
|
132
|
+
|
|
133
|
+
stow_locations = [
|
|
134
|
+
home / "dotfiles",
|
|
135
|
+
home / ".dotfiles",
|
|
136
|
+
home / "dotfiles" / ".git",
|
|
137
|
+
home / ".dotfiles" / ".git",
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
for p in stow_locations:
|
|
141
|
+
if p.exists():
|
|
142
|
+
packages = [
|
|
143
|
+
d for d in p.iterdir() if d.is_dir() and not d.name.startswith(".")
|
|
144
|
+
]
|
|
145
|
+
if packages:
|
|
146
|
+
return str(p)
|
|
147
|
+
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _import_chezmoi(source_path: str | None, dry_run: bool, git: GitManager):
|
|
152
|
+
"""Import from chezmoi."""
|
|
153
|
+
if source_path is None:
|
|
154
|
+
source_path = _detect_chezmoi()
|
|
155
|
+
if source_path is None:
|
|
156
|
+
error(
|
|
157
|
+
"chezmoi source not found. Install chezmoi first or use --path.",
|
|
158
|
+
exit_code=1,
|
|
159
|
+
)
|
|
160
|
+
assert source_path is not None
|
|
161
|
+
|
|
162
|
+
source = Path(source_path).expanduser().resolve()
|
|
163
|
+
|
|
164
|
+
if not source.exists():
|
|
165
|
+
error(f"Source path does not exist: {source}", exit_code=1)
|
|
166
|
+
|
|
167
|
+
ui.console.print(f"[dim]Importing from chezmoi: {source}[/dim]")
|
|
168
|
+
|
|
169
|
+
files_imported = 0
|
|
170
|
+
|
|
171
|
+
for item in source.rglob("*"):
|
|
172
|
+
if item.is_file():
|
|
173
|
+
rel_path = item.relative_to(source)
|
|
174
|
+
|
|
175
|
+
if rel_path.name.startswith("."):
|
|
176
|
+
dest_path = Path.home() / f".{rel_path.name}"
|
|
177
|
+
else:
|
|
178
|
+
dest_path = Path.home() / ".config" / str(rel_path)
|
|
179
|
+
|
|
180
|
+
if source.name == "chezmoi":
|
|
181
|
+
src_path = item
|
|
182
|
+
else:
|
|
183
|
+
src_path = item
|
|
184
|
+
|
|
185
|
+
if dry_run:
|
|
186
|
+
ui.console.print(f" [dim]Would import: {src_path} → {dest_path}[/dim]")
|
|
187
|
+
else:
|
|
188
|
+
try:
|
|
189
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
shutil.copy2(src_path, dest_path)
|
|
191
|
+
files_imported += 1
|
|
192
|
+
except Exception as e:
|
|
193
|
+
warn(f"Failed to import {src_path}: {e}")
|
|
194
|
+
|
|
195
|
+
if dry_run:
|
|
196
|
+
ui.success(f"Would import {files_imported} files from chezmoi")
|
|
197
|
+
else:
|
|
198
|
+
ui.success(f"Imported {files_imported} files from chezmoi")
|
|
199
|
+
|
|
200
|
+
commit_msg = "Import dotfiles from chezmoi"
|
|
201
|
+
git.add_all()
|
|
202
|
+
git.commit(commit_msg)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _import_yadm(source_path: str | None, dry_run: bool, git: GitManager):
|
|
206
|
+
"""Import from yadm."""
|
|
207
|
+
if source_path is None:
|
|
208
|
+
source_path = _detect_yadm()
|
|
209
|
+
if source_path is None:
|
|
210
|
+
error(
|
|
211
|
+
"yadm source not found. Install yadm first or use --path.", exit_code=1
|
|
212
|
+
)
|
|
213
|
+
assert source_path is not None
|
|
214
|
+
|
|
215
|
+
source = Path(source_path).expanduser().resolve()
|
|
216
|
+
|
|
217
|
+
if not source.exists():
|
|
218
|
+
error(f"Source path does not exist: {source}", exit_code=1)
|
|
219
|
+
|
|
220
|
+
ui.console.print(f"[dim]Importing from yadm: {source}[/dim]")
|
|
221
|
+
|
|
222
|
+
files_imported = 0
|
|
223
|
+
|
|
224
|
+
for item in source.rglob("*"):
|
|
225
|
+
if item.is_file() and not item.name.endswith(".git"):
|
|
226
|
+
rel_path = item.relative_to(source)
|
|
227
|
+
|
|
228
|
+
dest_path = Path.home() / rel_path.name
|
|
229
|
+
if dest_path.exists():
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
if dry_run:
|
|
233
|
+
ui.console.print(f" [dim]Would import: {item} → {dest_path}[/dim]")
|
|
234
|
+
else:
|
|
235
|
+
try:
|
|
236
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
shutil.copy2(item, dest_path)
|
|
238
|
+
files_imported += 1
|
|
239
|
+
except Exception as e:
|
|
240
|
+
warn(f"Failed to import {item}: {e}")
|
|
241
|
+
|
|
242
|
+
if dry_run:
|
|
243
|
+
ui.success(f"Would import {files_imported} files from yadm")
|
|
244
|
+
else:
|
|
245
|
+
ui.success(f"Imported {files_imported} files from yadm")
|
|
246
|
+
|
|
247
|
+
commit_msg = "Import dotfiles from yadm"
|
|
248
|
+
git.add_all()
|
|
249
|
+
git.commit(commit_msg)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _import_stow(source_path: str | None, dry_run: bool, git: GitManager):
|
|
253
|
+
"""Import from GNU Stow packages."""
|
|
254
|
+
if source_path is None:
|
|
255
|
+
source_path = _detect_stow()
|
|
256
|
+
if source_path is None:
|
|
257
|
+
error("Stow packages not found. Use --path to specify.", exit_code=1)
|
|
258
|
+
assert source_path is not None
|
|
259
|
+
|
|
260
|
+
source = Path(source_path).expanduser().resolve()
|
|
261
|
+
|
|
262
|
+
if not source.exists():
|
|
263
|
+
error(f"Source path does not exist: {source}", exit_code=1)
|
|
264
|
+
|
|
265
|
+
ui.console.print(f"[dim]Importing from Stow: {source}[/dim]")
|
|
266
|
+
|
|
267
|
+
packages = [
|
|
268
|
+
d for d in source.iterdir() if d.is_dir() and not d.name.startswith(".")
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
if not packages:
|
|
272
|
+
error("No Stow packages found in source directory", exit_code=1)
|
|
273
|
+
|
|
274
|
+
ui.console.print(
|
|
275
|
+
f"[dim]Found {len(packages)} packages: {', '.join([p.name for p in packages])}[/dim]"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
files_imported = 0
|
|
279
|
+
|
|
280
|
+
for package in packages:
|
|
281
|
+
for item in package.rglob("*"):
|
|
282
|
+
if item.is_file():
|
|
283
|
+
rel_path = item.relative_to(package)
|
|
284
|
+
|
|
285
|
+
if rel_path.parts[0].startswith("."):
|
|
286
|
+
dest_path = Path.home() / "/".join(rel_path.parts)
|
|
287
|
+
else:
|
|
288
|
+
dest_path = (
|
|
289
|
+
Path.home()
|
|
290
|
+
/ f".{rel_path.parts[0]}"
|
|
291
|
+
/ "/".join(rel_path.parts[1:])
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if dry_run:
|
|
295
|
+
ui.console.print(f" [dim]Would import: {item} → {dest_path}[/dim]")
|
|
296
|
+
else:
|
|
297
|
+
try:
|
|
298
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
299
|
+
shutil.copy2(item, dest_path)
|
|
300
|
+
files_imported += 1
|
|
301
|
+
except Exception as e:
|
|
302
|
+
warn(f"Failed to import {item}: {e}")
|
|
303
|
+
|
|
304
|
+
if dry_run:
|
|
305
|
+
ui.success(
|
|
306
|
+
f"Would import {files_imported} files from {len(packages)} Stow packages"
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
ui.success(
|
|
310
|
+
f"Imported {files_imported} files from {len(packages)} Stow packages"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
commit_msg = f"Import dotfiles from {len(packages)} Stow packages"
|
|
314
|
+
git.add_all()
|
|
315
|
+
git.commit(commit_msg)
|