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
19 changes: 2 additions & 17 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,5 @@
# AGENTS.md

This repository doesn't contain any agent specific instructions other than its [README.md](README.md), required development documentation, and its linked resources.
An application frontend for [porringer](https://www.github.com/synodic/porringer) that manages and downloads package managers and their dependents.

## Logging

Application logs are written to a deterministic path under the OS config directory:

| Mode | Path |
|----------------|------------------------------------------------------|
| Production | `%LOCALAPPDATA%\Synodic\logs\synodic.log` |
| Dev (`--dev`) | `%LOCALAPPDATA%\Synodic-Dev\logs\synodic-dev.log` |

Resolve the current log path programmatically:

```shell
python -c "from synodic_client.logging import log_path; print(log_path())"
```

Logs use rotating file handlers (1 MB max, 3 backups).
Development workflow, commands, and debug CLI reference are in `docs/development.md`.
117 changes: 117 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
@@ -1 +1,118 @@
# Development Guide

We use [PDM](https://pdm-project.org/en/latest/) as our build system and package manager. All commands below are `pdm <script>`.

## Quick Commands

| Command | Description |
|---------|-------------|
| `pdm dev` | Launch the app from source with `--dev` isolation. Accepts `-- --debug` for verbose file logging. |
| `pdm test` | Run pytest with coverage (`--cov=synodic_client`). |
| `pdm lint` | Composite: `analyze` + `format` + `type-check`. |
| `pdm analyze` | `ruff check` — linting only. |
| `pdm format` | `ruff format` — formatting only. |
| `pdm type-check` | `pyrefly check` — type checking. |

`post_install` runs automatically after `pdm install` and registers example project directories with porringer.

## Dev Mode

The `--dev` flag isolates the development instance from production:

- **Config dir:** `%LOCALAPPDATA%\Synodic-Dev\` (instead of `Synodic\`)
- **Log file:** `synodic-dev.log` (instead of `synodic.log`)
- **Instance lock:** Separate named socket — dev and production can run side-by-side.
- **Velopack + protocol registration:** Skipped in dev mode.

## Debug CLI

With the app running (`pdm dev`), inspect and control it from another terminal:

```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)
```

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. |

Example workflow:

```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
```

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
```

## IPC Console

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>
SingleInstance.send_debug_command(cmd)
│ connects to named pipe
│ writes b"debug:<cmd>"
│ waits for response
│ QLocalServer._on_new_connection()
│ reads "debug:<cmd>"
│ strips prefix → DebugHandler.handle(cmd)
│ returns JSON string
reads JSON response
pretty-prints to stdout
```

### Transport

| Detail | Value |
|--------|-------|
| Server name | `synodic-client` (production) / `synodic-client-dev` (dev mode) |
| Backing | Windows named pipe (`\\.\pipe\…`), Unix domain socket elsewhere |
| Protocol | `debug:` prefix → synchronous JSON response; all other payloads treated as `synodic://` URIs (fire-and-forget) |

### Wiring

1. **Server start** — `SingleInstance.start_server()` opens the `QLocalServer` during GUI init in `application/qt.py`.
2. **Handler registration** — `SingleInstance.set_debug_handler(debug_handler.handle)` connects the `DebugHandler` callback.
3. **Client send** — Each CLI invocation calls `SingleInstance.send_debug_command(command)`, which opens a new socket, writes the prefixed message, reads the JSON response, and disconnects.

Dev and production instances use separate server names so they can run side-by-side without colliding.

### Key files

| File | Role |
|------|------|
| `synodic_client/application/instance.py` | `SingleInstance` — QLocalServer/QLocalSocket transport, `send_debug_command()` static helper |
| `synodic_client/application/debug.py` | `DebugHandler` — dispatches commands to controllers, returns JSON |
| `synodic_client/cli/debug.py` | CLI subcommands (`state`, `actions`, `action`) that call `_send_debug()` |
| `synodic_client/application/qt.py` | Wires `SingleInstance` + `DebugHandler` during startup |
46 changes: 23 additions & 23 deletions pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 6 additions & 19 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15"
dependencies = [
"pyside6>=6.10.2",
"packaging>=26.0",
"porringer>=0.2.1.dev81",
"porringer>=0.2.1.dev84",
"qasync>=0.28.0",
"velopack>=0.0.1444.dev49733",
"typer>=0.24.1",
Expand All @@ -25,18 +25,9 @@ homepage = "https://github.com/synodic/synodic-client"
repository = "https://github.com/synodic/synodic-client"

[dependency-groups]
build = [
"pyinstaller>=6.19.0",
]
lint = [
"ruff>=0.15.5",
"pyrefly>=0.56.0",
]
test = [
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
"pytest-mock>=3.15.1",
]
build = ["pyinstaller>=6.19.0"]
lint = ["ruff>=0.15.6", "pyrefly>=0.56.0"]
test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"]

[project.scripts]
synodic-c = "synodic_client.cli:app"
Expand All @@ -53,7 +44,7 @@ line-length = 120
preview = true

[tool.ruff.lint]
ignore = ["D206", "D300", "D415", "E111", "E114", "E117"]
ignore = ["D206", "D300", "D415", "E111", "E114", "E117", "PLC0415", "PLR0913"]
select = [
"D", # pydocstyle
"F", # Pyflakes
Expand All @@ -66,10 +57,6 @@ select = [
"PT", # flake8-pytest-style
]

[tool.ruff.lint.per-file-ignores]
"synodic_client/application/bootstrap.py" = ["E402"]
"synodic_client/cli.py" = ["PLC0415"]

[tool.ruff.lint.pydocstyle]
convention = "google"

Expand All @@ -92,7 +79,7 @@ allow-prereleases = true

[tool.pdm.scripts]
analyze = "ruff check"
dev = { call = "tool.scripts.dev:main" }
dev = "synodic-c --dev"
format = "ruff format"
lint = { composite = ["analyze", "format", "type-check"] }
package = { call = "tool.scripts.package:main" }
Expand Down
64 changes: 35 additions & 29 deletions synodic_client/application/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,41 +17,47 @@
import sys
import traceback

try:
from synodic_client.config import set_dev_mode
from synodic_client.logging import configure_logging
from synodic_client.protocol import extract_uri_from_args
from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
from synodic_client.updater import initialize_velopack
except Exception:
# Last-resort crash log when imports fail before logging is configured.
import os

_fallback = os.path.join(os.environ.get('LOCALAPPDATA', '.'), 'Synodic', 'logs', 'bootstrap-crash.log')
os.makedirs(os.path.dirname(_fallback), exist_ok=True)
with open(_fallback, 'a', encoding='utf-8') as _f: # noqa: PTH123
_f.write(traceback.format_exc())
raise
def bootstrap() -> None:
"""Execute the ordered bootstrap sequence."""
try:
from synodic_client.config import set_dev_mode
from synodic_client.logging import configure_logging
from synodic_client.protocol import extract_uri_from_args
from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
from synodic_client.updater import initialize_velopack
except Exception:
# Last-resort crash log when imports fail before logging is configured.
import os

# Parse flags early so logging uses the right filename and level.
_dev_mode = '--dev' in sys.argv[1:]
_debug = '--debug' in sys.argv[1:]
set_dev_mode(_dev_mode)
_apply_subprocess_patch()
_fallback = os.path.join(os.environ.get('LOCALAPPDATA', '.'), 'Synodic', 'logs', 'bootstrap-crash.log')
os.makedirs(os.path.dirname(_fallback), exist_ok=True)
with open(_fallback, 'a', encoding='utf-8') as _f:
_f.write(traceback.format_exc())
raise

configure_logging(debug=_debug)
# Parse flags early so logging uses the right filename and level.
dev_mode = '--dev' in sys.argv[1:]
debug = '--debug' in sys.argv[1:]
set_dev_mode(dev_mode)
_apply_subprocess_patch()

_logger = logging.getLogger(__name__)
_logger.info('Bootstrap started (exe=%s, argv=%s)', sys.executable, sys.argv)
configure_logging(debug=debug)

initialize_velopack()
logger = logging.getLogger(__name__)
logger.info('Bootstrap started (exe=%s, argv=%s)', sys.executable, sys.argv)

if not _dev_mode:
from synodic_client.application.init import run_startup_preamble
initialize_velopack()

run_startup_preamble(sys.executable)
if not dev_mode:
from synodic_client.application.init import run_startup_preamble

# Heavy imports happen here — PySide6, porringer, etc.
from synodic_client.application.qt import application
run_startup_preamble(sys.executable)

application(uri=extract_uri_from_args(), dev_mode=_dev_mode, debug=_debug)
# Heavy imports happen here — PySide6, porringer, etc.
from synodic_client.application.qt import application

application(uri=extract_uri_from_args(), dev_mode=dev_mode, debug=debug)


bootstrap()
18 changes: 18 additions & 0 deletions synodic_client/application/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ def discovered_plugins(self) -> DiscoveredPlugins | None:
"""Shortcut to the current ``DiscoveredPlugins`` instance."""
return self._snapshot.discovered

@property
def is_stale(self) -> bool:
"""Whether the cached data needs refreshing."""
return self._stale

def invalidate(self) -> None:
"""Mark the cached data as stale.

Expand Down Expand Up @@ -173,14 +178,27 @@ async def _fetch(self) -> Snapshot:
if isinstance(env, PluginManager) and env.is_available():
managers[env.tool_name()] = env

# Step 5: collect protocol capabilities for each plugin
capabilities: dict[str, frozenset] = {
plugin.name: frozenset(discovered.capabilities(plugin.name)) for plugin in plugins
}

# Derive the un-validated directory list for callers that only
# need path + name (e.g. _gather_packages).
directories = [r.directory for r in validated]

logger.info(
'Discovery complete: %d plugin(s), %d directory(ies), %d plugin manager(s)',
len(plugins),
len(directories),
len(managers),
)

return Snapshot(
plugins=plugins,
directories=directories,
validated_directories=validated,
discovered=discovered,
plugin_managers=managers,
plugin_capabilities=capabilities,
)
Loading
Loading