Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 43 additions & 26 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,57 +26,74 @@ The `--dev` flag isolates the development instance from production:

## Debug CLI

With the app running (`pdm dev`), inspect and control it from another terminal:
Debug commands run **headlessly** by default — no running GUI instance
is required. Data operations (``state``, ``list_projects``,
``project_status``, etc.) call the porringer API directly.

Pass ``--live`` to route a command over IPC to a running GUI instance.
This is required for GUI-control actions (``show_main``,
``check_update``, ``select_project``, etc.) and gives faster responses
for data queries thanks to the GUI's cached plugin discovery.

```shell
pdm run synodic-c debug state --dev # JSON dump of app state, config, update phase, data
pdm run synodic-c debug actions --dev # List available actions with descriptions
pdm run synodic-c debug action <name> --dev # Trigger an action (e.g. check_update, show_main)
pdm run synodic-c debug action <name> <arg> --dev # Action with argument (e.g. add_project /path)
# Headless (default) — no GUI needed
pdm run synodic-c debug state --dev
pdm run synodic-c debug actions --dev
pdm run synodic-c debug action list_projects --dev
pdm run synodic-c debug action project_status D:\example --dev
pdm run synodic-c debug action add_project D:\my-project --dev
pdm run synodic-c debug action remove_project D:\my-project --dev

# Live (IPC to running GUI) — requires `pdm dev` in another terminal
pdm run synodic-c debug state --dev --live
pdm run synodic-c debug action show_main --dev --live
pdm run synodic-c debug action check_update --dev --live
pdm run synodic-c debug action project_status --dev --live # uses selected project
pdm run synodic-c debug action select_project D:\example --dev --live
```

Available actions: `check_update`, `tool_update`, `refresh_data`, `show_main`, `show_settings`, `apply_update`, `list_projects`, `add_project`, `remove_project`, `project_status`, `select_project`.

### Project management actions

| Action | Arg | Description |
|--------|-----|-------------|
| `list_projects` | — | List cached directories with validation status. |
| `add_project` | `<path>` | Add a directory to the cache (no file picker). |
| `remove_project` | `<path>` | Remove a directory from the cache. |
| `project_status` | `[path]` | Per-action preview status. Defaults to selected project. |
| `select_project` | `<path>` | Switch sidebar selection to a project. |
| Action | Arg | Headless | Description |
|--------|-----|----------|-------------|
| `list_projects` | — | ✓ | List cached directories with validation status. |
| `add_project` | `<path>` | ✓ | Add a directory to the cache (no file picker). |
| `remove_project` | `<path>` | ✓ | Remove a directory from the cache. |
| `project_status` | `<path>` | ✓ | Per-action preview status (dry-run). |
| `select_project` | `<path>` | `--live` | Switch sidebar selection to a project. |

Example workflow:
### GUI-only actions (require `--live`)

```shell
pdm run synodic-c debug action list_projects --dev
pdm run synodic-c debug action show_main --dev
pdm run synodic-c debug action project_status --dev # selected project
pdm run synodic-c debug action project_status D:\example --dev # specific project
pdm run synodic-c debug action add_project D:\my-project --dev
pdm run synodic-c debug action remove_project D:\my-project --dev
```
| Action | Arg | Description |
|--------|-----|-------------|
| `check_update` | — | Trigger a self-update check. |
| `tool_update` | — | Run tool/package updates for all plugins. |
| `refresh_data` | — | Mark cached data as stale. |
| `show_main` | — | Show and raise the main window. |
| `show_settings` | — | Show the settings window. |
| `apply_update` | — | Apply a downloaded update and restart. |

Commands route to the controller/service layer (not widgets), so they are stable across UI changes.

For production instances, omit `--dev`:

```shell
pdm run synodic-c debug state
pdm run synodic-c debug action check_update
pdm run synodic-c debug state # headless
pdm run synodic-c debug action check_update --live # IPC to running GUI
```

## IPC Console
## IPC Console (`--live` mode)

The debug CLI communicates with the running GUI over a local named-pipe IPC channel powered by `QLocalServer` / `QLocalSocket` (PySide6).
When ``--live`` is passed, the debug CLI communicates with the running GUI over a local named-pipe IPC channel powered by `QLocalServer` / `QLocalSocket` (PySide6).

### Architecture

```
CLI process GUI process
─────────── ───────────
synodic-c debug <cmd>
synodic-c debug <cmd> --live
SingleInstance.send_debug_command(cmd)
Expand Down
73 changes: 27 additions & 46 deletions synodic_client/application/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from typing import TYPE_CHECKING

from synodic_client.config import is_dev_mode
from synodic_client.operations.schema import DEBUG_ACTIONS

if TYPE_CHECKING:
from porringer.api import API
Expand All @@ -32,19 +33,7 @@

logger = logging.getLogger(__name__)

_ACTIONS: dict[str, str] = {
'check_update': 'Trigger a self-update check.',
'tool_update': 'Run tool/package updates for all plugins.',
'refresh_data': 'Mark cached data as stale (next refresh re-fetches).',
'show_main': 'Show and raise the main window.',
'show_settings': 'Show the settings window.',
'apply_update': 'Apply a downloaded update and restart.',
'list_projects': 'List cached project directories with validation status.',
'add_project': 'Add a directory to the project cache. Arg: <path>',
'remove_project': 'Remove a directory from the project cache. Arg: <path>',
'project_status': 'Dump per-action preview status. Arg (optional): <path>',
'select_project': 'Select a project in the sidebar. Arg: <path>',
}
_ACTIONS = DEBUG_ACTIONS


@dataclasses.dataclass
Expand Down Expand Up @@ -164,49 +153,39 @@ def _handle_action(self, name: str, arg: str | None = None) -> str:

def _handle_list_projects(self) -> str:
"""List all cached project directories with validation status."""
from synodic_client.operations.project import list_projects
from synodic_client.operations.project import run_project_action

projects = list_projects(self._s.porringer)
return json.dumps({'projects': [dataclasses.asdict(p) for p in projects]})
return json.dumps(run_project_action('list_projects', None, self._s.porringer))

def _handle_add_project(self, arg: str | None) -> str:
"""Add a directory to the porringer cache."""
if not arg:
return json.dumps({'error': 'add_project requires a path argument'})

from synodic_client.operations.project import add_project
from synodic_client.operations.project import run_project_action

try:
add_project(self._s.porringer, arg)
except (NotADirectoryError, ValueError) as exc:
return json.dumps({'error': str(exc)})
result = run_project_action('add_project', arg, self._s.porringer)

if self._s.coordinator is not None:
self._s.coordinator.invalidate()

projects_view = self._s.main_window._projects_view
if projects_view is not None:
projects_view.refresh()
if 'error' not in result:
if self._s.coordinator is not None:
self._s.coordinator.invalidate()
projects_view = self._s.main_window._projects_view
if projects_view is not None:
projects_view.refresh()

return json.dumps({'ok': True, 'action': 'add_project', 'path': arg})
return json.dumps(result)

def _handle_remove_project(self, arg: str | None) -> str:
"""Remove a directory from the porringer cache."""
if not arg:
return json.dumps({'error': 'remove_project requires a path argument'})
from synodic_client.operations.project import run_project_action

from synodic_client.operations.project import remove_project
result = run_project_action('remove_project', arg, self._s.porringer)

remove_project(self._s.porringer, arg)

if self._s.coordinator is not None:
self._s.coordinator.invalidate()

projects_view = self._s.main_window._projects_view
if projects_view is not None:
projects_view.refresh()
if 'error' not in result:
if self._s.coordinator is not None:
self._s.coordinator.invalidate()
projects_view = self._s.main_window._projects_view
if projects_view is not None:
projects_view.refresh()

return json.dumps({'ok': True, 'action': 'remove_project', 'path': arg})
return json.dumps(result)

def _handle_project_status(self, arg: str | None) -> str:
"""Dump per-action preview status for a project."""
Expand Down Expand Up @@ -243,9 +222,11 @@ def _handle_project_status(self, arg: str | None) -> str:
entry['installer'] = act.installer
actions.append(entry)

needed = sum(1 for s in model.action_states if s.status == 'Needed')
satisfied = sum(1 for s in model.action_states if '\u2713' in s.status)
pending = sum(1 for s in model.action_states if s.status == 'Pending')
from synodic_client.operations.schema import classify_status

needed = sum(1 for s in model.action_states if classify_status(s.status) == 'needed')
satisfied = sum(1 for s in model.action_states if classify_status(s.status) == 'satisfied')
pending = sum(1 for s in model.action_states if classify_status(s.status) == 'pending')
upgradable = len(model.upgradable_keys)

return json.dumps({
Expand Down
29 changes: 6 additions & 23 deletions synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

import qasync
from porringer.api import API
from porringer.schema import LocalConfiguration
from PySide6.QtCore import QEvent, QObject, Qt, QTimer
from PySide6.QtWidgets import QApplication, QWidget

Expand All @@ -29,40 +28,24 @@
from synodic_client.client import Client
from synodic_client.config import set_dev_mode
from synodic_client.logging import configure_logging, log_path, set_debug_level
from synodic_client.operations.bootstrap import init_services
from synodic_client.protocol import extract_uri_from_args
from synodic_client.resolution import (
ResolvedConfig,
resolve_config,
resolve_update_config,
)
from synodic_client.resolution import ResolvedConfig
from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
from synodic_client.updater import initialize_velopack


def _init_services(logger: logging.Logger) -> tuple[Client, API, ResolvedConfig]:
"""Create and configure core services.

Delegates to :func:`~synodic_client.operations.bootstrap.init_services`
and adds GUI-specific debug logging.

Returns:
A (Client, porringer API, resolved config) tuple.
"""
config = resolve_config()
client = Client()

local_config = LocalConfiguration()
porringer = API(local_config)

update_config = resolve_update_config(config)
client.initialize_updater(update_config)
client, porringer, config = init_services()

cached_dirs = porringer.cache.list_directories()

logger.info(
'Synodic Client v%s started (channel: %s, source: %s, cached_projects: %d)',
client.version,
update_config.channel.name,
update_config.repo_url,
len(cached_dirs),
)
logger.debug(
'Resolved config: update_source=%s update_channel=%s auto_update=%dm tool_update=%dm '
'auto_apply=%s auto_start=%s debug_logging=%s prerelease_packages=%s plugin_auto_update=%s',
Expand Down
28 changes: 11 additions & 17 deletions synodic_client/application/screen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@
import re
from datetime import UTC, datetime

from porringer.schema import SetupAction, SetupActionResult, SkipReason
from porringer.schema import SetupAction, SetupActionResult
from porringer.schema.plugin import PluginKind

from synodic_client.operations.schema import (
SKIP_REASON_LABELS as SKIP_REASON_LABELS,
)
from synodic_client.operations.schema import (
resolve_action_status as resolve_action_status,
)
from synodic_client.operations.schema import (
skip_reason_label as skip_reason_label,
)

_SECONDS_PER_MINUTE = 60
_MINUTES_PER_HOUR = 60
_HOURS_PER_DAY = 24
Expand Down Expand Up @@ -48,22 +58,6 @@ def plugin_kind_group_label(kind: PluginKind) -> str:
return PLUGIN_KIND_GROUP_LABELS.get(kind, kind.name.replace('_', ' ').title())


SKIP_REASON_LABELS: dict[SkipReason, str] = {
SkipReason.ALREADY_INSTALLED: 'Already installed',
SkipReason.NOT_INSTALLED: 'Not installed',
SkipReason.ALREADY_LATEST: 'Already latest',
SkipReason.NO_PROJECT_DIRECTORY: 'No project directory',
SkipReason.UPDATE_AVAILABLE: 'Update available',
}


def skip_reason_label(reason: SkipReason | None) -> str:
"""Return a human-readable label for a skip reason."""
if reason is None:
return 'Skipped'
return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())


def format_cli_command(
action: SetupAction,
*,
Expand Down
Loading
Loading