dotx 2.2.0__tar.gz → 3.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. dotx-3.1.0/MANIFEST.in +3 -0
  2. {dotx-2.2.0/src/dotx.egg-info → dotx-3.1.0}/PKG-INFO +132 -7
  3. {dotx-2.2.0 → dotx-3.1.0}/README.md +131 -6
  4. {dotx-2.2.0 → dotx-3.1.0}/pyproject.toml +1 -1
  5. dotx-3.1.0/src/dotx/always-create +23 -0
  6. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/cli.py +2 -1
  7. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/commands/database.py +55 -29
  8. dotx-3.1.0/src/dotx/commands/path_cmd.py +108 -0
  9. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/database.py +102 -34
  10. dotx-3.1.0/src/dotx/dotxignore +61 -0
  11. dotx-3.1.0/src/dotx/hierarchy.py +136 -0
  12. dotx-3.1.0/src/dotx/ignore.py +144 -0
  13. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/install.py +96 -12
  14. dotx-3.1.0/src/dotx/installed-schema.sql +26 -0
  15. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/plan.py +6 -2
  16. {dotx-2.2.0 → dotx-3.1.0/src/dotx.egg-info}/PKG-INFO +132 -7
  17. {dotx-2.2.0 → dotx-3.1.0}/src/dotx.egg-info/SOURCES.txt +6 -0
  18. dotx-3.1.0/tests/test_always_create.py +362 -0
  19. {dotx-2.2.0 → dotx-3.1.0}/tests/test_cli.py +7 -7
  20. {dotx-2.2.0 → dotx-3.1.0}/tests/test_cli_database.py +148 -30
  21. {dotx-2.2.0 → dotx-3.1.0}/tests/test_ignore.py +4 -4
  22. dotx-3.1.0/tests/test_ignore_rules.py +249 -0
  23. dotx-3.1.0/tests/test_install.py +545 -0
  24. dotx-3.1.0/tests/test_path_which.py +228 -0
  25. dotx-2.2.0/MANIFEST.in +0 -1
  26. dotx-2.2.0/src/dotx/ignore.py +0 -206
  27. dotx-2.2.0/src/dotx/installed-schema.sql +0 -23
  28. dotx-2.2.0/tests/test_ignore_rules.py +0 -241
  29. dotx-2.2.0/tests/test_install.py +0 -519
  30. {dotx-2.2.0 → dotx-3.1.0}/LICENSE +0 -0
  31. {dotx-2.2.0 → dotx-3.1.0}/setup.cfg +0 -0
  32. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/__init__.py +0 -0
  33. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/commands/__init__.py +0 -0
  34. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/commands/install_cmd.py +0 -0
  35. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/commands/uninstall_cmd.py +0 -0
  36. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/options.py +0 -0
  37. {dotx-2.2.0 → dotx-3.1.0}/src/dotx/uninstall.py +0 -0
  38. {dotx-2.2.0 → dotx-3.1.0}/src/dotx.egg-info/dependency_links.txt +0 -0
  39. {dotx-2.2.0 → dotx-3.1.0}/src/dotx.egg-info/entry_points.txt +0 -0
  40. {dotx-2.2.0 → dotx-3.1.0}/src/dotx.egg-info/requires.txt +0 -0
  41. {dotx-2.2.0 → dotx-3.1.0}/src/dotx.egg-info/top_level.txt +0 -0
  42. {dotx-2.2.0 → dotx-3.1.0}/tests/test_options.py +0 -0
  43. {dotx-2.2.0 → dotx-3.1.0}/tests/test_plan.py +0 -0
  44. {dotx-2.2.0 → dotx-3.1.0}/tests/test_uninstall.py +0 -0
dotx-3.1.0/MANIFEST.in ADDED
@@ -0,0 +1,3 @@
1
+ include src/dotx/installed-schema.sql
2
+ include src/dotx/always-create
3
+ include src/dotx/dotxignore
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 2.2.0
3
+ Version: 3.1.0
4
4
  Summary: A command-line tool to install a link-farm to your dotfiles
5
5
  Author-email: Wolf <Wolf@zv.cx>
6
6
  License-Expression: MIT
@@ -28,6 +28,8 @@ Dynamic: license-file
28
28
 
29
29
  ## The Basic Idea
30
30
 
31
+ > **Comparing dotfile managers?** See [ALTERNATIVES.md](ALTERNATIVES.md) for a detailed comparison of dotx vs GNU Stow, chezmoi, YADM, dotbot, and others.
32
+
31
33
  ### What `dotx` does; what it's _for_
32
34
  #### The problem
33
35
 
@@ -76,6 +78,8 @@ Commands:
76
78
  list List all installed packages
77
79
  verify Verify installations against filesystem
78
80
  show Show detailed installation information for a package
81
+ path Get source path of an installed package
82
+ which Find which package owns a target file
79
83
  sync Rebuild database from filesystem (interactive)
80
84
  ```
81
85
  So if you had a source package (a directory containing files) named `"bash"` containing `"dot-bashrc"` and
@@ -239,6 +243,52 @@ Installations:
239
243
  ...
240
244
  ```
241
245
 
246
+ #### Get package source path
247
+
248
+ Get the source path of an installed package for composition with other Unix tools:
249
+
250
+ ```bash
251
+ +$ dotx path bash
252
+ /Users/wolf/builds/dotfiles/bash
253
+
254
+ # Compose with other tools
255
+ +$ tree $(dotx path bash)
256
+ /Users/wolf/builds/dotfiles/bash
257
+ ├── dot-bash_profile
258
+ ├── dot-bash_topics.d
259
+ └── dot-bashrc
260
+
261
+ +$ cd $(dotx path vim)
262
+
263
+ +$ ls -la $(dotx path helix)
264
+ ```
265
+
266
+ The command prints the source directory path(s) where package files are located. If a package has files from multiple source directories (like the shells example with subdirectories), all unique paths are printed, one per line.
267
+
268
+ Exit codes: 0 if package found, 1 if not found.
269
+
270
+ #### Find which package owns a file
271
+
272
+ Find which package installed a specific file:
273
+
274
+ ```bash
275
+ +$ dotx which ~/.bashrc
276
+ bash
277
+
278
+ +$ dotx which ~/.config/helix/config.toml
279
+ helix
280
+
281
+ # Compose with other commands
282
+ +$ dotx path $(dotx which ~/.vimrc)
283
+ /Users/wolf/builds/dotfiles/vim
284
+
285
+ +$ tree $(dotx path $(dotx which ~/.zshrc))
286
+ ```
287
+
288
+ Simple output (just the package name) for easy composition with other Unix tools.
289
+
290
+ Exit codes: 0 if file found, 1 if not managed by any package.
291
+
242
292
  #### Rebuild database from filesystem
243
293
 
244
294
  If you have existing dotfile installations and an empty or missing database, use `sync` to rebuild it.
@@ -330,6 +380,75 @@ Continue? [y/N]: y
330
380
 
331
381
  **Note:** The `sync` command is **additive** - it updates existing entries and adds new ones, but doesn't delete entries for packages not found. This means running sync with `--package-root` won't remove other packages from your database.
332
382
 
383
+ ### Shared Directories: How `.config` and Friends Work
384
+
385
+ When multiple packages need to install files into the same directory (like `~/.config`), that directory must be a **real directory**, not a symlink. Otherwise, only one package could use it.
386
+
387
+ `dotx` automatically handles this using **always-create patterns** - directories that are always created as real directories instead of being symlinked.
388
+
389
+ #### Built-in Always-Create Patterns
390
+
391
+ These directories are automatically created as real directories:
392
+
393
+ **XDG Base Directories:**
394
+ - `.config` - Application configuration files
395
+ - `.local` - User-local data
396
+ - `.local/share` - User-specific data files
397
+ - `.local/bin` - User-specific executables
398
+ - `.cache` - Non-essential cached data
399
+
400
+ **Security-Sensitive Directories:**
401
+ - `.ssh` - SSH keys and configuration
402
+ - `.gnupg` - GPG keys and configuration
403
+
404
+ For example, if you have both a `vim` and `helix` package that install into `.config`:
405
+
406
+ ```bash
407
+ +$ tree -L 2 dotfiles/
408
+ dotfiles/
409
+ ├── vim/
410
+ │ └── dot-config/
411
+ │ └── nvim/
412
+ └── helix/
413
+ └── dot-config/
414
+ └── helix/
415
+
416
+ +$ dotx install vim helix
417
+
418
+ +$ ls -la ~/.config/
419
+ drwxr-xr-x .config/ # Real directory (not a symlink!)
420
+ lrwxr-xr-x nvim -> .../vim/dot-config/nvim/
421
+ lrwxr-xr-x helix -> .../helix/dot-config/helix/
422
+ ```
423
+
424
+ Notice that `.config` itself is a real directory, allowing both packages to install their subdirectories as symlinks within it.
425
+
426
+ #### Custom Always-Create Patterns (Advanced)
427
+
428
+ For most users, the built-in patterns are sufficient. However, if you have custom shared directories, you can create `.always-create` files using the same syntax as `.dotxignore`:
429
+
430
+ **Package-local** (in your package directory):
431
+ ```bash
432
+ +$ cat > mypackage/.always-create <<EOF
433
+ # Custom shared directory
434
+ .myapp
435
+ EOF
436
+ ```
437
+
438
+ **User-global** (applies to all packages):
439
+ ```bash
440
+ +$ mkdir -p ~/.config/dotx
441
+ +$ cat > ~/.config/dotx/always-create <<EOF
442
+ # My custom shared directories
443
+ .workspace
444
+ .tools
445
+ EOF
446
+ ```
447
+
448
+ Pattern precedence: **built-in → user global → package-local** (later patterns can override earlier ones using `!negation`)
449
+
450
+ **Note:** Patterns use leading `/` for root-level matching (e.g., `/.config` matches only `.config` at package root, not `subdir/.config`).
451
+
333
452
  ##### Cleaning Orphaned Entries with `--clean`
334
453
 
335
454
  Over time, your database may accumulate **orphaned entries** - records for symlinks that no longer exist on the filesystem. Use `--clean` to remove these automatically, similar to `git fetch --prune`:
@@ -450,10 +569,16 @@ This removes:
450
569
 
451
570
  **Note:** Add `.` at the end (`git clean -fdx .`) to limit cleanup to the current directory only.
452
571
 
453
- ### What's next
572
+ ### Shell Completions
573
+
574
+ dotx includes automatic shell completion via Typer:
575
+
576
+ ```bash
577
+ # Install completion for your shell
578
+ dotx --install-completion
579
+
580
+ # Or show the completion script to customize it
581
+ dotx --show-completion
582
+ ```
454
583
 
455
- Potential future enhancements:
456
- * Support for templates and variable substitution in dotfiles
457
- * Hooks system for running commands before/after installation
458
- * Conflict resolution strategies for overlapping packages
459
- * Shell completions for bash/zsh/fish
584
+ Supports Bash, Zsh, Fish, and PowerShell automatically.
@@ -1,5 +1,7 @@
1
1
  ## The Basic Idea
2
2
 
3
+ > **Comparing dotfile managers?** See [ALTERNATIVES.md](ALTERNATIVES.md) for a detailed comparison of dotx vs GNU Stow, chezmoi, YADM, dotbot, and others.
4
+
3
5
  ### What `dotx` does; what it's _for_
4
6
  #### The problem
5
7
 
@@ -48,6 +50,8 @@ Commands:
48
50
  list List all installed packages
49
51
  verify Verify installations against filesystem
50
52
  show Show detailed installation information for a package
53
+ path Get source path of an installed package
54
+ which Find which package owns a target file
51
55
  sync Rebuild database from filesystem (interactive)
52
56
  ```
53
57
  So if you had a source package (a directory containing files) named `"bash"` containing `"dot-bashrc"` and
@@ -211,6 +215,52 @@ Installations:
211
215
  ...
212
216
  ```
213
217
 
218
+ #### Get package source path
219
+
220
+ Get the source path of an installed package for composition with other Unix tools:
221
+
222
+ ```bash
223
+ +$ dotx path bash
224
+ /Users/wolf/builds/dotfiles/bash
225
+
226
+ # Compose with other tools
227
+ +$ tree $(dotx path bash)
228
+ /Users/wolf/builds/dotfiles/bash
229
+ ├── dot-bash_profile
230
+ ├── dot-bash_topics.d
231
+ └── dot-bashrc
232
+
233
+ +$ cd $(dotx path vim)
234
+
235
+ +$ ls -la $(dotx path helix)
236
+ ```
237
+
238
+ The command prints the source directory path(s) where package files are located. If a package has files from multiple source directories (like the shells example with subdirectories), all unique paths are printed, one per line.
239
+
240
+ Exit codes: 0 if package found, 1 if not found.
241
+
242
+ #### Find which package owns a file
243
+
244
+ Find which package installed a specific file:
245
+
246
+ ```bash
247
+ +$ dotx which ~/.bashrc
248
+ bash
249
+
250
+ +$ dotx which ~/.config/helix/config.toml
251
+ helix
252
+
253
+ # Compose with other commands
254
+ +$ dotx path $(dotx which ~/.vimrc)
255
+ /Users/wolf/builds/dotfiles/vim
256
+
257
+ +$ tree $(dotx path $(dotx which ~/.zshrc))
258
+ ```
259
+
260
+ Simple output (just the package name) for easy composition with other Unix tools.
261
+
262
+ Exit codes: 0 if file found, 1 if not managed by any package.
263
+
214
264
  #### Rebuild database from filesystem
215
265
 
216
266
  If you have existing dotfile installations and an empty or missing database, use `sync` to rebuild it.
@@ -302,6 +352,75 @@ Continue? [y/N]: y
302
352
 
303
353
  **Note:** The `sync` command is **additive** - it updates existing entries and adds new ones, but doesn't delete entries for packages not found. This means running sync with `--package-root` won't remove other packages from your database.
304
354
 
355
+ ### Shared Directories: How `.config` and Friends Work
356
+
357
+ When multiple packages need to install files into the same directory (like `~/.config`), that directory must be a **real directory**, not a symlink. Otherwise, only one package could use it.
358
+
359
+ `dotx` automatically handles this using **always-create patterns** - directories that are always created as real directories instead of being symlinked.
360
+
361
+ #### Built-in Always-Create Patterns
362
+
363
+ These directories are automatically created as real directories:
364
+
365
+ **XDG Base Directories:**
366
+ - `.config` - Application configuration files
367
+ - `.local` - User-local data
368
+ - `.local/share` - User-specific data files
369
+ - `.local/bin` - User-specific executables
370
+ - `.cache` - Non-essential cached data
371
+
372
+ **Security-Sensitive Directories:**
373
+ - `.ssh` - SSH keys and configuration
374
+ - `.gnupg` - GPG keys and configuration
375
+
376
+ For example, if you have both a `vim` and `helix` package that install into `.config`:
377
+
378
+ ```bash
379
+ +$ tree -L 2 dotfiles/
380
+ dotfiles/
381
+ ├── vim/
382
+ │ └── dot-config/
383
+ │ └── nvim/
384
+ └── helix/
385
+ └── dot-config/
386
+ └── helix/
387
+
388
+ +$ dotx install vim helix
389
+
390
+ +$ ls -la ~/.config/
391
+ drwxr-xr-x .config/ # Real directory (not a symlink!)
392
+ lrwxr-xr-x nvim -> .../vim/dot-config/nvim/
393
+ lrwxr-xr-x helix -> .../helix/dot-config/helix/
394
+ ```
395
+
396
+ Notice that `.config` itself is a real directory, allowing both packages to install their subdirectories as symlinks within it.
397
+
398
+ #### Custom Always-Create Patterns (Advanced)
399
+
400
+ For most users, the built-in patterns are sufficient. However, if you have custom shared directories, you can create `.always-create` files using the same syntax as `.dotxignore`:
401
+
402
+ **Package-local** (in your package directory):
403
+ ```bash
404
+ +$ cat > mypackage/.always-create <<EOF
405
+ # Custom shared directory
406
+ .myapp
407
+ EOF
408
+ ```
409
+
410
+ **User-global** (applies to all packages):
411
+ ```bash
412
+ +$ mkdir -p ~/.config/dotx
413
+ +$ cat > ~/.config/dotx/always-create <<EOF
414
+ # My custom shared directories
415
+ .workspace
416
+ .tools
417
+ EOF
418
+ ```
419
+
420
+ Pattern precedence: **built-in → user global → package-local** (later patterns can override earlier ones using `!negation`)
421
+
422
+ **Note:** Patterns use leading `/` for root-level matching (e.g., `/.config` matches only `.config` at package root, not `subdir/.config`).
423
+
305
424
  ##### Cleaning Orphaned Entries with `--clean`
306
425
 
307
426
  Over time, your database may accumulate **orphaned entries** - records for symlinks that no longer exist on the filesystem. Use `--clean` to remove these automatically, similar to `git fetch --prune`:
@@ -422,10 +541,16 @@ This removes:
422
541
 
423
542
  **Note:** Add `.` at the end (`git clean -fdx .`) to limit cleanup to the current directory only.
424
543
 
425
- ### What's next
544
+ ### Shell Completions
545
+
546
+ dotx includes automatic shell completion via Typer:
547
+
548
+ ```bash
549
+ # Install completion for your shell
550
+ dotx --install-completion
551
+
552
+ # Or show the completion script to customize it
553
+ dotx --show-completion
554
+ ```
426
555
 
427
- Potential future enhancements:
428
- * Support for templates and variable substitution in dotfiles
429
- * Hooks system for running commands before/after installation
430
- * Conflict resolution strategies for overlapping packages
431
- * Shell completions for bash/zsh/fish
556
+ Supports Bash, Zsh, Fish, and PowerShell automatically.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dotx"
3
- version = "2.2.0"
3
+ version = "3.1.0"
4
4
  description = "A command-line tool to install a link-farm to your dotfiles"
5
5
  authors = [
6
6
  { name = "Wolf", email = "Wolf@zv.cx" }
@@ -0,0 +1,23 @@
1
+ # Default patterns for directories that must be created (real directories, never symlinked)
2
+ #
3
+ # These are common shared directories where multiple programs/packages install files.
4
+ # If these were symlinks, only one package could use them.
5
+ #
6
+ # This file is shipped with dotx and can be overridden by:
7
+ # ~/.config/dotx/always-create (user-specific additions)
8
+ # <package>/.always-create (package-specific rules)
9
+ #
10
+ # Uses gitignore-style patterns with leading / to match only at root level:
11
+ # /.config - match .config at root only (not .config/subdir)
12
+ # /.local/share - match .local/share only (not .local/share/applications)
13
+
14
+ # XDG Base Directory specification directories
15
+ /.config
16
+ /.local
17
+ /.local/share
18
+ /.local/bin
19
+ /.cache
20
+
21
+ # Other common shared directories
22
+ /.ssh
23
+ /.gnupg
@@ -107,11 +107,12 @@ def main(
107
107
 
108
108
 
109
109
  # Register commands from submodules
110
- from dotx.commands import install_cmd, uninstall_cmd, database
110
+ from dotx.commands import install_cmd, uninstall_cmd, database, path_cmd
111
111
 
112
112
  install_cmd.register_command(app)
113
113
  uninstall_cmd.register_command(app)
114
114
  database.register_commands(app)
115
+ path_cmd.register_commands(app)
115
116
 
116
117
 
117
118
  def cli():
@@ -1,5 +1,6 @@
1
1
  """Database-related commands for dotx CLI (list, verify, show, sync)."""
2
2
 
3
+ from datetime import datetime
3
4
  from pathlib import Path
4
5
  from typing import Annotated, Optional
5
6
 
@@ -14,6 +15,23 @@ from dotx.database import InstallationDB
14
15
  from dotx.options import is_verbose_mode
15
16
 
16
17
 
18
+ def _format_timestamp(iso_timestamp: str | None) -> str:
19
+ """
20
+ Format an ISO timestamp for display.
21
+
22
+ Converts ISO 8601 timestamp to readable format: YYYY-MM-DD HH:MM:SS
23
+ Returns 'unknown' if timestamp is None or invalid.
24
+ """
25
+ if not iso_timestamp:
26
+ return "unknown"
27
+
28
+ try:
29
+ dt = datetime.fromisoformat(iso_timestamp)
30
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
31
+ except (ValueError, AttributeError):
32
+ return "unknown"
33
+
34
+
17
35
  def _scan_symlinks(path: Path, max_depth: int, progress: Progress, task_id) -> list[tuple[Path, bool]]:
18
36
  """
19
37
  Scan a directory for symlinks up to a maximum depth.
@@ -73,7 +91,9 @@ def register_commands(app: typer.Typer):
73
91
  if as_commands:
74
92
  # Output as dotx install commands (plain text, no formatting)
75
93
  for pkg in packages:
76
- typer.echo(f"dotx install {pkg['package_name']}")
94
+ # Use package_root / package_name to construct install path
95
+ package_path = Path(pkg["package_root"]) / pkg["package_name"]
96
+ typer.echo(f"dotx install {package_path}")
77
97
  else:
78
98
  # Output as rich table
79
99
  table = Table(title="Installed Packages", show_header=True, header_style="bold cyan")
@@ -82,13 +102,9 @@ def register_commands(app: typer.Typer):
82
102
  table.add_column("Last Install", style="green")
83
103
 
84
104
  for pkg in packages:
85
- package_name = Path(pkg["package_name"]).name
105
+ package_name = pkg["package_name"] # Already the semantic name
86
106
  file_count = str(pkg["file_count"])
87
- latest = (
88
- pkg["latest_install"][:19]
89
- if pkg["latest_install"]
90
- else "[dim]unknown[/dim]"
91
- )
107
+ latest = _format_timestamp(pkg["latest_install"])
92
108
  table.add_row(package_name, file_count, latest)
93
109
 
94
110
  console.print()
@@ -117,12 +133,12 @@ def register_commands(app: typer.Typer):
117
133
  with InstallationDB() as db:
118
134
  if package:
119
135
  # Verify specific package
120
- packages_to_verify = [package]
136
+ packages_to_verify = [(package.parent, package.name)]
121
137
  else:
122
138
  # Verify all packages
123
139
  all_packages = db.get_all_packages()
124
140
  packages_to_verify = [
125
- Path(pkg["package_name"]) for pkg in all_packages
141
+ (Path(pkg["package_root"]), pkg["package_name"]) for pkg in all_packages
126
142
  ]
127
143
 
128
144
  if not packages_to_verify:
@@ -130,14 +146,14 @@ def register_commands(app: typer.Typer):
130
146
  return
131
147
 
132
148
  total_issues = 0
133
- for pkg in packages_to_verify:
134
- issues = db.verify_installations(pkg)
149
+ for pkg_root, pkg_name in packages_to_verify:
150
+ issues = db.verify_installations(pkg_root, pkg_name)
135
151
  if issues:
136
- console.print(f"\n[bold cyan]{pkg.name}:[/bold cyan]")
152
+ console.print(f"\n[bold cyan]{pkg_name}:[/bold cyan]")
137
153
  for issue in issues:
138
- console.print(f" [red]✗[/red] {issue['target_path']}")
139
- console.print(f" [dim]Issue: {issue['issue']}[/dim]")
140
- console.print(f" [dim]Expected: {issue['link_type']}[/dim]")
154
+ console.print(f" [red]✗[/red] {issue["target_path"]}")
155
+ console.print(f" [dim]Issue: {issue["issue"]}[/dim]")
156
+ console.print(f" [dim]Expected: {issue["link_type"]}[/dim]")
141
157
  total_issues += len(issues)
142
158
 
143
159
  if total_issues == 0:
@@ -164,15 +180,19 @@ def register_commands(app: typer.Typer):
164
180
  logger.info("show starting")
165
181
  console = Console()
166
182
 
183
+ # Extract package info
184
+ package_root = package.parent
185
+ package_name = package.name
186
+
167
187
  with InstallationDB() as db:
168
- installations = db.get_installations(package)
188
+ installations = db.get_installations(package_root, package_name)
169
189
 
170
190
  if not installations:
171
- console.print(f"[yellow]No installations found for {package.name}[/yellow]")
191
+ console.print(f"[yellow]No installations found for {package_name}[/yellow]")
172
192
  return
173
193
 
174
194
  # Create info panel
175
- info = f"[bold cyan]Package:[/bold cyan] {package}\n"
195
+ info = f"[bold cyan]Package:[/bold cyan] {package_name}\n"
176
196
  info += f"[bold cyan]Installed files:[/bold cyan] {len(installations)}"
177
197
 
178
198
  panel = Panel(info, title="Package Information", border_style="cyan")
@@ -187,9 +207,9 @@ def register_commands(app: typer.Typer):
187
207
 
188
208
  for install in installations:
189
209
  table.add_row(
190
- str(install['target_path']),
191
- install['link_type'],
192
- install['installed_at'][:19] if install['installed_at'] else "unknown"
210
+ str(install["target_path"]),
211
+ install["link_type"],
212
+ _format_timestamp(install["installed_at"])
193
213
  )
194
214
 
195
215
  console.print()
@@ -367,11 +387,12 @@ def register_commands(app: typer.Typer):
367
387
  total_would_clean = 0
368
388
 
369
389
  for pkg_info in all_packages:
370
- pkg_path = Path(pkg_info["package_name"])
371
- orphaned = db.get_orphaned_entries(pkg_path)
390
+ pkg_root = Path(pkg_info["package_root"])
391
+ pkg_name = pkg_info["package_name"]
392
+ orphaned = db.get_orphaned_entries(pkg_root, pkg_name)
372
393
  if orphaned:
373
394
  total_would_clean += len(orphaned)
374
- console.print(f" {pkg_path.name}: {len(orphaned)} orphaned entry(ies)")
395
+ console.print(f" {pkg_name}: {len(orphaned)} orphaned entry(ies)")
375
396
 
376
397
  if total_would_clean > 0:
377
398
  console.print(f"[yellow]Would remove {total_would_clean} orphaned entry(ies).[/yellow]")
@@ -394,6 +415,10 @@ def register_commands(app: typer.Typer):
394
415
  total_recorded = 0
395
416
 
396
417
  for package_path, links in packages.items():
418
+ # Extract package info for database
419
+ package_root = package_path.parent
420
+ package_name = package_path.name
421
+
397
422
  for link_path, resolved, is_dir in links:
398
423
  # Determine link type
399
424
  if is_dir:
@@ -402,9 +427,9 @@ def register_commands(app: typer.Typer):
402
427
  link_type = "file"
403
428
 
404
429
  # Record in database
405
- db.record_installation(package_path, link_path, link_type)
430
+ db.record_installation(package_root, package_name, package_path, link_path, link_type)
406
431
  total_recorded += 1
407
- logger.debug(f"Recorded {link_path} -> {package_path}")
432
+ logger.debug(f"Recorded {link_path} -> {package_name}")
408
433
 
409
434
  console.print(f"\n[green]✓ Recorded {total_recorded} installation(s) in database.[/green]")
410
435
 
@@ -415,12 +440,13 @@ def register_commands(app: typer.Typer):
415
440
  total_cleaned = 0
416
441
 
417
442
  for pkg_info in all_packages:
418
- pkg_path = Path(pkg_info["package_name"])
419
- cleaned = db.clean_orphaned_entries(pkg_path)
443
+ pkg_root = Path(pkg_info["package_root"])
444
+ pkg_name = pkg_info["package_name"]
445
+ cleaned = db.clean_orphaned_entries(pkg_root, pkg_name)
420
446
  if cleaned > 0:
421
447
  total_cleaned += cleaned
422
448
  if verbose:
423
- console.print(f" Cleaned {cleaned} orphaned entry(ies) from {pkg_path.name}")
449
+ console.print(f" Cleaned {cleaned} orphaned entry(ies) from {pkg_name}")
424
450
 
425
451
  if total_cleaned > 0:
426
452
  console.print(f"[green]✓ Removed {total_cleaned} orphaned entry(ies).[/green]")
@@ -0,0 +1,108 @@
1
+ """Path and which commands for dotx CLI - query installed packages."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from loguru import logger
8
+
9
+ from dotx.database import InstallationDB
10
+
11
+
12
+ def register_commands(app: typer.Typer):
13
+ """Register the path command with the Typer app."""
14
+
15
+ @app.command()
16
+ def path(
17
+ package: Annotated[
18
+ str,
19
+ typer.Argument(help="Package name to get path for"),
20
+ ],
21
+ package_root: Annotated[
22
+ Path | None,
23
+ typer.Option(help="Package root to disambiguate (if package exists in multiple roots)"),
24
+ ] = None,
25
+ ):
26
+ """
27
+ Print the source path of an installed package.
28
+
29
+ Prints the path(s) where the package files are located, one per line.
30
+ Useful for composition with other tools: tree $(dotx path bash)
31
+
32
+ Exit codes:
33
+ 0 - Package found, path(s) printed
34
+ 1 - Package not found
35
+ """
36
+ logger.info(f"path command for package: {package}")
37
+
38
+ with InstallationDB() as db:
39
+ # Get all packages
40
+ all_packages = db.get_all_packages()
41
+
42
+ # Filter by package name
43
+ matching = [
44
+ pkg
45
+ for pkg in all_packages
46
+ if pkg["package_name"] == package
47
+ and (package_root is None or Path(pkg["package_root"]).resolve() == package_root.resolve())
48
+ ]
49
+
50
+ if not matching:
51
+ if package_root:
52
+ logger.error(f"Package '{package}' not found in package_root {package_root}")
53
+ else:
54
+ logger.error(f"Package '{package}' not found")
55
+ raise typer.Exit(code=1)
56
+
57
+ # For each matching package, get unique source paths
58
+ for pkg in matching:
59
+ # Get installations for this package to find source paths
60
+ installations = db.get_installations(Path(pkg["package_root"]), pkg["package_name"])
61
+
62
+ # Collect unique source_package_root values
63
+ source_roots = sorted(set(install["source_package_root"] for install in installations))
64
+
65
+ # Print each unique source root
66
+ for source_root in source_roots:
67
+ typer.echo(source_root)
68
+
69
+ logger.info("path command finished")
70
+
71
+ @app.command()
72
+ def which(
73
+ target_file: Annotated[
74
+ Path,
75
+ typer.Argument(help="Target file to find package for"),
76
+ ],
77
+ ):
78
+ """
79
+ Print the package name that owns a target file.
80
+
81
+ Simple output for composition: dotx path $(dotx which ~/.bashrc)
82
+
83
+ Exit codes:
84
+ 0 - File found, package name printed
85
+ 1 - File not found in database
86
+ """
87
+ logger.info(f"which command for file: {target_file}")
88
+
89
+ # Make path absolute for database lookup
90
+ target_abs = target_file.absolute()
91
+
92
+ with InstallationDB() as db:
93
+ # Query database for this target path
94
+ # We need to search through all installations to find matching target_path
95
+ all_packages = db.get_all_packages()
96
+
97
+ for pkg in all_packages:
98
+ installations = db.get_installations(Path(pkg["package_root"]), pkg["package_name"])
99
+ for install in installations:
100
+ if Path(install["target_path"]) == target_abs:
101
+ # Found it - print package name and exit
102
+ typer.echo(pkg["package_name"])
103
+ logger.info("which command finished")
104
+ return
105
+
106
+ # Not found
107
+ logger.error(f"File '{target_file}' not managed by any package")
108
+ raise typer.Exit(code=1)