terok 0.8.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.
- terok/__init__.py +39 -0
- terok/cli/__init__.py +8 -0
- terok/cli/__main__.py +9 -0
- terok/cli/commands/__init__.py +9 -0
- terok/cli/commands/_completers.py +111 -0
- terok/cli/commands/_desktop_entry.py +436 -0
- terok/cli/commands/_storage_view.py +278 -0
- terok/cli/commands/acp.py +381 -0
- terok/cli/commands/agents.py +134 -0
- terok/cli/commands/auth.py +159 -0
- terok/cli/commands/clearance.py +32 -0
- terok/cli/commands/completions.py +148 -0
- terok/cli/commands/image.py +282 -0
- terok/cli/commands/info.py +501 -0
- terok/cli/commands/panic.py +110 -0
- terok/cli/commands/project.py +405 -0
- terok/cli/commands/setup.py +376 -0
- terok/cli/commands/shield.py +226 -0
- terok/cli/commands/sickbay.py +635 -0
- terok/cli/commands/task.py +792 -0
- terok/cli/commands/uninstall.py +166 -0
- terok/cli/main.py +339 -0
- terok/cli/tree.py +139 -0
- terok/lib/__init__.py +9 -0
- terok/lib/api/__init__.py +465 -0
- terok/lib/api/agents.py +96 -0
- terok/lib/api/clearance.py +42 -0
- terok/lib/api/gate.py +27 -0
- terok/lib/api/project.py +94 -0
- terok/lib/api/setup.py +54 -0
- terok/lib/api/shield.py +54 -0
- terok/lib/api/task.py +113 -0
- terok/lib/api/vault.py +170 -0
- terok/lib/core/__init__.py +4 -0
- terok/lib/core/config.py +820 -0
- terok/lib/core/images.py +113 -0
- terok/lib/core/paths.py +219 -0
- terok/lib/core/project_model.py +206 -0
- terok/lib/core/projects.py +596 -0
- terok/lib/core/runtime.py +118 -0
- terok/lib/core/task_display.py +89 -0
- terok/lib/core/task_state.py +123 -0
- terok/lib/core/version.py +238 -0
- terok/lib/core/work_status.py +194 -0
- terok/lib/core/yaml_schema.py +491 -0
- terok/lib/domain/__init__.py +3 -0
- terok/lib/domain/auth.py +235 -0
- terok/lib/domain/image_cleanup.py +237 -0
- terok/lib/domain/log_format.py +338 -0
- terok/lib/domain/panic.py +390 -0
- terok/lib/domain/project.py +889 -0
- terok/lib/domain/project_state.py +239 -0
- terok/lib/domain/ssh.py +37 -0
- terok/lib/domain/storage.py +310 -0
- terok/lib/domain/task.py +246 -0
- terok/lib/domain/task_credentials.py +172 -0
- terok/lib/domain/task_logs.py +243 -0
- terok/lib/domain/vault.py +76 -0
- terok/lib/domain/wizards/__init__.py +4 -0
- terok/lib/domain/wizards/new_project.py +764 -0
- terok/lib/integrations/__init__.py +21 -0
- terok/lib/integrations/clearance.py +41 -0
- terok/lib/integrations/executor.py +112 -0
- terok/lib/integrations/sandbox.py +162 -0
- terok/lib/integrations/shield.py +37 -0
- terok/lib/orchestration/__init__.py +3 -0
- terok/lib/orchestration/agent_config.py +106 -0
- terok/lib/orchestration/container_doctor.py +772 -0
- terok/lib/orchestration/container_exec.py +85 -0
- terok/lib/orchestration/environment.py +607 -0
- terok/lib/orchestration/hooks.py +184 -0
- terok/lib/orchestration/image.py +475 -0
- terok/lib/orchestration/ports.py +25 -0
- terok/lib/orchestration/task_runners/__init__.py +49 -0
- terok/lib/orchestration/task_runners/cli.py +184 -0
- terok/lib/orchestration/task_runners/config.py +106 -0
- terok/lib/orchestration/task_runners/container.py +341 -0
- terok/lib/orchestration/task_runners/headless.py +471 -0
- terok/lib/orchestration/task_runners/restart.py +150 -0
- terok/lib/orchestration/task_runners/shield.py +283 -0
- terok/lib/orchestration/task_runners/toad.py +344 -0
- terok/lib/orchestration/tasks/__init__.py +135 -0
- terok/lib/orchestration/tasks/archive.py +106 -0
- terok/lib/orchestration/tasks/identity.py +149 -0
- terok/lib/orchestration/tasks/lifecycle.py +669 -0
- terok/lib/orchestration/tasks/meta.py +340 -0
- terok/lib/orchestration/tasks/naming.py +100 -0
- terok/lib/orchestration/tasks/query.py +364 -0
- terok/lib/util/__init__.py +4 -0
- terok/lib/util/ansi.py +93 -0
- terok/lib/util/check_reporter.py +232 -0
- terok/lib/util/emoji.py +132 -0
- terok/lib/util/fs.py +79 -0
- terok/lib/util/host_cmd.py +67 -0
- terok/lib/util/logging_utils.py +67 -0
- terok/lib/util/net.py +16 -0
- terok/lib/util/subprocess_env.py +33 -0
- terok/lib/util/yaml.py +57 -0
- terok/resources/desktop/terok-symbolic.svg +75 -0
- terok/resources/desktop/terok-xdg-terminal-exec.sh +38 -0
- terok/resources/desktop/terok.desktop.template +13 -0
- terok/resources/instructions/default.md +54 -0
- terok/resources/presets/review.yml +23 -0
- terok/resources/presets/solo.yml +16 -0
- terok/resources/presets/team.yml +144 -0
- terok/resources/templates/l2.project.Dockerfile.template +18 -0
- terok/resources/templates/projects/project.yml.template +57 -0
- terok/resources/tmux/host-tmux.conf +19 -0
- terok/tui/__init__.py +8 -0
- terok/tui/__main__.py +9 -0
- terok/tui/_worker_entry.py +82 -0
- terok/tui/agents_screen.py +234 -0
- terok/tui/app.py +1856 -0
- terok/tui/askpass.py +93 -0
- terok/tui/askpass_protocol.py +114 -0
- terok/tui/askpass_service.py +370 -0
- terok/tui/clearance_screen.py +410 -0
- terok/tui/clipboard.py +210 -0
- terok/tui/console_log.py +369 -0
- terok/tui/console_output_screen.py +132 -0
- terok/tui/log_viewer.py +547 -0
- terok/tui/polling.py +299 -0
- terok/tui/project_actions.py +1058 -0
- terok/tui/screens.py +2541 -0
- terok/tui/selinux_fix_screen.py +147 -0
- terok/tui/serve.py +413 -0
- terok/tui/setup_screen.py +198 -0
- terok/tui/shell_launch.py +215 -0
- terok/tui/task_actions.py +973 -0
- terok/tui/text_screens.py +152 -0
- terok/tui/widgets/__init__.py +43 -0
- terok/tui/widgets/panic_button.py +89 -0
- terok/tui/widgets/project_list.py +127 -0
- terok/tui/widgets/project_state.py +293 -0
- terok/tui/widgets/status_bar.py +41 -0
- terok/tui/widgets/task_detail.py +262 -0
- terok/tui/widgets/task_list.py +183 -0
- terok/tui/wizard_screens.py +1082 -0
- terok/tui/worker_actions.py +262 -0
- terok/tui/worker_log_screen.py +176 -0
- terok/ui_utils/__init__.py +4 -0
- terok/ui_utils/editor.py +56 -0
- terok/ui_utils/terminal.py +77 -0
- terok-0.8.0.dist-info/METADATA +265 -0
- terok-0.8.0.dist-info/RECORD +149 -0
- terok-0.8.0.dist-info/WHEEL +4 -0
- terok-0.8.0.dist-info/entry_points.txt +8 -0
- terok-0.8.0.dist-info/licenses/LICENSE +177 -0
- terok-0.8.0.dist-info/licenses/LICENSES/Apache-2.0.txt +202 -0
terok/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Jiri Vyskocil
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""terok package.
|
|
5
|
+
|
|
6
|
+
Modules:
|
|
7
|
+
- terok.cli: CLI entry point package (terok)
|
|
8
|
+
- terok.tui: Text UI entry point package (terok)
|
|
9
|
+
- terok.ui_utils: Shared UI helpers (terminal ANSI, editor launch)
|
|
10
|
+
- terok.lib: Business logic layer (core, containers, security, wizards, integrations, util)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"cli",
|
|
15
|
+
"tui",
|
|
16
|
+
"ui_utils",
|
|
17
|
+
"lib",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Version information - single source of truth using importlib.metadata
|
|
21
|
+
import tomllib
|
|
22
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
__version__ = version("terok")
|
|
26
|
+
except PackageNotFoundError:
|
|
27
|
+
# Fallback for development mode when package is not installed
|
|
28
|
+
try:
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
|
32
|
+
if pyproject_path.exists():
|
|
33
|
+
with open(pyproject_path, "rb") as f:
|
|
34
|
+
pyproject_data = tomllib.load(f)
|
|
35
|
+
__version__ = pyproject_data["tool"]["poetry"]["version"]
|
|
36
|
+
else:
|
|
37
|
+
__version__ = "unknown"
|
|
38
|
+
except (OSError, KeyError, tomllib.TOMLDecodeError):
|
|
39
|
+
__version__ = "unknown"
|
terok/cli/__init__.py
ADDED
terok/cli/__main__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Jiri Vyskocil
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""CLI command modules.
|
|
5
|
+
|
|
6
|
+
Each module exposes ``register(subparsers)`` to add its argument parsers
|
|
7
|
+
and ``dispatch(args) -> bool`` to handle parsed arguments. The dispatch
|
|
8
|
+
function returns ``True`` if it handled the command, ``False`` otherwise.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Jiri Vyskocil
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Shared argcomplete completers and helpers for CLI commands.
|
|
5
|
+
|
|
6
|
+
All completers assume the standard ``project_id`` / ``task_id`` dest
|
|
7
|
+
names. Parsers whose positionals display as ``<project>`` / ``<task>``
|
|
8
|
+
(e.g. ``sickbay``) should set ``dest="project_id"`` / ``dest="task_id"``
|
|
9
|
+
with a custom ``metavar=`` for display, so completers and argparse help
|
|
10
|
+
stay decoupled.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ...lib.api import get_tasks
|
|
20
|
+
from ...lib.core.projects import list_presets, list_projects
|
|
21
|
+
from ...lib.orchestration.tasks import normalize_task_id_input
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def complete_project_ids(
|
|
25
|
+
prefix: str, parsed_args: argparse.Namespace, **kwargs: object
|
|
26
|
+
) -> list[str]: # pragma: no cover
|
|
27
|
+
"""Return project IDs matching *prefix* for argcomplete."""
|
|
28
|
+
try:
|
|
29
|
+
ids = [p.id for p in list_projects()]
|
|
30
|
+
except Exception:
|
|
31
|
+
return []
|
|
32
|
+
if prefix:
|
|
33
|
+
ids = [i for i in ids if str(i).startswith(prefix)]
|
|
34
|
+
return ids
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def complete_task_ids(
|
|
38
|
+
prefix: str, parsed_args: argparse.Namespace, **kwargs: object
|
|
39
|
+
) -> list[str]: # pragma: no cover
|
|
40
|
+
"""Return task IDs matching *prefix* within ``parsed_args.project_id``.
|
|
41
|
+
|
|
42
|
+
Returns an empty list when the project arg hasn't been typed yet —
|
|
43
|
+
argcomplete uses the partially-parsed namespace, which is exactly
|
|
44
|
+
what we want to scope task-ID suggestions.
|
|
45
|
+
|
|
46
|
+
The prefix is run through [`normalize_task_id_input`][terok.lib.orchestration.tasks.normalize_task_id_input], so
|
|
47
|
+
``K3V<TAB>`` or ``k3-v<TAB>`` rewrite to the canonical lowercase
|
|
48
|
+
form — the same surface-form tolerance ``resolve_task_id`` gives
|
|
49
|
+
at dispatch time.
|
|
50
|
+
"""
|
|
51
|
+
project_id = getattr(parsed_args, "project_id", None)
|
|
52
|
+
if not project_id:
|
|
53
|
+
return []
|
|
54
|
+
try:
|
|
55
|
+
tids = [t.task_id for t in get_tasks(project_id) if t.task_id]
|
|
56
|
+
except Exception:
|
|
57
|
+
return []
|
|
58
|
+
normalized = normalize_task_id_input(prefix)
|
|
59
|
+
if normalized:
|
|
60
|
+
tids = [t for t in tids if t.startswith(normalized)]
|
|
61
|
+
return tids
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def complete_preset_names(
|
|
65
|
+
prefix: str, parsed_args: argparse.Namespace, **kwargs: object
|
|
66
|
+
) -> list[str]: # pragma: no cover
|
|
67
|
+
"""Return preset names matching *prefix* for the scoped project.
|
|
68
|
+
|
|
69
|
+
``list_presets`` requires a project ID to resolve the full tier
|
|
70
|
+
(bundled → global → project), so we only suggest presets once the
|
|
71
|
+
user has typed the project arg. No project typed yet → empty list,
|
|
72
|
+
which leaves argcomplete silent rather than misleading.
|
|
73
|
+
"""
|
|
74
|
+
project_id = getattr(parsed_args, "project_id", None)
|
|
75
|
+
if not project_id:
|
|
76
|
+
return []
|
|
77
|
+
try:
|
|
78
|
+
names = [p.name for p in list_presets(project_id)]
|
|
79
|
+
except Exception:
|
|
80
|
+
return []
|
|
81
|
+
if prefix:
|
|
82
|
+
names = [n for n in names if n.startswith(prefix)]
|
|
83
|
+
return names
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def set_completer(action: argparse.Action, fn: Callable[..., Any]) -> None:
|
|
87
|
+
"""Attach an argcomplete completer to *action*, ignoring missing argcomplete."""
|
|
88
|
+
action.completer = fn # type: ignore[attr-defined]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def add_project_id(parser: argparse.ArgumentParser, **kwargs: Any) -> argparse.Action:
|
|
92
|
+
"""Add a ``project_id`` positional with the project-ID completer attached.
|
|
93
|
+
|
|
94
|
+
Returns the argparse action so callers can further customise it.
|
|
95
|
+
Accepts any argparse kwargs (``nargs``, ``metavar``, ``help``, etc.).
|
|
96
|
+
"""
|
|
97
|
+
action = parser.add_argument("project_id", **kwargs)
|
|
98
|
+
set_completer(action, complete_project_ids)
|
|
99
|
+
return action
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def add_task_id(parser: argparse.ArgumentParser, **kwargs: Any) -> argparse.Action:
|
|
103
|
+
"""Add a ``task_id`` positional with the task-ID completer attached.
|
|
104
|
+
|
|
105
|
+
Returns the argparse action. Callers should typically precede this
|
|
106
|
+
with `add_project_id` so argcomplete has a project scope to
|
|
107
|
+
look up tasks under.
|
|
108
|
+
"""
|
|
109
|
+
action = parser.add_argument("task_id", **kwargs)
|
|
110
|
+
set_completer(action, complete_task_ids)
|
|
111
|
+
return action
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Jiri Vyskocil
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Install the XDG desktop entry + symbolic SVG icon for ``terok-tui``.
|
|
5
|
+
|
|
6
|
+
``terok setup`` calls `install_desktop_entry` (or the matching
|
|
7
|
+
`uninstall_desktop_entry`) as a default-on phase, so the TUI
|
|
8
|
+
appears as *Terok* in GNOME / KDE / XFCE application menus without the
|
|
9
|
+
operator knowing the template layout. Every step soft-fails so a
|
|
10
|
+
headless host without ``.local/share`` or without ``xdg-utils`` never
|
|
11
|
+
kills the wider ``terok setup`` flow.
|
|
12
|
+
|
|
13
|
+
Preferred path for the ``.desktop`` file is ``xdg-utils`` —
|
|
14
|
+
``xdg-desktop-menu install`` runs ``desktop-file-install`` (validates
|
|
15
|
+
the file, catches malformed keys) and refreshes
|
|
16
|
+
``update-desktop-database`` for us. The icon, however, is always
|
|
17
|
+
written manually: ``xdg-icon-resource install --size`` only accepts
|
|
18
|
+
numeric sizes or ``scalable`` (per the upstream xdg-utils source —
|
|
19
|
+
``size argument must be numeric or the word 'scalable'``), so a
|
|
20
|
+
symbolic icon (which lives in ``hicolor/symbolic/apps/``) can't be
|
|
21
|
+
registered through that path. We drop the icon directly into the
|
|
22
|
+
hicolor tree and kick ``gtk-update-icon-cache`` ourselves.
|
|
23
|
+
|
|
24
|
+
When ``xdg-utils`` isn't on PATH (minimal container images, some CI
|
|
25
|
+
runners) we fall back to writing the ``.desktop`` ourselves too.
|
|
26
|
+
This is *best-effort*: the file ends up in the right place on hosts
|
|
27
|
+
that match the spec, but there's no ``desktop-file-install``
|
|
28
|
+
validation and no cover for DE-specific layout drift.
|
|
29
|
+
`install_desktop_entry` returns a `DesktopBackend` so the caller can
|
|
30
|
+
surface a gentle warning when the fallback kicks in.
|
|
31
|
+
|
|
32
|
+
The passive assets (``.desktop`` template, logo SVG) live under
|
|
33
|
+
``terok/resources/desktop/`` — this module is the *builder* that
|
|
34
|
+
renders them and delegates to the XDG tool of choice. When ``ptyxis``
|
|
35
|
+
is on PATH, `_render_desktop_file` routes the launch through the
|
|
36
|
+
bundled ``terok-xdg-terminal-exec.sh`` shim to dodge a Ptyxis
|
|
37
|
+
standalone-mode bug — Fedora patches GLib to inject ``ptyxis`` into
|
|
38
|
+
GIO's hardcoded ``known_terminals[]`` (right after ``xdg-terminal-exec``)
|
|
39
|
+
so a vanilla ``Terminal=true`` launcher ends up as ``ptyxis -- terok-tui``,
|
|
40
|
+
which trips the bug. See the shim's header for the rationale.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import logging
|
|
46
|
+
import os
|
|
47
|
+
import shutil
|
|
48
|
+
import subprocess # nosec B404 — cache refresh binaries are trusted
|
|
49
|
+
import tempfile
|
|
50
|
+
from enum import StrEnum
|
|
51
|
+
from importlib import resources as importlib_resources
|
|
52
|
+
from importlib.resources.abc import Traversable
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
import jinja2
|
|
56
|
+
|
|
57
|
+
_log = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
#: Base name of the application launcher.
|
|
60
|
+
APP_NAME = "terok"
|
|
61
|
+
|
|
62
|
+
#: Icon name — the ``-symbolic`` suffix is honoured by GTK and Qt as a
|
|
63
|
+
#: marker that triggers the toolkit's symbolic-icon rendering pipeline,
|
|
64
|
+
#: which substitutes the placeholder fill (``#bebebe`` in our SVG) with
|
|
65
|
+
#: the active theme's foreground colour. Same mechanism as ``Icon=
|
|
66
|
+
#: <name>-symbolic`` on every well-behaved GNOME / KDE app.
|
|
67
|
+
_ICON_NAME = f"{APP_NAME}-symbolic"
|
|
68
|
+
|
|
69
|
+
_DESKTOP_FILE = f"{APP_NAME}.desktop"
|
|
70
|
+
_ICON_FILE = f"{_ICON_NAME}.svg"
|
|
71
|
+
_TEMPLATE_NAME = "terok.desktop.template"
|
|
72
|
+
_LOGO_NAME = _ICON_FILE
|
|
73
|
+
_PTYXIS_SHIM_NAME = "terok-xdg-terminal-exec.sh"
|
|
74
|
+
|
|
75
|
+
# XDG Base Directory + Icon Theme spec path fragments. Named so a
|
|
76
|
+
# future theme-dir shift is a single-constant change and so ``grep`` for
|
|
77
|
+
# the fragment lands on the canonical definition rather than every join
|
|
78
|
+
# site. Symbolic icons live under ``hicolor/symbolic/apps/`` (the
|
|
79
|
+
# ``symbolic`` directory is hicolor's well-known symbolic-icon slot).
|
|
80
|
+
_APPLICATIONS_SUBDIR = "applications"
|
|
81
|
+
_ICONS_SUBDIR = "icons"
|
|
82
|
+
_HICOLOR_THEME = "hicolor"
|
|
83
|
+
_APPS_SUBDIR = "apps"
|
|
84
|
+
_ICON_SIZE_DIR = "symbolic"
|
|
85
|
+
_DEFAULT_DATA_HOME = (".local", "share") # $HOME/.local/share — XDG fallback
|
|
86
|
+
|
|
87
|
+
_XDG_MENU_BINARY = "xdg-desktop-menu"
|
|
88
|
+
# xdg-icon-resource intentionally NOT used — its ``--size`` accepts only
|
|
89
|
+
# numeric values or ``scalable``, never ``symbolic``, so symbolic icons
|
|
90
|
+
# can't be registered through it. Manual write into
|
|
91
|
+
# ``hicolor/symbolic/apps/`` instead.
|
|
92
|
+
|
|
93
|
+
_SUBPROCESS_TIMEOUT_S = 10
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DesktopBackend(StrEnum):
|
|
97
|
+
"""Which install path `install_desktop_entry` actually took."""
|
|
98
|
+
|
|
99
|
+
XDG_UTILS = "xdg-utils"
|
|
100
|
+
FALLBACK = "fallback"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _resource_dir() -> Traversable:
|
|
104
|
+
"""Return a ``Traversable`` rooted at the passive ``resources/desktop/`` assets.
|
|
105
|
+
|
|
106
|
+
Uses the namespace-package idiom already used by
|
|
107
|
+
[`terok.lib.core.config.bundled_presets_dir`][terok.lib.core.config.bundled_presets_dir]: walk the top-level
|
|
108
|
+
``terok`` package into the ``resources`` + ``desktop`` subdirs (no
|
|
109
|
+
``__init__.py`` anywhere under ``resources/``, matching the project's
|
|
110
|
+
"resources hold only data files" convention).
|
|
111
|
+
"""
|
|
112
|
+
return importlib_resources.files("terok").joinpath("resources", "desktop")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def install_desktop_entry(bin_path: str | Path) -> DesktopBackend:
|
|
116
|
+
"""Render the launcher + copy the icon, via xdg-utils when available.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
bin_path: Absolute path (or bare name) to ``terok-tui``. The
|
|
120
|
+
freedesktop ``Exec=`` / ``TryExec=`` keys need this — the
|
|
121
|
+
launcher's minimal PATH often misses ``~/.local/bin``, so
|
|
122
|
+
``shutil.which("terok-tui")``'s absolute result is preferred
|
|
123
|
+
over the short name.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The `DesktopBackend` actually used. Callers wire this to
|
|
127
|
+
a status-line warning when the fallback kicks in so the operator
|
|
128
|
+
knows ``xdg-utils`` is missing.
|
|
129
|
+
"""
|
|
130
|
+
rendered = _render_desktop_file(str(bin_path))
|
|
131
|
+
logo_bytes = _resource_dir().joinpath(_LOGO_NAME).read_bytes()
|
|
132
|
+
if xdg_utils_available() and _install_via_xdg_utils(rendered, logo_bytes):
|
|
133
|
+
return DesktopBackend.XDG_UTILS
|
|
134
|
+
# xdg-utils missing *or* it barfed (readonly menu dir, timeout, bad
|
|
135
|
+
# DE detection) — land the files ourselves so the operator still
|
|
136
|
+
# gets a working launcher, and report FALLBACK so the caller can
|
|
137
|
+
# warn. The DEBUG log carries the xdg-utils failure detail.
|
|
138
|
+
_install_manually(rendered, logo_bytes)
|
|
139
|
+
return DesktopBackend.FALLBACK
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def uninstall_desktop_entry() -> DesktopBackend:
|
|
143
|
+
"""Remove the launcher + icon, via xdg-utils when available.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The `DesktopBackend` actually used — symmetric with
|
|
147
|
+
`install_desktop_entry`. XDG_UTILS only when both
|
|
148
|
+
front-ends reported rc 0; on failure (or xdg-utils absent) we
|
|
149
|
+
retry via manual unlinks and report FALLBACK so the teardown
|
|
150
|
+
leaves no stragglers even when xdg-utils misbehaves.
|
|
151
|
+
"""
|
|
152
|
+
if xdg_utils_available() and _uninstall_via_xdg_utils():
|
|
153
|
+
return DesktopBackend.XDG_UTILS
|
|
154
|
+
_uninstall_manually()
|
|
155
|
+
return DesktopBackend.FALLBACK
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def is_desktop_entry_installed() -> bool:
|
|
159
|
+
"""Return True when both the ``.desktop`` and icon files exist on disk.
|
|
160
|
+
|
|
161
|
+
Probes the install tree directly rather than asking xdg-utils — both
|
|
162
|
+
backends land the same files in the same XDG-spec locations, so the
|
|
163
|
+
presence check is backend-agnostic.
|
|
164
|
+
"""
|
|
165
|
+
return _desktop_entry_path().is_file() and _icon_path().is_file()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── xdg-utils backend ─────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def xdg_utils_available() -> bool:
|
|
172
|
+
"""Return True when xdg-desktop-menu is on PATH (icon side is always manual)."""
|
|
173
|
+
return bool(shutil.which(_XDG_MENU_BINARY))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _install_via_xdg_utils(desktop_contents: str, logo_bytes: bytes) -> bool:
|
|
177
|
+
"""Install the ``.desktop`` via xdg-utils; write the icon manually.
|
|
178
|
+
|
|
179
|
+
``xdg-desktop-menu install`` runs ``desktop-file-install`` (catches
|
|
180
|
+
malformed keys), drops the file under the user's applications dir,
|
|
181
|
+
and kicks ``update-desktop-database``. We stage to a tempdir
|
|
182
|
+
because xdg-desktop-menu names the installed file after the source
|
|
183
|
+
basename — staging to ``/tmp/.../terok.desktop`` makes the launcher
|
|
184
|
+
register as ``terok``.
|
|
185
|
+
|
|
186
|
+
Icon: ``xdg-icon-resource install --size`` accepts only numeric
|
|
187
|
+
sizes or ``scalable``, never ``symbolic``, so we write the symbolic
|
|
188
|
+
icon directly into ``hicolor/symbolic/apps/`` and refresh
|
|
189
|
+
``gtk-update-icon-cache`` ourselves.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True only when the ``.desktop`` install reported success.
|
|
193
|
+
Icon install is always attempted (manual write). A failed
|
|
194
|
+
``.desktop`` install reads as False so the caller retries via
|
|
195
|
+
the manual path.
|
|
196
|
+
"""
|
|
197
|
+
with tempfile.TemporaryDirectory(prefix="terok-desktop-") as td:
|
|
198
|
+
staged_desktop = Path(td) / _DESKTOP_FILE
|
|
199
|
+
staged_desktop.write_text(desktop_contents, encoding="utf-8")
|
|
200
|
+
menu_ok = _run_xdg(
|
|
201
|
+
_XDG_MENU_BINARY,
|
|
202
|
+
"install",
|
|
203
|
+
"--novendor",
|
|
204
|
+
str(staged_desktop),
|
|
205
|
+
)
|
|
206
|
+
if not menu_ok:
|
|
207
|
+
return False
|
|
208
|
+
_write_icon(logo_bytes)
|
|
209
|
+
_refresh_icon_cache()
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _uninstall_via_xdg_utils() -> bool:
|
|
214
|
+
"""Remove the ``.desktop`` via xdg-utils; unlink the icon manually.
|
|
215
|
+
|
|
216
|
+
Symmetric with `_install_via_xdg_utils` — xdg-utils can't manage
|
|
217
|
+
symbolic icons, so the icon side is always direct unlink +
|
|
218
|
+
``gtk-update-icon-cache``.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
True when the xdg-desktop-menu uninstall reports success.
|
|
222
|
+
Icon unlink is always attempted.
|
|
223
|
+
"""
|
|
224
|
+
menu_ok = _run_xdg(_XDG_MENU_BINARY, "uninstall", "--novendor", _DESKTOP_FILE)
|
|
225
|
+
_unlink_icon()
|
|
226
|
+
_refresh_icon_cache()
|
|
227
|
+
return menu_ok
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _run_xdg(binary: str, *args: str) -> bool:
|
|
231
|
+
"""Invoke an xdg-utils front-end; return True only on rc-0, False otherwise.
|
|
232
|
+
|
|
233
|
+
Never raises — a hung / missing / broken front-end lands in DEBUG
|
|
234
|
+
so an operator chasing a weird install state can grep
|
|
235
|
+
``journalctl --user`` without ``terok setup`` exploding. The
|
|
236
|
+
return value lets `_install_via_xdg_utils` decide whether to
|
|
237
|
+
hand off to the manual fallback.
|
|
238
|
+
"""
|
|
239
|
+
found = shutil.which(binary)
|
|
240
|
+
if not found: # pragma: no cover — gated by xdg_utils_available
|
|
241
|
+
return False
|
|
242
|
+
# nosec B603 — argv is our own literal binary path plus subcommand/arg tokens.
|
|
243
|
+
try:
|
|
244
|
+
result = subprocess.run( # noqa: S603 # nosec B603
|
|
245
|
+
[found, *args],
|
|
246
|
+
check=False,
|
|
247
|
+
capture_output=True,
|
|
248
|
+
timeout=_SUBPROCESS_TIMEOUT_S,
|
|
249
|
+
)
|
|
250
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
251
|
+
_log.debug("%s %s failed: %s", binary, args, exc)
|
|
252
|
+
return False
|
|
253
|
+
if result.returncode != 0:
|
|
254
|
+
_log.debug(
|
|
255
|
+
"%s %s exited with %d: %s",
|
|
256
|
+
binary,
|
|
257
|
+
args,
|
|
258
|
+
result.returncode,
|
|
259
|
+
(result.stderr or b"").decode(errors="replace").strip(),
|
|
260
|
+
)
|
|
261
|
+
return False
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── Manual fallback ───────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _install_manually(desktop_contents: str, logo_bytes: bytes) -> None:
|
|
269
|
+
"""Write the launcher + icon directly and trigger cache refreshes by hand."""
|
|
270
|
+
desktop_path = _desktop_entry_path()
|
|
271
|
+
desktop_path.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
desktop_path.write_text(desktop_contents, encoding="utf-8")
|
|
273
|
+
desktop_path.chmod(0o644)
|
|
274
|
+
|
|
275
|
+
_write_icon(logo_bytes)
|
|
276
|
+
|
|
277
|
+
_refresh_desktop_database()
|
|
278
|
+
_refresh_icon_cache()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _uninstall_manually() -> None:
|
|
282
|
+
"""Unlink the launcher + icon and refresh caches so menus forget."""
|
|
283
|
+
try:
|
|
284
|
+
_desktop_entry_path().unlink(missing_ok=True)
|
|
285
|
+
except OSError as exc:
|
|
286
|
+
_log.warning("failed to unlink %s: %s", _desktop_entry_path(), exc)
|
|
287
|
+
_unlink_icon()
|
|
288
|
+
_refresh_desktop_database()
|
|
289
|
+
_refresh_icon_cache()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _write_icon(logo_bytes: bytes) -> None:
|
|
293
|
+
"""Write the symbolic SVG to ``hicolor/symbolic/apps/`` directly.
|
|
294
|
+
|
|
295
|
+
Used by both the xdg-utils path and the manual path — xdg-icon-resource
|
|
296
|
+
can't register symbolic icons (its ``--size`` rejects anything but a
|
|
297
|
+
numeric value or ``scalable``), so the symbolic install is always a
|
|
298
|
+
direct write. Caller is expected to refresh the icon cache afterwards.
|
|
299
|
+
"""
|
|
300
|
+
icon_path = _icon_path()
|
|
301
|
+
icon_path.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
icon_path.write_bytes(logo_bytes)
|
|
303
|
+
icon_path.chmod(0o644)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _unlink_icon() -> None:
|
|
307
|
+
"""Remove the installed icon; symmetric with `_write_icon`."""
|
|
308
|
+
icon_path = _icon_path()
|
|
309
|
+
try:
|
|
310
|
+
icon_path.unlink(missing_ok=True)
|
|
311
|
+
except OSError as exc:
|
|
312
|
+
_log.warning("failed to unlink %s: %s", icon_path, exc)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ── Path derivation ───────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _desktop_entry_path() -> Path:
|
|
319
|
+
"""Return ``$XDG_DATA_HOME/applications/terok.desktop`` (XDG default)."""
|
|
320
|
+
return _data_home() / _APPLICATIONS_SUBDIR / _DESKTOP_FILE
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _icon_path() -> Path:
|
|
324
|
+
"""Return ``$XDG_DATA_HOME/icons/hicolor/symbolic/apps/terok-symbolic.svg``."""
|
|
325
|
+
return (
|
|
326
|
+
_data_home() / _ICONS_SUBDIR / _HICOLOR_THEME / _ICON_SIZE_DIR / _APPS_SUBDIR / _ICON_FILE
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _data_home() -> Path:
|
|
331
|
+
"""Return the user's XDG data home, honouring ``$XDG_DATA_HOME`` when set."""
|
|
332
|
+
override = os.environ.get("XDG_DATA_HOME")
|
|
333
|
+
return Path(override) if override else Path.home().joinpath(*_DEFAULT_DATA_HOME)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ── Template rendering ────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _render_desktop_file(bin_str: str) -> str:
|
|
340
|
+
"""Render ``terok.desktop`` with the right Exec / TryExec / Terminal values."""
|
|
341
|
+
# We gate on `ptyxis` alone. A more precise "is this a Fedora-
|
|
342
|
+
# patched glib" probe exists — `grep -aow ptyxis /lib64/libgio-2.0.so.0`
|
|
343
|
+
# exits 0 iff Fedora's `default-terminal.patch` injected
|
|
344
|
+
# { "ptyxis", "--" } into gio's hardcoded `known_terminals[]` — but
|
|
345
|
+
# it's (a) un-pythonic (shelling out to grep at a fixed sopath) and
|
|
346
|
+
# (b) not actually sufficient: when `xdg-terminal-exec` is installed
|
|
347
|
+
# it precedes ptyxis in the glib list and consults the user's
|
|
348
|
+
# `xdg-terminals.list`, so the rodata literal mis-predicts the
|
|
349
|
+
# real launch. Hijacking on PATH-presence is over-eager on hosts
|
|
350
|
+
# where vanilla glib wouldn't pick ptyxis anyway, but that's fine
|
|
351
|
+
# — the user installed Ptyxis on purpose; the shim gives them the
|
|
352
|
+
# container-tabs UI they want.
|
|
353
|
+
if shutil.which("ptyxis"):
|
|
354
|
+
shim = str(_resource_dir().joinpath(_PTYXIS_SHIM_NAME))
|
|
355
|
+
# ``TryExec`` points at the binary, not the shim: pipx (and any
|
|
356
|
+
# PEP 517 wheel installer) ships package data without the
|
|
357
|
+
# executable bit, and GNOME silently hides any launcher whose
|
|
358
|
+
# ``TryExec`` target isn't ``+x``. ``Exec`` invokes the shim
|
|
359
|
+
# via ``/bin/sh``, so the shim doesn't need to be executable —
|
|
360
|
+
# and the semantic we want to gate on ("is terok-tui actually
|
|
361
|
+
# installed?") is best expressed by ``TryExec``-ing the binary
|
|
362
|
+
# anyway.
|
|
363
|
+
variables = {
|
|
364
|
+
"EXEC": f"/bin/sh {shim} {bin_str}",
|
|
365
|
+
"TRY_EXEC": bin_str,
|
|
366
|
+
"TERMINAL": "false",
|
|
367
|
+
}
|
|
368
|
+
else:
|
|
369
|
+
variables = {"EXEC": bin_str, "TRY_EXEC": bin_str, "TERMINAL": "true"}
|
|
370
|
+
# The fallback install path skips ``desktop-file-install`` validation,
|
|
371
|
+
# so refuse any value with a C0/DEL/C1 character before substitution —
|
|
372
|
+
# a stray control byte in ``Exec=`` / ``TryExec=`` corrupts the
|
|
373
|
+
# launcher's key syntax and the unvalidated path lands as-is. In
|
|
374
|
+
# practice the values come from ``shutil.which`` results (path
|
|
375
|
+
# strings), but the cost of the guard is zero compared to debugging
|
|
376
|
+
# a silently-broken launcher.
|
|
377
|
+
for key, value in variables.items():
|
|
378
|
+
if any(ord(ch) < 0x20 or 0x7F <= ord(ch) <= 0x9F for ch in value):
|
|
379
|
+
raise ValueError(f"{key} contains a control character: {value!r}")
|
|
380
|
+
with importlib_resources.as_file(_resource_dir().joinpath(_TEMPLATE_NAME)) as template_path:
|
|
381
|
+
# ``StrictUndefined`` upgrades silent ``{{TYPO}}`` to a hard error;
|
|
382
|
+
# ``autoescape=False`` because ``.desktop`` syntax is not HTML and
|
|
383
|
+
# any escaping would corrupt ``Exec=`` quoting.
|
|
384
|
+
env = jinja2.Environment( # nosec B701 — see comment above # noqa: S701
|
|
385
|
+
loader=jinja2.FileSystemLoader(str(template_path.parent)),
|
|
386
|
+
keep_trailing_newline=True,
|
|
387
|
+
undefined=jinja2.StrictUndefined,
|
|
388
|
+
autoescape=False,
|
|
389
|
+
)
|
|
390
|
+
return env.get_template(template_path.name).render(**variables)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ── Manual cache refresh (fallback backend only) ──────────────────────
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _refresh_desktop_database() -> None:
|
|
397
|
+
"""Nudge ``update-desktop-database`` if present; silent otherwise."""
|
|
398
|
+
_run_cache_refresh(
|
|
399
|
+
"update-desktop-database",
|
|
400
|
+
[_data_home() / _APPLICATIONS_SUBDIR],
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _refresh_icon_cache() -> None:
|
|
405
|
+
"""Nudge ``gtk-update-icon-cache`` on the hicolor theme if present."""
|
|
406
|
+
_run_cache_refresh(
|
|
407
|
+
"gtk-update-icon-cache",
|
|
408
|
+
["-q", "-t", _data_home() / _ICONS_SUBDIR / _HICOLOR_THEME],
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _run_cache_refresh(binary: str, args: list[str | Path]) -> None:
|
|
413
|
+
"""Invoke *binary* with *args*, swallow every failure — caches are optional."""
|
|
414
|
+
found = shutil.which(binary)
|
|
415
|
+
if not found:
|
|
416
|
+
return
|
|
417
|
+
# nosec B603 — argv is a literal + controlled Path; no shell, no user input.
|
|
418
|
+
try:
|
|
419
|
+
result = subprocess.run( # noqa: S603 # nosec B603
|
|
420
|
+
[found, *[str(a) for a in args]],
|
|
421
|
+
check=False,
|
|
422
|
+
capture_output=True,
|
|
423
|
+
timeout=_SUBPROCESS_TIMEOUT_S,
|
|
424
|
+
)
|
|
425
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
426
|
+
_log.debug("%s refresh failed: %s", binary, exc)
|
|
427
|
+
return
|
|
428
|
+
if result.returncode != 0:
|
|
429
|
+
# Same DEBUG trail as _run_xdg — ``check=False`` keeps us quiet,
|
|
430
|
+
# the log makes the failure diagnosable after the fact.
|
|
431
|
+
_log.debug(
|
|
432
|
+
"%s exited with %d: %s",
|
|
433
|
+
binary,
|
|
434
|
+
result.returncode,
|
|
435
|
+
(result.stderr or b"").decode(errors="replace").strip(),
|
|
436
|
+
)
|