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
dot_man/cli/init_cmd.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""Init command for dot-man CLI."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from .. import ui
|
|
11
|
+
from ..config import DotManConfig, GlobalConfig
|
|
12
|
+
from ..config_detector import ConfigDetector, get_auto_hooks_for_config
|
|
13
|
+
from ..constants import BACKUPS_DIR, DOT_MAN_DIR, FILE_PERMISSIONS, REPO_DIR
|
|
14
|
+
from ..core import GitManager
|
|
15
|
+
from ..utils import is_git_installed
|
|
16
|
+
from .common import error, handle_exception, success, warn
|
|
17
|
+
from .interface import cli as main
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@main.command()
|
|
21
|
+
@click.option("--force", is_flag=True, help="Reinitialize even if already exists")
|
|
22
|
+
@click.option("--no-wizard", is_flag=True, help="Skip interactive setup wizard")
|
|
23
|
+
@click.option(
|
|
24
|
+
"--sandbox",
|
|
25
|
+
"sandbox_dir",
|
|
26
|
+
type=click.Path(),
|
|
27
|
+
default=None,
|
|
28
|
+
help="Test init in a temporary sandbox directory (for testing wizard)",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--import",
|
|
32
|
+
"import_path",
|
|
33
|
+
type=click.Path(exists=True),
|
|
34
|
+
default=None,
|
|
35
|
+
help="Import from an existing git repository",
|
|
36
|
+
)
|
|
37
|
+
def init(
|
|
38
|
+
force: bool, no_wizard: bool, sandbox_dir: str | None, import_path: str | None
|
|
39
|
+
):
|
|
40
|
+
"""Initialize a new dot-man repository.
|
|
41
|
+
|
|
42
|
+
By default, runs an interactive setup wizard to detect and add
|
|
43
|
+
common dotfiles. Use --no-wizard for manual setup.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
dot-man init # Interactive wizard
|
|
47
|
+
dot-man init --sandbox /tmp/test # Test wizard in sandbox
|
|
48
|
+
dot-man init --no-wizard # Manual setup only
|
|
49
|
+
dot-man init --import ~/dotfiles # Import existing dotfiles repo
|
|
50
|
+
"""
|
|
51
|
+
# Pre-checks
|
|
52
|
+
if not is_git_installed():
|
|
53
|
+
error("Git not found. Please install git first.", exit_code=2)
|
|
54
|
+
|
|
55
|
+
if DOT_MAN_DIR.exists() and not force:
|
|
56
|
+
if not ui.confirm(
|
|
57
|
+
"Repository already initialized. Reinitialize? (This will DELETE all data)"
|
|
58
|
+
):
|
|
59
|
+
ui.info("Aborted.")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
shutil.rmtree(DOT_MAN_DIR)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
# Create directory structure
|
|
66
|
+
DOT_MAN_DIR.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
DOT_MAN_DIR.chmod(FILE_PERMISSIONS)
|
|
68
|
+
REPO_DIR.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
# Handle import from existing repo
|
|
72
|
+
if import_path:
|
|
73
|
+
source_path: Path
|
|
74
|
+
|
|
75
|
+
# Check if it's a GitHub URL
|
|
76
|
+
github_url = _parse_github_url(import_path)
|
|
77
|
+
if github_url:
|
|
78
|
+
cloned_path = _clone_github_repo(github_url)
|
|
79
|
+
if cloned_path is None:
|
|
80
|
+
error("Failed to clone GitHub repository.", exit_code=1)
|
|
81
|
+
source_path = cloned_path # type: ignore[assignment]
|
|
82
|
+
else:
|
|
83
|
+
# It's a local path
|
|
84
|
+
source_path = Path(import_path).expanduser().resolve()
|
|
85
|
+
if not source_path.exists():
|
|
86
|
+
error(f"Path '{source_path}' does not exist.", exit_code=1)
|
|
87
|
+
if not (source_path / ".git").exists():
|
|
88
|
+
error(f"'{source_path}' is not a git repository.", exit_code=1)
|
|
89
|
+
|
|
90
|
+
ui.console.print(f"[dim]Importing from {source_path}...[/dim]")
|
|
91
|
+
|
|
92
|
+
# Get the current branch from source repo
|
|
93
|
+
current_branch = "master"
|
|
94
|
+
try:
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
97
|
+
cwd=source_path,
|
|
98
|
+
capture_output=True,
|
|
99
|
+
text=True,
|
|
100
|
+
timeout=5,
|
|
101
|
+
)
|
|
102
|
+
if result.returncode == 0:
|
|
103
|
+
current_branch = result.stdout.strip()
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
# Copy entire repo including .git
|
|
108
|
+
shutil.copytree(source_path, REPO_DIR, dirs_exist_ok=True)
|
|
109
|
+
|
|
110
|
+
# Initialize git to get the repo object
|
|
111
|
+
git = GitManager()
|
|
112
|
+
|
|
113
|
+
# Set the active branch to match source
|
|
114
|
+
try:
|
|
115
|
+
if current_branch in git.list_branches():
|
|
116
|
+
git.checkout(current_branch)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
ui.success(f"Imported dotfiles from {source_path}")
|
|
121
|
+
else:
|
|
122
|
+
# Initialize git repository
|
|
123
|
+
git = GitManager()
|
|
124
|
+
git.init()
|
|
125
|
+
|
|
126
|
+
# Verify git config exists (user.name and user.email)
|
|
127
|
+
try:
|
|
128
|
+
git.repo.config_reader().get_value("user", "name")
|
|
129
|
+
git.repo.config_reader().get_value("user", "email")
|
|
130
|
+
except Exception:
|
|
131
|
+
ui.console.print()
|
|
132
|
+
ui.warn("Git user configuration not found. Setting defaults...")
|
|
133
|
+
with git.repo.config_writer() as config:
|
|
134
|
+
config.set_value("user", "name", "dot-man-user")
|
|
135
|
+
config.set_value("user", "email", "dot-man@localhost")
|
|
136
|
+
ui.console.print("[dim] Configured default git user.[/dim]")
|
|
137
|
+
ui.console.print(
|
|
138
|
+
"[dim] Run 'git config --global user.name \"Your Name\"' to customize[/dim]"
|
|
139
|
+
)
|
|
140
|
+
ui.console.print(
|
|
141
|
+
"[dim] Run 'git config --global user.email \"you@example.com\"' to customize[/dim]"
|
|
142
|
+
)
|
|
143
|
+
ui.console.print()
|
|
144
|
+
|
|
145
|
+
# Create global configuration
|
|
146
|
+
global_config = GlobalConfig()
|
|
147
|
+
global_config.create_default()
|
|
148
|
+
|
|
149
|
+
# Create minimal dot-man.toml
|
|
150
|
+
dotman_config = DotManConfig()
|
|
151
|
+
dotman_config.create_default()
|
|
152
|
+
|
|
153
|
+
# Initial commit
|
|
154
|
+
git.commit("dot-man: Initial commit")
|
|
155
|
+
|
|
156
|
+
# Success message
|
|
157
|
+
ui.console.print()
|
|
158
|
+
ui.print_banner("🎉 dot-man initialized successfully!")
|
|
159
|
+
ui.console.print()
|
|
160
|
+
|
|
161
|
+
# Run wizard by default (unless --no-wizard)
|
|
162
|
+
if not no_wizard:
|
|
163
|
+
run_setup_wizard(global_config, dotman_config, git)
|
|
164
|
+
else:
|
|
165
|
+
show_quick_start()
|
|
166
|
+
|
|
167
|
+
except KeyboardInterrupt:
|
|
168
|
+
handle_exception(KeyboardInterrupt())
|
|
169
|
+
except Exception as e:
|
|
170
|
+
handle_exception(e, "Initialization")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run_setup_wizard(
|
|
174
|
+
global_config: GlobalConfig, dotman_config: DotManConfig, git: GitManager
|
|
175
|
+
):
|
|
176
|
+
"""Interactive setup wizard for new users."""
|
|
177
|
+
ui.print_banner("🧙 Setup Wizard")
|
|
178
|
+
ui.console.print()
|
|
179
|
+
ui.console.print(
|
|
180
|
+
"Let's get your dotfiles set up! I'll detect common files automatically."
|
|
181
|
+
)
|
|
182
|
+
ui.console.print()
|
|
183
|
+
|
|
184
|
+
# Detect common dotfiles
|
|
185
|
+
ui.console.print("[bold]Detecting dotfiles...[/bold]")
|
|
186
|
+
ui.console.print()
|
|
187
|
+
|
|
188
|
+
# Detect quickshell configs using ConfigDetector
|
|
189
|
+
qs_configs = ConfigDetector.detect_quickshell_configs()
|
|
190
|
+
|
|
191
|
+
# Build common_files with quickshell detection
|
|
192
|
+
common_files = [
|
|
193
|
+
("~/.bashrc", "Bash shell", "bashrc"),
|
|
194
|
+
("~/.zshrc", "Zsh shell", "zshrc"),
|
|
195
|
+
("~/.gitconfig", "Git config", "gitconfig"),
|
|
196
|
+
("~/.vimrc", "Vim editor", "vimrc"),
|
|
197
|
+
("~/.config/nvim", "Neovim", "nvim"),
|
|
198
|
+
("~/.config/fish", "Fish shell", "fish"),
|
|
199
|
+
("~/.config/kitty", "Kitty terminal", "kitty"),
|
|
200
|
+
("~/.config/alacritty", "Alacritty terminal", "alacritty"),
|
|
201
|
+
("~/.config/hypr", "Hyprland WM", "hypr"),
|
|
202
|
+
("~/.config/i3", "i3 WM", "i3"),
|
|
203
|
+
("~/.tmux.conf", "tmux", "tmux"),
|
|
204
|
+
("~/.ssh/config", "SSH config", "ssh-config"),
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
# Add quickshell configs as separate entries
|
|
208
|
+
for qs_config in qs_configs:
|
|
209
|
+
common_files.append(
|
|
210
|
+
(
|
|
211
|
+
qs_config["paths"][0],
|
|
212
|
+
qs_config["display_name"],
|
|
213
|
+
qs_config["section_name"],
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
files_to_add = []
|
|
218
|
+
found_count = 0
|
|
219
|
+
|
|
220
|
+
for path_str, desc, section_name in common_files:
|
|
221
|
+
path = Path(path_str).expanduser()
|
|
222
|
+
|
|
223
|
+
# Skip quickshell subdirs (detected separately by ConfigDetector)
|
|
224
|
+
if section_name.startswith("qs-"):
|
|
225
|
+
if path.exists():
|
|
226
|
+
found_count += 1
|
|
227
|
+
ui.console.print(
|
|
228
|
+
f" [green]✓[/green] Found: [cyan]{path_str}[/cyan] ({desc})"
|
|
229
|
+
)
|
|
230
|
+
if ui.confirm(" Track this?", default=True):
|
|
231
|
+
files_to_add.append((path_str, section_name))
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# Special handling for Quickshell root ambiguity
|
|
235
|
+
if section_name == "quickshell" and path.exists():
|
|
236
|
+
subdirs = sorted(
|
|
237
|
+
[
|
|
238
|
+
d
|
|
239
|
+
for d in path.iterdir()
|
|
240
|
+
if d.is_dir() and not d.name.startswith(".")
|
|
241
|
+
],
|
|
242
|
+
key=lambda x: x.name,
|
|
243
|
+
)
|
|
244
|
+
if len(subdirs) > 1:
|
|
245
|
+
found_count += 1
|
|
246
|
+
ui.console.print(
|
|
247
|
+
f" [green]✓[/green] Found: [cyan]{path_str}[/cyan] ({desc})"
|
|
248
|
+
)
|
|
249
|
+
ui.console.print(
|
|
250
|
+
" [yellow]⚠️ Multiple configurations detected:[/yellow]"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# List options
|
|
254
|
+
options = subdirs + [path] # subdirs + root
|
|
255
|
+
for i, opt in enumerate(options, 1):
|
|
256
|
+
if opt == path:
|
|
257
|
+
label = f"Track root directory ({path_str})"
|
|
258
|
+
else:
|
|
259
|
+
label = f"Track '{opt.name}'"
|
|
260
|
+
ui.console.print(f" [bold]{i}.[/bold] {label}")
|
|
261
|
+
|
|
262
|
+
# Ask user
|
|
263
|
+
while True:
|
|
264
|
+
choice = ui.ask(
|
|
265
|
+
f" Which one to track? (1-{len(options)})",
|
|
266
|
+
default=str(len(options)),
|
|
267
|
+
)
|
|
268
|
+
try:
|
|
269
|
+
idx = int(choice) - 1
|
|
270
|
+
if 0 <= idx < len(options):
|
|
271
|
+
selected_path = options[idx]
|
|
272
|
+
|
|
273
|
+
# Determine section name
|
|
274
|
+
if selected_path == path:
|
|
275
|
+
final_section = section_name
|
|
276
|
+
final_path_str = path_str
|
|
277
|
+
else:
|
|
278
|
+
final_section = selected_path.name
|
|
279
|
+
final_path_str = f"{path_str}/{selected_path.name}"
|
|
280
|
+
|
|
281
|
+
if ui.confirm(
|
|
282
|
+
f" Track '{final_section}'?", default=True
|
|
283
|
+
):
|
|
284
|
+
files_to_add.append((final_path_str, final_section))
|
|
285
|
+
break
|
|
286
|
+
else:
|
|
287
|
+
warn("Invalid selection")
|
|
288
|
+
except ValueError:
|
|
289
|
+
warn("Please enter a number")
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
if path.exists():
|
|
293
|
+
found_count += 1
|
|
294
|
+
ui.console.print(
|
|
295
|
+
f" [green]✓[/green] Found: [cyan]{path_str}[/cyan] ({desc})"
|
|
296
|
+
)
|
|
297
|
+
if ui.confirm(" Track this?", default=True):
|
|
298
|
+
files_to_add.append((path_str, section_name))
|
|
299
|
+
|
|
300
|
+
if found_count == 0:
|
|
301
|
+
ui.console.print(
|
|
302
|
+
" [dim]No common dotfiles detected in default locations[/dim]"
|
|
303
|
+
)
|
|
304
|
+
ui.console.print()
|
|
305
|
+
else:
|
|
306
|
+
ui.console.print()
|
|
307
|
+
|
|
308
|
+
# Offer to add custom files
|
|
309
|
+
if ui.confirm("Add custom files not in the list?", default=False):
|
|
310
|
+
ui.console.print()
|
|
311
|
+
while True:
|
|
312
|
+
custom_path = ui.ask(
|
|
313
|
+
"Path to track (or press Enter to finish)",
|
|
314
|
+
default="",
|
|
315
|
+
show_default=False,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if not custom_path:
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
path = Path(custom_path).expanduser()
|
|
322
|
+
if not path.exists():
|
|
323
|
+
warn(f"Path doesn't exist: {custom_path}")
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
# Auto-generate section name from path
|
|
327
|
+
if path.name.startswith("."):
|
|
328
|
+
default_section = path.name[1:] if path.suffix else path.name[1:]
|
|
329
|
+
else:
|
|
330
|
+
default_section = path.stem or path.name
|
|
331
|
+
|
|
332
|
+
section_name = ui.ask("Section name", default=default_section)
|
|
333
|
+
files_to_add.append((custom_path, section_name))
|
|
334
|
+
|
|
335
|
+
# Add files to config
|
|
336
|
+
if files_to_add:
|
|
337
|
+
ui.console.print()
|
|
338
|
+
ui.console.print(f"[bold]Adding {len(files_to_add)} files...[/bold]")
|
|
339
|
+
ui.console.print()
|
|
340
|
+
|
|
341
|
+
for path_str, section_name in files_to_add:
|
|
342
|
+
try:
|
|
343
|
+
dotman_config.add_section(
|
|
344
|
+
name=section_name,
|
|
345
|
+
paths=[path_str],
|
|
346
|
+
)
|
|
347
|
+
# Auto-detect and suggest hooks for popular configs
|
|
348
|
+
auto_hooks = get_auto_hooks_for_config(section_name, [path_str])
|
|
349
|
+
if auto_hooks:
|
|
350
|
+
for hook_type, hook_cmd in auto_hooks.items():
|
|
351
|
+
# Map to actual config keys
|
|
352
|
+
if hook_type == "post_deploy":
|
|
353
|
+
dotman_config.update_section(
|
|
354
|
+
section_name, post_deploy=hook_cmd
|
|
355
|
+
)
|
|
356
|
+
elif hook_type == "pre_deploy":
|
|
357
|
+
dotman_config.update_section(
|
|
358
|
+
section_name, pre_deploy=hook_cmd
|
|
359
|
+
)
|
|
360
|
+
ui.console.print(
|
|
361
|
+
f" [dim]Auto-detected {hook_type} hook for {section_name}[/dim]"
|
|
362
|
+
)
|
|
363
|
+
ui.console.print(f" [green]✓[/green] [{section_name}]: {path_str}")
|
|
364
|
+
except Exception as e:
|
|
365
|
+
warn(f"Could not add {path_str}: {e}")
|
|
366
|
+
|
|
367
|
+
dotman_config.save()
|
|
368
|
+
|
|
369
|
+
# Commit the initial config
|
|
370
|
+
git.add_all()
|
|
371
|
+
git.commit("Add initial dotfiles configuration")
|
|
372
|
+
|
|
373
|
+
ui.console.print()
|
|
374
|
+
success(f"Added {len(files_to_add)} files to configuration")
|
|
375
|
+
ui.console.print()
|
|
376
|
+
|
|
377
|
+
# Show what was configured
|
|
378
|
+
ui.console.print("[bold]Your dotfiles are now tracked:[/bold]")
|
|
379
|
+
for path_str, section_name in files_to_add[:5]:
|
|
380
|
+
ui.console.print(f" • [{section_name}] {path_str}")
|
|
381
|
+
if len(files_to_add) > 5:
|
|
382
|
+
ui.console.print(f" ... and {len(files_to_add) - 5} more")
|
|
383
|
+
|
|
384
|
+
ui.console.print()
|
|
385
|
+
|
|
386
|
+
# Offer remote setup
|
|
387
|
+
if ui.confirm("Set up remote repository for syncing? (optional)", default=False):
|
|
388
|
+
ui.console.print()
|
|
389
|
+
from .remote_cmd import setup
|
|
390
|
+
|
|
391
|
+
ctx = click.Context(setup)
|
|
392
|
+
ctx.invoke(setup)
|
|
393
|
+
|
|
394
|
+
# Final instructions
|
|
395
|
+
ui.console.print()
|
|
396
|
+
ui.print_banner("🎉 Setup Complete!")
|
|
397
|
+
ui.console.print()
|
|
398
|
+
|
|
399
|
+
if files_to_add:
|
|
400
|
+
ui.console.print("[bold]Next steps:[/bold]")
|
|
401
|
+
ui.console.print(
|
|
402
|
+
" 1. [cyan]dot-man status[/cyan] - View your tracked files"
|
|
403
|
+
)
|
|
404
|
+
ui.console.print(
|
|
405
|
+
" 2. [cyan]dot-man navigate work[/cyan] - Create a work configuration branch"
|
|
406
|
+
)
|
|
407
|
+
ui.console.print(
|
|
408
|
+
" 3. [cyan]dot-man add <path>[/cyan] - Track more files"
|
|
409
|
+
)
|
|
410
|
+
else:
|
|
411
|
+
ui.console.print("[bold]Get started:[/bold]")
|
|
412
|
+
ui.console.print(
|
|
413
|
+
" [cyan]dot-man add ~/.bashrc[/cyan] - Add files to track"
|
|
414
|
+
)
|
|
415
|
+
ui.console.print(
|
|
416
|
+
" [cyan]dot-man edit[/cyan] - Edit config file"
|
|
417
|
+
)
|
|
418
|
+
ui.console.print(" [cyan]dot-man status[/cyan] - View status")
|
|
419
|
+
|
|
420
|
+
ui.console.print()
|
|
421
|
+
ui.console.print("[dim]💡 Run 'dot-man --help' to see all commands[/dim]")
|
|
422
|
+
ui.console.print()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def show_quick_start():
|
|
426
|
+
"""Display quick start guide (for --no-wizard users)."""
|
|
427
|
+
ui.console.print("[bold]📚 Quick Start Guide:[/bold]")
|
|
428
|
+
ui.console.print()
|
|
429
|
+
ui.console.print("[bold cyan]Adding files to track:[/bold cyan]")
|
|
430
|
+
ui.console.print(" dot-man add ~/.bashrc [dim]# Single file[/dim]")
|
|
431
|
+
ui.console.print(" dot-man add ~/.config/nvim [dim]# Directory[/dim]")
|
|
432
|
+
ui.console.print(
|
|
433
|
+
" dot-man edit [dim]# Edit config manually[/dim]"
|
|
434
|
+
)
|
|
435
|
+
ui.console.print()
|
|
436
|
+
ui.console.print("[bold cyan]Managing configurations:[/bold cyan]")
|
|
437
|
+
ui.console.print(
|
|
438
|
+
" dot-man status [dim]# View tracked files[/dim]"
|
|
439
|
+
)
|
|
440
|
+
ui.console.print(
|
|
441
|
+
" dot-man navigate main [dim]# Save & switch to main[/dim]"
|
|
442
|
+
)
|
|
443
|
+
ui.console.print(
|
|
444
|
+
" dot-man navigate work --preview [dim]# Preview work branch[/dim]"
|
|
445
|
+
)
|
|
446
|
+
ui.console.print()
|
|
447
|
+
ui.console.print(
|
|
448
|
+
"[dim]💡 Tip: Config is at ~/.config/dot-man/repo/dot-man.toml[/dim]"
|
|
449
|
+
)
|
|
450
|
+
ui.console.print()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _parse_github_url(url: str) -> str | None:
|
|
454
|
+
"""Parse GitHub URL and return repo URL if valid.
|
|
455
|
+
|
|
456
|
+
Supports:
|
|
457
|
+
- github.com/user/repo
|
|
458
|
+
- https://github.com/user/repo
|
|
459
|
+
- git@github.com:user/repo
|
|
460
|
+
- https://github.com/user/repo.git
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Clone URL if valid GitHub repo, None otherwise
|
|
464
|
+
"""
|
|
465
|
+
import re
|
|
466
|
+
|
|
467
|
+
url = url.strip()
|
|
468
|
+
|
|
469
|
+
# https://github.com/user/repo or https://github.com/user/repo.git
|
|
470
|
+
match = re.match(r"^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?$", url)
|
|
471
|
+
if match:
|
|
472
|
+
return f"https://github.com/{match.group(1)}/{match.group(2)}.git"
|
|
473
|
+
|
|
474
|
+
# git@github.com:user/repo
|
|
475
|
+
match = re.match(r"^git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url)
|
|
476
|
+
if match:
|
|
477
|
+
return f"git@github.com:{match.group(1)}/{match.group(2)}.git"
|
|
478
|
+
|
|
479
|
+
# github.com/user/repo (shorthand)
|
|
480
|
+
match = re.match(r"^github\.com/([^/]+)/([^/]+)$", url)
|
|
481
|
+
if match:
|
|
482
|
+
return f"https://github.com/{match.group(1)}/{match.group(2)}.git"
|
|
483
|
+
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _clone_github_repo(github_url: str) -> Path | None:
|
|
488
|
+
"""Clone a GitHub repo to a temporary directory.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
github_url: GitHub clone URL
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Path to cloned repo, or None on failure
|
|
495
|
+
"""
|
|
496
|
+
import tempfile
|
|
497
|
+
|
|
498
|
+
ui.console.print(f"[dim]Cloning {github_url}...[/dim]")
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
temp_dir = tempfile.mkdtemp(prefix="dotman_import_")
|
|
502
|
+
result = subprocess.run(
|
|
503
|
+
["git", "clone", "--mirror", github_url, temp_dir],
|
|
504
|
+
capture_output=True,
|
|
505
|
+
text=True,
|
|
506
|
+
timeout=60,
|
|
507
|
+
)
|
|
508
|
+
if result.returncode != 0:
|
|
509
|
+
ui.error(f"Failed to clone: {result.stderr}")
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
# Get the actual repo path (git clone --mirror creates a bare repo)
|
|
513
|
+
# We need to convert it to a working repo
|
|
514
|
+
# Clone again to get a working copy
|
|
515
|
+
work_dir = tempfile.mkdtemp(prefix="dotman_working_")
|
|
516
|
+
result = subprocess.run(
|
|
517
|
+
["git", "clone", temp_dir, work_dir],
|
|
518
|
+
capture_output=True,
|
|
519
|
+
text=True,
|
|
520
|
+
timeout=60,
|
|
521
|
+
)
|
|
522
|
+
if result.returncode != 0:
|
|
523
|
+
ui.error(f"Failed to clone working copy: {result.stderr}")
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
return Path(work_dir)
|
|
527
|
+
except subprocess.TimeoutExpired:
|
|
528
|
+
ui.error("Clone timed out")
|
|
529
|
+
return None
|
|
530
|
+
except Exception as e:
|
|
531
|
+
ui.error(f"Clone failed: {e}")
|
|
532
|
+
return None
|
dot_man/cli/interface.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""CLI Interface definition.
|
|
2
|
+
|
|
3
|
+
This module defines the main Click group to avoid circular imports
|
|
4
|
+
when subcommands need to decorate themselves with @main.command().
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from .. import __version__, ui
|
|
12
|
+
from ..constants import DOT_MAN_DIR
|
|
13
|
+
from .common import DotManGroup
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group(cls=DotManGroup)
|
|
17
|
+
@click.version_option(version=__version__, prog_name="dot-man")
|
|
18
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output on console")
|
|
19
|
+
@click.option("--debug", is_flag=True, help="Enable debug logging to file")
|
|
20
|
+
@click.pass_context
|
|
21
|
+
def cli(ctx, verbose: bool, debug: bool):
|
|
22
|
+
"""dot-man: The Dotfile Manager for Professionals."""
|
|
23
|
+
# Ensure config dir exists for logs
|
|
24
|
+
if not DOT_MAN_DIR.exists():
|
|
25
|
+
try:
|
|
26
|
+
DOT_MAN_DIR.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
except OSError:
|
|
28
|
+
pass # Init command will handle main creation
|
|
29
|
+
|
|
30
|
+
log_file = DOT_MAN_DIR / "dot-man.log"
|
|
31
|
+
level = logging.DEBUG if (debug or verbose) else logging.INFO
|
|
32
|
+
|
|
33
|
+
# Configure file logging
|
|
34
|
+
logging.basicConfig(
|
|
35
|
+
filename=str(log_file),
|
|
36
|
+
level=level,
|
|
37
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
38
|
+
filemode="a",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# If verbose, also log to console
|
|
42
|
+
if verbose:
|
|
43
|
+
console_handler = logging.StreamHandler()
|
|
44
|
+
console_handler.setLevel(logging.DEBUG)
|
|
45
|
+
console_handler.setFormatter(
|
|
46
|
+
logging.Formatter("[dim]%(levelname)s:[/dim] %(message)s")
|
|
47
|
+
)
|
|
48
|
+
logging.getLogger().addHandler(console_handler)
|
|
49
|
+
ui.console.print("[dim]Verbose mode enabled[/dim]")
|
|
50
|
+
elif debug:
|
|
51
|
+
ui.console.print("[dim]Debug logging enabled (see dot-man.log)[/dim]")
|
|
52
|
+
|
|
53
|
+
# Store flags in context for subcommands
|
|
54
|
+
ctx.ensure_object(dict)
|
|
55
|
+
ctx.obj["DEBUG"] = debug
|
|
56
|
+
ctx.obj["VERBOSE"] = verbose
|