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.
- dotx-3.1.0/MANIFEST.in +3 -0
- {dotx-2.2.0/src/dotx.egg-info → dotx-3.1.0}/PKG-INFO +132 -7
- {dotx-2.2.0 → dotx-3.1.0}/README.md +131 -6
- {dotx-2.2.0 → dotx-3.1.0}/pyproject.toml +1 -1
- dotx-3.1.0/src/dotx/always-create +23 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/cli.py +2 -1
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/commands/database.py +55 -29
- dotx-3.1.0/src/dotx/commands/path_cmd.py +108 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/database.py +102 -34
- dotx-3.1.0/src/dotx/dotxignore +61 -0
- dotx-3.1.0/src/dotx/hierarchy.py +136 -0
- dotx-3.1.0/src/dotx/ignore.py +144 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/install.py +96 -12
- dotx-3.1.0/src/dotx/installed-schema.sql +26 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/plan.py +6 -2
- {dotx-2.2.0 → dotx-3.1.0/src/dotx.egg-info}/PKG-INFO +132 -7
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx.egg-info/SOURCES.txt +6 -0
- dotx-3.1.0/tests/test_always_create.py +362 -0
- {dotx-2.2.0 → dotx-3.1.0}/tests/test_cli.py +7 -7
- {dotx-2.2.0 → dotx-3.1.0}/tests/test_cli_database.py +148 -30
- {dotx-2.2.0 → dotx-3.1.0}/tests/test_ignore.py +4 -4
- dotx-3.1.0/tests/test_ignore_rules.py +249 -0
- dotx-3.1.0/tests/test_install.py +545 -0
- dotx-3.1.0/tests/test_path_which.py +228 -0
- dotx-2.2.0/MANIFEST.in +0 -1
- dotx-2.2.0/src/dotx/ignore.py +0 -206
- dotx-2.2.0/src/dotx/installed-schema.sql +0 -23
- dotx-2.2.0/tests/test_ignore_rules.py +0 -241
- dotx-2.2.0/tests/test_install.py +0 -519
- {dotx-2.2.0 → dotx-3.1.0}/LICENSE +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/setup.cfg +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/__init__.py +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/commands/__init__.py +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/commands/install_cmd.py +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/commands/uninstall_cmd.py +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/options.py +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx/uninstall.py +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx.egg-info/dependency_links.txt +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx.egg-info/entry_points.txt +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx.egg-info/requires.txt +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/src/dotx.egg-info/top_level.txt +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/tests/test_options.py +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/tests/test_plan.py +0 -0
- {dotx-2.2.0 → dotx-3.1.0}/tests/test_uninstall.py +0 -0
dotx-3.1.0/MANIFEST.in
ADDED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotx
|
|
3
|
-
Version:
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
134
|
-
issues = db.verify_installations(
|
|
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]{
|
|
152
|
+
console.print(f"\n[bold cyan]{pkg_name}:[/bold cyan]")
|
|
137
153
|
for issue in issues:
|
|
138
|
-
console.print(f" [red]✗[/red] {issue[
|
|
139
|
-
console.print(f" [dim]Issue: {issue[
|
|
140
|
-
console.print(f" [dim]Expected: {issue[
|
|
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(
|
|
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 {
|
|
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] {
|
|
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[
|
|
191
|
-
install[
|
|
192
|
-
install[
|
|
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
|
-
|
|
371
|
-
|
|
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" {
|
|
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} -> {
|
|
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
|
-
|
|
419
|
-
|
|
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 {
|
|
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)
|