cli-tools-kit 0.8.4__tar.gz → 1.0.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.
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/PKG-INFO +59 -10
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/README.md +54 -8
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/__init__.py +1 -1
- cli_tools_kit-1.0.0/cli_tools_kit/autostart.py +284 -0
- cli_tools_kit-1.0.0/cli_tools_kit/cli.py +635 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/cron_installer.py +41 -23
- cli_tools_kit-1.0.0/cli_tools_kit/discovery.py +294 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/gui_installer.py +161 -2404
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/host.py +52 -1
- cli_tools_kit-1.0.0/cli_tools_kit/icons.py +423 -0
- cli_tools_kit-1.0.0/cli_tools_kit/install.py +298 -0
- cli_tools_kit-1.0.0/cli_tools_kit/settings.py +125 -0
- cli_tools_kit-1.0.0/cli_tools_kit/state.py +196 -0
- cli_tools_kit-1.0.0/cli_tools_kit/sweep.py +187 -0
- cli_tools_kit-1.0.0/cli_tools_kit/testing.py +126 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/tool_installer.py +10 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/tui_installer.py +7 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/PKG-INFO +59 -10
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/SOURCES.txt +15 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/pyproject.toml +10 -3
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_cron_installer.py +23 -0
- cli_tools_kit-1.0.0/tests/test_e2e.py +164 -0
- cli_tools_kit-1.0.0/tests/test_engine.py +182 -0
- cli_tools_kit-1.0.0/tests/test_gui_smoke.py +68 -0
- cli_tools_kit-1.0.0/tests/test_hooks.py +45 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_host.py +42 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_identity.py +22 -20
- cli_tools_kit-1.0.0/tests/test_public_api.py +117 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_sources.py +28 -17
- cli_tools_kit-1.0.0/tests/test_testing.py +57 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_tool_installer.py +22 -1
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_tui_installer.py +21 -2
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/LICENSE +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/__main__.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/advertise.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/autostart_gate.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/identity.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/onboarding.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/skills.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/sources.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/__init__.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/build.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/capability.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/cluster.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/corpus.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/embedder.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/groups.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/entry_points.txt +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/requires.txt +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/top_level.txt +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/setup.cfg +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_autostart_gate.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_capability_groups.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_llm_groups.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_skills.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.0.0}/tests/test_taxonomy_cluster.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cli-tools-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens
|
|
5
5
|
Author: Steffen Probst
|
|
6
6
|
License: MIT License
|
|
@@ -26,18 +26,21 @@ License: MIT License
|
|
|
26
26
|
SOFTWARE.
|
|
27
27
|
|
|
28
28
|
Project-URL: Homepage, https://github.com/Probst1nator/cli-tools-kit
|
|
29
|
+
Project-URL: Changelog, https://github.com/Probst1nator/cli-tools-kit/blob/main/CHANGELOG.md
|
|
29
30
|
Project-URL: Issues, https://github.com/Probst1nator/cli-tools-kit/issues
|
|
30
|
-
Keywords: installer,desktop,cron,cli,linux,kde,gnome
|
|
31
|
+
Keywords: installer,desktop,cron,cli,linux,windows,kde,gnome
|
|
31
32
|
Classifier: Development Status :: 4 - Beta
|
|
32
33
|
Classifier: Environment :: Console
|
|
33
34
|
Classifier: Intended Audience :: Developers
|
|
34
35
|
Classifier: License :: OSI Approved :: MIT License
|
|
35
36
|
Classifier: Operating System :: POSIX :: Linux
|
|
37
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
36
38
|
Classifier: Programming Language :: Python :: 3
|
|
37
39
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
38
40
|
Classifier: Programming Language :: Python :: 3.10
|
|
39
41
|
Classifier: Programming Language :: Python :: 3.11
|
|
40
42
|
Classifier: Programming Language :: Python :: 3.12
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
44
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
42
45
|
Classifier: Topic :: System :: Installation/Setup
|
|
43
46
|
Requires-Python: >=3.10
|
|
@@ -92,17 +95,42 @@ See [`PROTOCOL.md`](PROTOCOL.md) for the full `--advertise` specification.
|
|
|
92
95
|
pip install cli-tools-kit
|
|
93
96
|
```
|
|
94
97
|
|
|
95
|
-
Or pin in `requirements.txt`:
|
|
98
|
+
Or pin a range in `requirements.txt`:
|
|
96
99
|
|
|
97
100
|
```
|
|
98
|
-
cli-tools-kit
|
|
101
|
+
cli-tools-kit>=1.0,<2
|
|
99
102
|
```
|
|
100
103
|
|
|
104
|
+
Pin a range, not an exact version. An installer adds the kit to each tool's
|
|
105
|
+
own `pip install -r requirements.txt`, and two exact pins of one package on
|
|
106
|
+
one pip command line (`==0.6.0` from the tool, `==0.6.3` from the installer)
|
|
107
|
+
can never resolve. Two overlapping ranges always do.
|
|
108
|
+
|
|
101
109
|
The git URL form still works if you need an unreleased commit:
|
|
102
|
-
`pip install git+https://github.com/Probst1nator/cli-tools-kit.git@
|
|
110
|
+
`pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v1.0.0`.
|
|
111
|
+
|
|
112
|
+
Requires Python ≥ 3.10 and `termcolor` (installed with the package). Linux
|
|
113
|
+
and Windows are tested in CI.
|
|
114
|
+
|
|
115
|
+
## Stability
|
|
116
|
+
|
|
117
|
+
From 1.0.0 the kit follows [Semantic Versioning](https://semver.org/). The
|
|
118
|
+
public surface is what `tests/test_public_api.py` pins: the names in
|
|
119
|
+
`cli_tools_kit.__all__`, `ToolInstaller`/`ToolMetadata`/`CronInstaller` and
|
|
120
|
+
their methods, `gui_installer.run()` and its keywords, `InstallHooks`,
|
|
121
|
+
`sources.run_installer()`, `tui_installer.SkillTarget`,
|
|
122
|
+
`testing.assert_advertises()`, the taxonomy
|
|
123
|
+
functions a tree's regroup script calls, the installer's command-line flags,
|
|
124
|
+
and the `--advertise` JSON in [`PROTOCOL.md`](PROTOCOL.md).
|
|
103
125
|
|
|
104
|
-
|
|
105
|
-
|
|
126
|
+
- Removing or renaming anything there is a major release.
|
|
127
|
+
- A name that is going away warns for at least one minor release first.
|
|
128
|
+
- The `--advertise` schema only gains optional fields; a parent installer
|
|
129
|
+
ignores fields it does not know.
|
|
130
|
+
- Anything with a leading underscore, and every module-level name not listed
|
|
131
|
+
above, can change in any release.
|
|
132
|
+
|
|
133
|
+
Changes are listed in [`CHANGELOG.md`](CHANGELOG.md).
|
|
106
134
|
|
|
107
135
|
## Minimal example
|
|
108
136
|
|
|
@@ -276,6 +304,7 @@ Keyword-only; every argument defaults to `None`, meaning "leave the default".
|
|
|
276
304
|
| `wm_class` | identity's | `StartupWMClass` for window-manager grouping. |
|
|
277
305
|
| `notify_app` | identity's | `notify-send` application label on the `--check` path. |
|
|
278
306
|
| `autostart_check_desktop_name`, `check_log_name`, `check_state_name` | identity's | Login-check artifact filenames. |
|
|
307
|
+
| `hooks` | `None` | `InstallHooks(install_tool=…, remove_tool=…, install_skill=…, uninstall_skill=…)`: replace how one tool is installed, for a wrapper that builds a venv per tool. A field left `None` keeps the kit's own. |
|
|
279
308
|
|
|
280
309
|
The identity is applied first and these individual names override it, so you can
|
|
281
310
|
take the whole namespace from a slug and still change one thing.
|
|
@@ -380,7 +409,7 @@ and needs no network.
|
|
|
380
409
|
Icon thumbnails need Pillow:
|
|
381
410
|
|
|
382
411
|
```bash
|
|
383
|
-
pip install "cli-tools-kit[gui]
|
|
412
|
+
pip install "cli-tools-kit[gui]>=1.0,<2"
|
|
384
413
|
```
|
|
385
414
|
|
|
386
415
|
Installing the package also exposes a `cli-tool-installer` console script.
|
|
@@ -532,13 +561,33 @@ per repo it could not (`log=` takes any callable, `print` by default). Pass
|
|
|
532
561
|
Reading the TOML needs Python 3.11 or the `tomli` package, which is a dependency
|
|
533
562
|
on 3.10.
|
|
534
563
|
|
|
564
|
+
## Testing a tool's `--advertise`
|
|
565
|
+
|
|
566
|
+
A tool that breaks the protocol is skipped by the parent installer and simply
|
|
567
|
+
disappears from the list. Put this in the tool's own tests to catch that:
|
|
568
|
+
|
|
569
|
+
```python
|
|
570
|
+
from cli_tools_kit.testing import assert_advertises
|
|
571
|
+
|
|
572
|
+
def test_advertise():
|
|
573
|
+
assert_advertises("main.py")
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
It runs the probe the way a parent does (5 s limit, only JSON on stdout) and
|
|
577
|
+
fails with every problem it finds.
|
|
578
|
+
|
|
535
579
|
## Tests
|
|
536
580
|
|
|
537
581
|
```bash
|
|
538
|
-
pip install -e ".[dev]"
|
|
539
|
-
|
|
582
|
+
pip install -e ".[dev,gui]"
|
|
583
|
+
ruff check .
|
|
584
|
+
pytest # xvfb-run -a pytest on a host without a display
|
|
540
585
|
```
|
|
541
586
|
|
|
587
|
+
CI runs the same on Linux and Windows for Python 3.10 to 3.13, plus an
|
|
588
|
+
install from the built wheel. The GUI smoke test skips where tkinter or a
|
|
589
|
+
display is missing.
|
|
590
|
+
|
|
542
591
|
## Used by
|
|
543
592
|
|
|
544
593
|
Consumers, each a self-installing tool that answers `--advertise`:
|
|
@@ -35,17 +35,42 @@ See [`PROTOCOL.md`](PROTOCOL.md) for the full `--advertise` specification.
|
|
|
35
35
|
pip install cli-tools-kit
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Or pin in `requirements.txt`:
|
|
38
|
+
Or pin a range in `requirements.txt`:
|
|
39
39
|
|
|
40
40
|
```
|
|
41
|
-
cli-tools-kit
|
|
41
|
+
cli-tools-kit>=1.0,<2
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Pin a range, not an exact version. An installer adds the kit to each tool's
|
|
45
|
+
own `pip install -r requirements.txt`, and two exact pins of one package on
|
|
46
|
+
one pip command line (`==0.6.0` from the tool, `==0.6.3` from the installer)
|
|
47
|
+
can never resolve. Two overlapping ranges always do.
|
|
48
|
+
|
|
44
49
|
The git URL form still works if you need an unreleased commit:
|
|
45
|
-
`pip install git+https://github.com/Probst1nator/cli-tools-kit.git@
|
|
50
|
+
`pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v1.0.0`.
|
|
51
|
+
|
|
52
|
+
Requires Python ≥ 3.10 and `termcolor` (installed with the package). Linux
|
|
53
|
+
and Windows are tested in CI.
|
|
54
|
+
|
|
55
|
+
## Stability
|
|
56
|
+
|
|
57
|
+
From 1.0.0 the kit follows [Semantic Versioning](https://semver.org/). The
|
|
58
|
+
public surface is what `tests/test_public_api.py` pins: the names in
|
|
59
|
+
`cli_tools_kit.__all__`, `ToolInstaller`/`ToolMetadata`/`CronInstaller` and
|
|
60
|
+
their methods, `gui_installer.run()` and its keywords, `InstallHooks`,
|
|
61
|
+
`sources.run_installer()`, `tui_installer.SkillTarget`,
|
|
62
|
+
`testing.assert_advertises()`, the taxonomy
|
|
63
|
+
functions a tree's regroup script calls, the installer's command-line flags,
|
|
64
|
+
and the `--advertise` JSON in [`PROTOCOL.md`](PROTOCOL.md).
|
|
46
65
|
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
- Removing or renaming anything there is a major release.
|
|
67
|
+
- A name that is going away warns for at least one minor release first.
|
|
68
|
+
- The `--advertise` schema only gains optional fields; a parent installer
|
|
69
|
+
ignores fields it does not know.
|
|
70
|
+
- Anything with a leading underscore, and every module-level name not listed
|
|
71
|
+
above, can change in any release.
|
|
72
|
+
|
|
73
|
+
Changes are listed in [`CHANGELOG.md`](CHANGELOG.md).
|
|
49
74
|
|
|
50
75
|
## Minimal example
|
|
51
76
|
|
|
@@ -219,6 +244,7 @@ Keyword-only; every argument defaults to `None`, meaning "leave the default".
|
|
|
219
244
|
| `wm_class` | identity's | `StartupWMClass` for window-manager grouping. |
|
|
220
245
|
| `notify_app` | identity's | `notify-send` application label on the `--check` path. |
|
|
221
246
|
| `autostart_check_desktop_name`, `check_log_name`, `check_state_name` | identity's | Login-check artifact filenames. |
|
|
247
|
+
| `hooks` | `None` | `InstallHooks(install_tool=…, remove_tool=…, install_skill=…, uninstall_skill=…)`: replace how one tool is installed, for a wrapper that builds a venv per tool. A field left `None` keeps the kit's own. |
|
|
222
248
|
|
|
223
249
|
The identity is applied first and these individual names override it, so you can
|
|
224
250
|
take the whole namespace from a slug and still change one thing.
|
|
@@ -323,7 +349,7 @@ and needs no network.
|
|
|
323
349
|
Icon thumbnails need Pillow:
|
|
324
350
|
|
|
325
351
|
```bash
|
|
326
|
-
pip install "cli-tools-kit[gui]
|
|
352
|
+
pip install "cli-tools-kit[gui]>=1.0,<2"
|
|
327
353
|
```
|
|
328
354
|
|
|
329
355
|
Installing the package also exposes a `cli-tool-installer` console script.
|
|
@@ -475,13 +501,33 @@ per repo it could not (`log=` takes any callable, `print` by default). Pass
|
|
|
475
501
|
Reading the TOML needs Python 3.11 or the `tomli` package, which is a dependency
|
|
476
502
|
on 3.10.
|
|
477
503
|
|
|
504
|
+
## Testing a tool's `--advertise`
|
|
505
|
+
|
|
506
|
+
A tool that breaks the protocol is skipped by the parent installer and simply
|
|
507
|
+
disappears from the list. Put this in the tool's own tests to catch that:
|
|
508
|
+
|
|
509
|
+
```python
|
|
510
|
+
from cli_tools_kit.testing import assert_advertises
|
|
511
|
+
|
|
512
|
+
def test_advertise():
|
|
513
|
+
assert_advertises("main.py")
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
It runs the probe the way a parent does (5 s limit, only JSON on stdout) and
|
|
517
|
+
fails with every problem it finds.
|
|
518
|
+
|
|
478
519
|
## Tests
|
|
479
520
|
|
|
480
521
|
```bash
|
|
481
|
-
pip install -e ".[dev]"
|
|
482
|
-
|
|
522
|
+
pip install -e ".[dev,gui]"
|
|
523
|
+
ruff check .
|
|
524
|
+
pytest # xvfb-run -a pytest on a host without a display
|
|
483
525
|
```
|
|
484
526
|
|
|
527
|
+
CI runs the same on Linux and Windows for Python 3.10 to 3.13, plus an
|
|
528
|
+
install from the built wheel. The GUI smoke test skips where tkinter or a
|
|
529
|
+
display is missing.
|
|
530
|
+
|
|
485
531
|
## Used by
|
|
486
532
|
|
|
487
533
|
Consumers, each a self-installing tool that answers `--advertise`:
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Starting tools at login (autostart entry or cron) and the login update check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from . import discovery
|
|
13
|
+
from . import host
|
|
14
|
+
from . import state
|
|
15
|
+
from .autostart_gate import build_exec_prefix, load_tool_conditions, save_tool_conditions
|
|
16
|
+
from .cron_installer import read_crontab
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def autostart_check_enabled() -> bool:
|
|
20
|
+
return os.path.exists(state.AUTOSTART_CHECK_DESKTOP)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def enable_autostart_check() -> str:
|
|
24
|
+
"""Write the login update-check autostart entry. Returns its path."""
|
|
25
|
+
os.makedirs(state.AUTOSTART_DIR, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
if host.IS_WINDOWS:
|
|
28
|
+
# The Startup folder runs shortcuts; a .desktop file dropped there is
|
|
29
|
+
# never executed. pythonw keeps the console window from flashing up at
|
|
30
|
+
# every login, since the check reports through a notification anyway.
|
|
31
|
+
runner = sys.executable
|
|
32
|
+
windowless = os.path.join(os.path.dirname(runner), "pythonw.exe")
|
|
33
|
+
if os.path.exists(windowless):
|
|
34
|
+
runner = windowless
|
|
35
|
+
host.write_shortcut(
|
|
36
|
+
state.AUTOSTART_CHECK_DESKTOP,
|
|
37
|
+
target=runner,
|
|
38
|
+
args=f'"{state.ENTRY_SCRIPT}" --check',
|
|
39
|
+
workdir=os.path.dirname(state.ENTRY_SCRIPT),
|
|
40
|
+
)
|
|
41
|
+
return state.AUTOSTART_CHECK_DESKTOP
|
|
42
|
+
|
|
43
|
+
exec_line = f"{sys.executable} {state.ENTRY_SCRIPT} --check"
|
|
44
|
+
content = (
|
|
45
|
+
"[Desktop Entry]\n"
|
|
46
|
+
"Type=Application\n"
|
|
47
|
+
f"Name={state.SELF_DESKTOP_NAME} — login update check\n"
|
|
48
|
+
"Comment=Apply network-free tool reconciliations on login; notify for new tools\n"
|
|
49
|
+
f"Exec={exec_line}\n"
|
|
50
|
+
"Icon=system-software-update\n"
|
|
51
|
+
"Terminal=false\n"
|
|
52
|
+
"NoDisplay=true\n"
|
|
53
|
+
"X-KDE-autostart-after=panel\n"
|
|
54
|
+
"X-GNOME-Autostart-enabled=true\n"
|
|
55
|
+
)
|
|
56
|
+
with open(state.AUTOSTART_CHECK_DESKTOP, "w") as f:
|
|
57
|
+
f.write(content)
|
|
58
|
+
return state.AUTOSTART_CHECK_DESKTOP
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def disable_autostart_check() -> bool:
|
|
62
|
+
"""Remove the login update-check autostart entry. True if one was present."""
|
|
63
|
+
removed = False
|
|
64
|
+
# On Windows, also clear the .desktop an older version wrote into the
|
|
65
|
+
# Startup folder, where it sat inert instead of running the check.
|
|
66
|
+
stale = os.path.join(state.AUTOSTART_DIR, state.AUTOSTART_CHECK_DESKTOP_NAME)
|
|
67
|
+
for path in {state.AUTOSTART_CHECK_DESKTOP, stale}:
|
|
68
|
+
if os.path.exists(path):
|
|
69
|
+
os.remove(path)
|
|
70
|
+
removed = True
|
|
71
|
+
return removed
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ================= AUTOSTART UTILITIES =================
|
|
75
|
+
|
|
76
|
+
def get_autostart_path(tool: discovery.ToolEntry) -> str:
|
|
77
|
+
"""Where a tool's autostart entry lives: a .desktop symlink in
|
|
78
|
+
~/.config/autostart, or a copy of its .lnk in the Startup folder."""
|
|
79
|
+
if host.IS_WINDOWS:
|
|
80
|
+
return os.path.join(state.AUTOSTART_DIR,
|
|
81
|
+
os.path.splitext(tool.desktop_file)[0] + ".lnk")
|
|
82
|
+
return os.path.join(state.AUTOSTART_DIR, tool.desktop_file)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cron_line_for_tool(tool: discovery.ToolEntry) -> str:
|
|
86
|
+
"""Build the crontab line for a cron-based tool."""
|
|
87
|
+
parts = [tool.cron_schedule, sys.executable, tool.script_path] + list(tool.cron_args)
|
|
88
|
+
return " ".join(parts)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cron_contains(line: str) -> bool:
|
|
92
|
+
try:
|
|
93
|
+
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
|
94
|
+
return result.returncode == 0 and line in result.stdout
|
|
95
|
+
except Exception:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_autostart_enabled(tool: discovery.ToolEntry) -> bool:
|
|
100
|
+
"""Check if autostart is enabled for a tool."""
|
|
101
|
+
if "Icon" in tool.tags:
|
|
102
|
+
return os.path.exists(get_autostart_path(tool))
|
|
103
|
+
if tool.cron_schedule:
|
|
104
|
+
return _cron_contains(_cron_line_for_tool(tool))
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def autostart_tool_key(tool: discovery.ToolEntry) -> str:
|
|
109
|
+
"""The key a tool's conditions are stored under.
|
|
110
|
+
|
|
111
|
+
The .desktop stem: stable across renames of the display name, and already
|
|
112
|
+
unique per installed shortcut.
|
|
113
|
+
"""
|
|
114
|
+
return os.path.splitext(tool.desktop_file)[0]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_autostart_conditions(tool: discovery.ToolEntry) -> dict:
|
|
118
|
+
"""The conditions currently configured for *tool* on this host."""
|
|
119
|
+
return load_tool_conditions(state.IDENTITY.slug, autostart_tool_key(tool))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def set_autostart_conditions(tool: discovery.ToolEntry, conditions: Optional[dict]) -> None:
|
|
123
|
+
"""Store *tool*'s conditions, then rewrite its entry if autostart is on.
|
|
124
|
+
|
|
125
|
+
The Exec line differs between a gated and an ungated entry, so a change
|
|
126
|
+
here only takes effect once the entry is rewritten.
|
|
127
|
+
"""
|
|
128
|
+
save_tool_conditions(state.IDENTITY.slug, autostart_tool_key(tool), conditions)
|
|
129
|
+
if is_autostart_enabled(tool):
|
|
130
|
+
enable_autostart(tool)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_desktop_exec(desktop_path: str) -> str:
|
|
134
|
+
"""The Exec= line of an installed .desktop, or "" if it has none."""
|
|
135
|
+
try:
|
|
136
|
+
with open(desktop_path, "r", encoding="utf-8") as fh:
|
|
137
|
+
for line in fh:
|
|
138
|
+
if line.startswith("Exec="):
|
|
139
|
+
return line[len("Exec="):].strip()
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
return ""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _write_gated_autostart(tool: discovery.ToolEntry, desktop_path: str,
|
|
146
|
+
autostart_path: str, conditions: dict) -> tuple[bool, str]:
|
|
147
|
+
"""Write an autostart .desktop whose Exec runs *tool* through the gate.
|
|
148
|
+
|
|
149
|
+
A real file rather than the usual symlink: the app entry in the menu must
|
|
150
|
+
keep launching the tool unconditionally, so only this copy carries the
|
|
151
|
+
gate. Everything else is inherited from the installed entry.
|
|
152
|
+
"""
|
|
153
|
+
exec_line = _read_desktop_exec(desktop_path)
|
|
154
|
+
if not exec_line:
|
|
155
|
+
return False, f"No Exec line in {desktop_path}"
|
|
156
|
+
|
|
157
|
+
prefix = " ".join(shlex.quote(p) for p in
|
|
158
|
+
build_exec_prefix(state.IDENTITY.slug, autostart_tool_key(tool)))
|
|
159
|
+
gated_exec = f"{prefix} {exec_line}"
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
with open(desktop_path, "r", encoding="utf-8") as fh:
|
|
163
|
+
lines = fh.read().splitlines()
|
|
164
|
+
except OSError as e:
|
|
165
|
+
return False, f"Failed to read {desktop_path}: {e}"
|
|
166
|
+
|
|
167
|
+
out = []
|
|
168
|
+
for line in lines:
|
|
169
|
+
if line.startswith("Exec="):
|
|
170
|
+
out.append(f"Exec={gated_exec}")
|
|
171
|
+
elif line.startswith("X-CliToolsKit-Gated="):
|
|
172
|
+
continue
|
|
173
|
+
else:
|
|
174
|
+
out.append(line)
|
|
175
|
+
# Marks the entry as ours and generated, so it is obvious in a diff why
|
|
176
|
+
# this one is a file where every other autostart entry is a symlink.
|
|
177
|
+
out.append("X-CliToolsKit-Gated=true")
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
if os.path.exists(autostart_path) or os.path.islink(autostart_path):
|
|
181
|
+
os.remove(autostart_path)
|
|
182
|
+
with open(autostart_path, "w", encoding="utf-8") as fh:
|
|
183
|
+
fh.write("\n".join(out) + "\n")
|
|
184
|
+
# Deliberately not executable: systemd-xdg-autostart-generator warns
|
|
185
|
+
# on every login about an executable entry in ~/.config/autostart.
|
|
186
|
+
exec_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
187
|
+
os.chmod(autostart_path, os.stat(autostart_path).st_mode & ~exec_bits)
|
|
188
|
+
except OSError as e:
|
|
189
|
+
return False, f"Failed to write {autostart_path}: {e}"
|
|
190
|
+
|
|
191
|
+
return True, f"Autostart enabled (conditional): {tool.name}"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def enable_autostart(tool: discovery.ToolEntry) -> tuple[bool, str]:
|
|
195
|
+
"""Enable autostart for a tool.
|
|
196
|
+
|
|
197
|
+
Icon tools: create a .desktop symlink in ~/.config/autostart.
|
|
198
|
+
Cron tools: add an @reboot (or other schedule) crontab entry.
|
|
199
|
+
|
|
200
|
+
Returns (success, message).
|
|
201
|
+
"""
|
|
202
|
+
if "Icon" in tool.tags:
|
|
203
|
+
if host.IS_WINDOWS:
|
|
204
|
+
stem = os.path.splitext(tool.desktop_file)[0]
|
|
205
|
+
lnk_path = os.path.join(state.APPS_DIR, stem + ".lnk")
|
|
206
|
+
if not os.path.exists(lnk_path):
|
|
207
|
+
return False, f"Shortcut not found: {lnk_path}"
|
|
208
|
+
os.makedirs(state.AUTOSTART_DIR, exist_ok=True)
|
|
209
|
+
try:
|
|
210
|
+
import shutil
|
|
211
|
+
shutil.copyfile(lnk_path, get_autostart_path(tool))
|
|
212
|
+
return True, f"Autostart enabled: {tool.name}"
|
|
213
|
+
except OSError as e:
|
|
214
|
+
return False, f"Failed to copy shortcut: {e}"
|
|
215
|
+
|
|
216
|
+
desktop_path = os.path.join(state.APPS_DIR, tool.desktop_file)
|
|
217
|
+
if not os.path.exists(desktop_path):
|
|
218
|
+
return False, f"Desktop file not found: {desktop_path}"
|
|
219
|
+
|
|
220
|
+
os.makedirs(state.AUTOSTART_DIR, exist_ok=True)
|
|
221
|
+
autostart_path = get_autostart_path(tool)
|
|
222
|
+
|
|
223
|
+
# A tool with conditions configured gets a gated copy instead of the
|
|
224
|
+
# plain symlink, so the menu entry stays unconditional.
|
|
225
|
+
conditions = get_autostart_conditions(tool)
|
|
226
|
+
if conditions:
|
|
227
|
+
return _write_gated_autostart(tool, desktop_path, autostart_path, conditions)
|
|
228
|
+
|
|
229
|
+
if os.path.exists(autostart_path) or os.path.islink(autostart_path):
|
|
230
|
+
os.remove(autostart_path)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
os.symlink(desktop_path, autostart_path)
|
|
234
|
+
return True, f"Autostart enabled: {tool.name}"
|
|
235
|
+
except OSError as e:
|
|
236
|
+
return False, f"Failed to create symlink: {e}"
|
|
237
|
+
|
|
238
|
+
if tool.cron_schedule:
|
|
239
|
+
if host.IS_WINDOWS:
|
|
240
|
+
return False, "Cron autostart is not supported on Windows"
|
|
241
|
+
line = _cron_line_for_tool(tool)
|
|
242
|
+
try:
|
|
243
|
+
existing = read_crontab() # raises rather than read a failure as empty
|
|
244
|
+
if line in existing:
|
|
245
|
+
return True, "Cron entry already present"
|
|
246
|
+
new_crontab = existing.rstrip("\n") + ("\n" if existing else "") + line + "\n"
|
|
247
|
+
subprocess.run(["crontab", "-"], input=new_crontab, text=True, check=True)
|
|
248
|
+
return True, f"Cron entry added: {line}"
|
|
249
|
+
except Exception as e:
|
|
250
|
+
return False, f"Failed to add cron entry: {e}"
|
|
251
|
+
|
|
252
|
+
return False, "Tool has no supported autostart method"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def disable_autostart(tool: discovery.ToolEntry) -> tuple[bool, str]:
|
|
256
|
+
"""Disable autostart for a tool.
|
|
257
|
+
|
|
258
|
+
Returns (success, message).
|
|
259
|
+
"""
|
|
260
|
+
if "Icon" in tool.tags:
|
|
261
|
+
autostart_path = get_autostart_path(tool)
|
|
262
|
+
if not os.path.exists(autostart_path) and not os.path.islink(autostart_path):
|
|
263
|
+
return True, "Already disabled"
|
|
264
|
+
try:
|
|
265
|
+
os.remove(autostart_path)
|
|
266
|
+
return True, f"Autostart disabled: {tool.name}"
|
|
267
|
+
except OSError as e:
|
|
268
|
+
return False, f"Failed to remove {'shortcut' if host.IS_WINDOWS else 'symlink'}: {e}"
|
|
269
|
+
|
|
270
|
+
if tool.cron_schedule:
|
|
271
|
+
if host.IS_WINDOWS:
|
|
272
|
+
return False, "Cron autostart is not supported on Windows"
|
|
273
|
+
line = _cron_line_for_tool(tool)
|
|
274
|
+
try:
|
|
275
|
+
existing = read_crontab()
|
|
276
|
+
if line not in existing:
|
|
277
|
+
return True, "Already disabled"
|
|
278
|
+
new_crontab = "\n".join(l for l in existing.splitlines() if l != line) + "\n"
|
|
279
|
+
subprocess.run(["crontab", "-"], input=new_crontab, text=True, check=True)
|
|
280
|
+
return True, f"Cron entry removed: {tool.name}"
|
|
281
|
+
except Exception as e:
|
|
282
|
+
return False, f"Failed to remove cron entry: {e}"
|
|
283
|
+
|
|
284
|
+
return True, "Already disabled"
|