richmack-workspace 0.1.3__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.
- richmack_workspace-0.1.3/.github/workflows/publish.yml +56 -0
- richmack_workspace-0.1.3/.gitignore +8 -0
- richmack_workspace-0.1.3/PKG-INFO +160 -0
- richmack_workspace-0.1.3/README.md +149 -0
- richmack_workspace-0.1.3/install.sh +13 -0
- richmack_workspace-0.1.3/language_ninja.db +0 -0
- richmack_workspace-0.1.3/pyproject.toml +25 -0
- richmack_workspace-0.1.3/richmack_workspace/__init__.py +1 -0
- richmack_workspace-0.1.3/richmack_workspace/app.py +354 -0
- richmack_workspace-0.1.3/richmack_workspace/catalog.py +65 -0
- richmack_workspace-0.1.3/richmack_workspace/db.py +52 -0
- richmack_workspace-0.1.3/richmack_workspace/manager.py +184 -0
- richmack_workspace-0.1.3/richmack_workspace/paths.py +15 -0
- richmack_workspace-0.1.3/tests/test_catalog.py +10 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: Publish Richmack Workspace to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
name: Build package
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout repository
|
|
17
|
+
uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
|
|
24
|
+
- name: Install build tools
|
|
25
|
+
run: python -m pip install --upgrade build
|
|
26
|
+
|
|
27
|
+
- name: Build distribution
|
|
28
|
+
run: python -m build
|
|
29
|
+
|
|
30
|
+
- name: Upload distributions
|
|
31
|
+
uses: actions/upload-artifact@v4
|
|
32
|
+
with:
|
|
33
|
+
name: python-package-distributions
|
|
34
|
+
path: dist/
|
|
35
|
+
|
|
36
|
+
publish:
|
|
37
|
+
name: Publish to PyPI
|
|
38
|
+
needs: build
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
|
|
41
|
+
environment:
|
|
42
|
+
name: pypi
|
|
43
|
+
url: https://pypi.org/project/richmack-workspace/
|
|
44
|
+
|
|
45
|
+
permissions:
|
|
46
|
+
id-token: write
|
|
47
|
+
|
|
48
|
+
steps:
|
|
49
|
+
- name: Download distributions
|
|
50
|
+
uses: actions/download-artifact@v4
|
|
51
|
+
with:
|
|
52
|
+
name: python-package-distributions
|
|
53
|
+
path: dist/
|
|
54
|
+
|
|
55
|
+
- name: Publish package to PyPI
|
|
56
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: richmack-workspace
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Terminal workspace and launcher for the Richmack PyPI application ecosystem.
|
|
5
|
+
Author: Jeremy Franklin
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: platformdirs>=4.0
|
|
9
|
+
Requires-Dist: textual>=0.70
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# ⚡ Richmack Workspace
|
|
13
|
+
|
|
14
|
+
**Richmack Workspace** is a keyboard-first Textual launcher and package manager for the Richmack terminal application ecosystem.
|
|
15
|
+
|
|
16
|
+
## 🚀 Current application catalog
|
|
17
|
+
|
|
18
|
+
- 🧙 **Wize Wizard** — `wize-wizard`
|
|
19
|
+
- 🥷 **Language Ninja** — `language-ninja`
|
|
20
|
+
- 🍯 **Pot of Mannah** — `pot-of-mannah`
|
|
21
|
+
- 💪 **ExerxEye** — `ExerxEye`
|
|
22
|
+
- 📜 **Hebrew Fuzzy Study** — `hebrew-fuzzy-study`
|
|
23
|
+
- 🧞 **JinnLab** — `jinnlab`
|
|
24
|
+
|
|
25
|
+
Each managed application is installed in its **own isolated virtual environment**, so dependencies from one Richmack application do not break another.
|
|
26
|
+
|
|
27
|
+
## 🧠 Architecture
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
Richmack Workspace
|
|
31
|
+
│
|
|
32
|
+
├── App Catalog
|
|
33
|
+
│
|
|
34
|
+
├── SQLite Activity Registry
|
|
35
|
+
│
|
|
36
|
+
└── Managed Application Environments
|
|
37
|
+
│
|
|
38
|
+
├── wize-wizard/.venv
|
|
39
|
+
├── language-ninja/.venv
|
|
40
|
+
├── pot-of-mannah/.venv
|
|
41
|
+
├── exerxeye/.venv
|
|
42
|
+
├── hebrew-fuzzy-study/.venv
|
|
43
|
+
└── jinnlab/.venv
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Managed data is stored under the platform-specific user data directory. On macOS this is typically under:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
~/Library/Application Support/richmack-workspace/
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 🛠️ Development install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
cd richmack-workspace
|
|
56
|
+
chmod +x install.sh
|
|
57
|
+
./install.sh
|
|
58
|
+
source .venv/bin/activate
|
|
59
|
+
richmack
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python3 -m venv .venv
|
|
66
|
+
source .venv/bin/activate
|
|
67
|
+
python -m pip install --upgrade pip
|
|
68
|
+
python -m pip install -e .
|
|
69
|
+
richmack
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## ⌨️ Keyboard controls
|
|
73
|
+
|
|
74
|
+
| Key | Action |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `↑` / `↓` | Select an application |
|
|
77
|
+
| `Enter` | Launch selected application |
|
|
78
|
+
| `G` | Get/install |
|
|
79
|
+
| `U` | Update |
|
|
80
|
+
| `X` | Remove |
|
|
81
|
+
| `R` | Refresh |
|
|
82
|
+
| `Q` | Quit |
|
|
83
|
+
|
|
84
|
+
## 📦 How installation works
|
|
85
|
+
|
|
86
|
+
When you select an application and press `G`, Workspace:
|
|
87
|
+
|
|
88
|
+
1. Creates an isolated virtual environment.
|
|
89
|
+
2. Upgrades pip inside that environment.
|
|
90
|
+
3. Installs the selected package from PyPI.
|
|
91
|
+
4. Reads the installed package metadata.
|
|
92
|
+
5. Detects the package's `console_scripts` entry point.
|
|
93
|
+
6. Records the operation in SQLite.
|
|
94
|
+
|
|
95
|
+
When you press `Enter`, Workspace suspends its own Textual interface and gives the terminal to the selected child TUI. When the child program exits, you return to Richmack Workspace.
|
|
96
|
+
|
|
97
|
+
## 🧪 Test
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
python -m pip install pytest
|
|
101
|
+
pytest -q
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 📦 Build
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python -m pip install build
|
|
108
|
+
python -m build
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Expected output:
|
|
112
|
+
|
|
113
|
+
```text
|
|
114
|
+
dist/
|
|
115
|
+
├── richmack_workspace-0.1.0-py3-none-any.whl
|
|
116
|
+
└── richmack_workspace-0.1.0.tar.gz
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## 🌐 Future roadmap
|
|
120
|
+
|
|
121
|
+
### v0.2
|
|
122
|
+
- PyPI latest-version lookup
|
|
123
|
+
- Update-available indicator
|
|
124
|
+
- Update All
|
|
125
|
+
- Installed-only view
|
|
126
|
+
- Launch counters
|
|
127
|
+
|
|
128
|
+
### v0.3
|
|
129
|
+
- Application search
|
|
130
|
+
- Favorites
|
|
131
|
+
- Shared Richmack theme/config
|
|
132
|
+
- Health checks
|
|
133
|
+
- Broken-environment repair
|
|
134
|
+
|
|
135
|
+
### v1.0
|
|
136
|
+
- Richmack account/workspace profile
|
|
137
|
+
- Shared project registry
|
|
138
|
+
- Cross-application file handoff
|
|
139
|
+
- Richmack Workspace plugin manifest
|
|
140
|
+
- Optional remote package catalog
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
**Richmack Workspace** is intended to become the terminal front door for the wider Richmack software ecosystem.
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
## ✨ v0.1.1 interface improvements
|
|
148
|
+
|
|
149
|
+
- Added large **RICHMACK WORKSPACE** ASCII branding.
|
|
150
|
+
- Added an always-visible keyboard instruction strip.
|
|
151
|
+
- Increased application-menu row height and spacing.
|
|
152
|
+
- Replaced human-character emojis with more neutral symbols.
|
|
153
|
+
- Package installation and update output is now redirected to per-app log files instead of being printed over the Textual display.
|
|
154
|
+
- Package-manager failures show the log location in the TUI.
|
|
155
|
+
|
|
156
|
+
Logs are stored in the Workspace data directory under:
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
logs/<application>.log
|
|
160
|
+
```
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# ⚡ Richmack Workspace
|
|
2
|
+
|
|
3
|
+
**Richmack Workspace** is a keyboard-first Textual launcher and package manager for the Richmack terminal application ecosystem.
|
|
4
|
+
|
|
5
|
+
## 🚀 Current application catalog
|
|
6
|
+
|
|
7
|
+
- 🧙 **Wize Wizard** — `wize-wizard`
|
|
8
|
+
- 🥷 **Language Ninja** — `language-ninja`
|
|
9
|
+
- 🍯 **Pot of Mannah** — `pot-of-mannah`
|
|
10
|
+
- 💪 **ExerxEye** — `ExerxEye`
|
|
11
|
+
- 📜 **Hebrew Fuzzy Study** — `hebrew-fuzzy-study`
|
|
12
|
+
- 🧞 **JinnLab** — `jinnlab`
|
|
13
|
+
|
|
14
|
+
Each managed application is installed in its **own isolated virtual environment**, so dependencies from one Richmack application do not break another.
|
|
15
|
+
|
|
16
|
+
## 🧠 Architecture
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
Richmack Workspace
|
|
20
|
+
│
|
|
21
|
+
├── App Catalog
|
|
22
|
+
│
|
|
23
|
+
├── SQLite Activity Registry
|
|
24
|
+
│
|
|
25
|
+
└── Managed Application Environments
|
|
26
|
+
│
|
|
27
|
+
├── wize-wizard/.venv
|
|
28
|
+
├── language-ninja/.venv
|
|
29
|
+
├── pot-of-mannah/.venv
|
|
30
|
+
├── exerxeye/.venv
|
|
31
|
+
├── hebrew-fuzzy-study/.venv
|
|
32
|
+
└── jinnlab/.venv
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Managed data is stored under the platform-specific user data directory. On macOS this is typically under:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
~/Library/Application Support/richmack-workspace/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 🛠️ Development install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cd richmack-workspace
|
|
45
|
+
chmod +x install.sh
|
|
46
|
+
./install.sh
|
|
47
|
+
source .venv/bin/activate
|
|
48
|
+
richmack
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python3 -m venv .venv
|
|
55
|
+
source .venv/bin/activate
|
|
56
|
+
python -m pip install --upgrade pip
|
|
57
|
+
python -m pip install -e .
|
|
58
|
+
richmack
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## ⌨️ Keyboard controls
|
|
62
|
+
|
|
63
|
+
| Key | Action |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `↑` / `↓` | Select an application |
|
|
66
|
+
| `Enter` | Launch selected application |
|
|
67
|
+
| `G` | Get/install |
|
|
68
|
+
| `U` | Update |
|
|
69
|
+
| `X` | Remove |
|
|
70
|
+
| `R` | Refresh |
|
|
71
|
+
| `Q` | Quit |
|
|
72
|
+
|
|
73
|
+
## 📦 How installation works
|
|
74
|
+
|
|
75
|
+
When you select an application and press `G`, Workspace:
|
|
76
|
+
|
|
77
|
+
1. Creates an isolated virtual environment.
|
|
78
|
+
2. Upgrades pip inside that environment.
|
|
79
|
+
3. Installs the selected package from PyPI.
|
|
80
|
+
4. Reads the installed package metadata.
|
|
81
|
+
5. Detects the package's `console_scripts` entry point.
|
|
82
|
+
6. Records the operation in SQLite.
|
|
83
|
+
|
|
84
|
+
When you press `Enter`, Workspace suspends its own Textual interface and gives the terminal to the selected child TUI. When the child program exits, you return to Richmack Workspace.
|
|
85
|
+
|
|
86
|
+
## 🧪 Test
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
python -m pip install pytest
|
|
90
|
+
pytest -q
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 📦 Build
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m pip install build
|
|
97
|
+
python -m build
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Expected output:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
dist/
|
|
104
|
+
├── richmack_workspace-0.1.0-py3-none-any.whl
|
|
105
|
+
└── richmack_workspace-0.1.0.tar.gz
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## 🌐 Future roadmap
|
|
109
|
+
|
|
110
|
+
### v0.2
|
|
111
|
+
- PyPI latest-version lookup
|
|
112
|
+
- Update-available indicator
|
|
113
|
+
- Update All
|
|
114
|
+
- Installed-only view
|
|
115
|
+
- Launch counters
|
|
116
|
+
|
|
117
|
+
### v0.3
|
|
118
|
+
- Application search
|
|
119
|
+
- Favorites
|
|
120
|
+
- Shared Richmack theme/config
|
|
121
|
+
- Health checks
|
|
122
|
+
- Broken-environment repair
|
|
123
|
+
|
|
124
|
+
### v1.0
|
|
125
|
+
- Richmack account/workspace profile
|
|
126
|
+
- Shared project registry
|
|
127
|
+
- Cross-application file handoff
|
|
128
|
+
- Richmack Workspace plugin manifest
|
|
129
|
+
- Optional remote package catalog
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
**Richmack Workspace** is intended to become the terminal front door for the wider Richmack software ecosystem.
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
## ✨ v0.1.1 interface improvements
|
|
137
|
+
|
|
138
|
+
- Added large **RICHMACK WORKSPACE** ASCII branding.
|
|
139
|
+
- Added an always-visible keyboard instruction strip.
|
|
140
|
+
- Increased application-menu row height and spacing.
|
|
141
|
+
- Replaced human-character emojis with more neutral symbols.
|
|
142
|
+
- Package installation and update output is now redirected to per-app log files instead of being printed over the Textual display.
|
|
143
|
+
- Package-manager failures show the log location in the TUI.
|
|
144
|
+
|
|
145
|
+
Logs are stored in the Workspace data directory under:
|
|
146
|
+
|
|
147
|
+
```text
|
|
148
|
+
logs/<application>.log
|
|
149
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
python3 -m venv .venv
|
|
5
|
+
source .venv/bin/activate
|
|
6
|
+
python -m pip install --upgrade pip
|
|
7
|
+
python -m pip install -e .
|
|
8
|
+
|
|
9
|
+
echo
|
|
10
|
+
echo "✅ Richmack Workspace installed in .venv"
|
|
11
|
+
echo "Run:"
|
|
12
|
+
echo " source .venv/bin/activate"
|
|
13
|
+
echo " richmack"
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "richmack-workspace"
|
|
7
|
+
version = "0.1.3"
|
|
8
|
+
description = "Terminal workspace and launcher for the Richmack PyPI application ecosystem."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Jeremy Franklin" }
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"textual>=0.70",
|
|
17
|
+
"platformdirs>=4.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
richmack = "richmack_workspace.app:main"
|
|
22
|
+
richmack-workspace = "richmack_workspace.app:main"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["richmack_workspace"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from textual import work
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.containers import Horizontal, Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Footer, Header, Label, ListItem, ListView, Static
|
|
9
|
+
|
|
10
|
+
from .catalog import APPS, WorkspaceApp
|
|
11
|
+
from .db import recent_activity
|
|
12
|
+
from .manager import WorkspaceManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
WORKSPACE_ART = r"""
|
|
16
|
+
____ ___ ____ _ _ __ __ _ ____ _ __
|
|
17
|
+
| _ \\|_ _/ ___| | | | \\/ | / \\ / ___| |/ /
|
|
18
|
+
| |_) || | | | |_| | |\\/| | / _ \\| | | ' /
|
|
19
|
+
| _ < | | |___| _ | | | |/ ___ \\ |___| . \\
|
|
20
|
+
|_| \\_\\___\\____|_| |_|_| |_/_/ \\_\\____|_|\\_\\
|
|
21
|
+
|
|
22
|
+
W O R K S P A C E
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfirmUninstall(ModalScreen[bool]):
|
|
27
|
+
CSS = """
|
|
28
|
+
ConfirmUninstall {
|
|
29
|
+
align: center middle;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#confirm-box {
|
|
33
|
+
width: 64;
|
|
34
|
+
height: auto;
|
|
35
|
+
border: round $warning;
|
|
36
|
+
padding: 1 2;
|
|
37
|
+
background: $surface;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#confirm-buttons {
|
|
41
|
+
height: auto;
|
|
42
|
+
align-horizontal: center;
|
|
43
|
+
margin-top: 1;
|
|
44
|
+
}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, app_name: str) -> None:
|
|
48
|
+
super().__init__()
|
|
49
|
+
self.app_name = app_name
|
|
50
|
+
|
|
51
|
+
def compose(self) -> ComposeResult:
|
|
52
|
+
with Vertical(id="confirm-box"):
|
|
53
|
+
yield Label(f"Remove {self.app_name} from Richmack Workspace?")
|
|
54
|
+
with Horizontal(id="confirm-buttons"):
|
|
55
|
+
yield Button("Cancel", id="cancel")
|
|
56
|
+
yield Button("Remove", id="remove", variant="error")
|
|
57
|
+
|
|
58
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
59
|
+
self.dismiss(event.button.id == "remove")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RichmackWorkspace(App):
|
|
63
|
+
TITLE = "Richmack Workspace"
|
|
64
|
+
SUB_TITLE = "Terminal Intelligence Environment"
|
|
65
|
+
|
|
66
|
+
CSS = """
|
|
67
|
+
Screen {
|
|
68
|
+
layout: vertical;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#brand {
|
|
72
|
+
height: 9;
|
|
73
|
+
content-align: center middle;
|
|
74
|
+
text-align: center;
|
|
75
|
+
text-style: bold;
|
|
76
|
+
border-bottom: heavy $primary;
|
|
77
|
+
padding: 0 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#instructions {
|
|
81
|
+
height: 3;
|
|
82
|
+
content-align: center middle;
|
|
83
|
+
text-align: center;
|
|
84
|
+
padding: 0 1;
|
|
85
|
+
border-bottom: solid $boost;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#main {
|
|
89
|
+
height: 1fr;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#apps {
|
|
93
|
+
width: 42%;
|
|
94
|
+
min-width: 38;
|
|
95
|
+
border: round $primary;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#detail {
|
|
99
|
+
width: 58%;
|
|
100
|
+
padding: 1 2;
|
|
101
|
+
border: round $secondary;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#big-icon {
|
|
105
|
+
height: 5;
|
|
106
|
+
content-align: center middle;
|
|
107
|
+
text-align: center;
|
|
108
|
+
text-style: bold;
|
|
109
|
+
border-bottom: solid $boost;
|
|
110
|
+
margin-bottom: 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#app-title {
|
|
114
|
+
text-style: bold;
|
|
115
|
+
text-align: center;
|
|
116
|
+
margin-bottom: 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#description {
|
|
120
|
+
margin-bottom: 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#status {
|
|
124
|
+
margin: 1 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#actions {
|
|
128
|
+
height: auto;
|
|
129
|
+
margin-top: 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#actions Button {
|
|
133
|
+
margin-right: 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#activity {
|
|
137
|
+
margin-top: 2;
|
|
138
|
+
height: 1fr;
|
|
139
|
+
border-top: solid $boost;
|
|
140
|
+
padding-top: 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ListItem {
|
|
144
|
+
height: 3;
|
|
145
|
+
padding: 1 2;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
ListItem.--highlight {
|
|
149
|
+
text-style: bold;
|
|
150
|
+
}
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
BINDINGS = [
|
|
154
|
+
("enter", "launch", "Launch"),
|
|
155
|
+
("g", "install", "Get"),
|
|
156
|
+
("u", "update", "Update"),
|
|
157
|
+
("x", "uninstall", "Remove"),
|
|
158
|
+
("r", "refresh", "Refresh"),
|
|
159
|
+
("q", "quit", "Quit"),
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
def __init__(self) -> None:
|
|
163
|
+
super().__init__()
|
|
164
|
+
self.manager = WorkspaceManager()
|
|
165
|
+
self.selected_index = 0
|
|
166
|
+
|
|
167
|
+
def compose(self) -> ComposeResult:
|
|
168
|
+
yield Header(show_clock=True)
|
|
169
|
+
yield Static(WORKSPACE_ART, id="brand")
|
|
170
|
+
yield Static(
|
|
171
|
+
"UP/DOWN Select ENTER Launch G Install U Update "
|
|
172
|
+
"X Remove R Refresh Q Quit",
|
|
173
|
+
id="instructions",
|
|
174
|
+
)
|
|
175
|
+
with Horizontal(id="main"):
|
|
176
|
+
yield ListView(
|
|
177
|
+
*[
|
|
178
|
+
ListItem(
|
|
179
|
+
Label(f" {app.emoji} {app.name}"),
|
|
180
|
+
id=f"app-{i}",
|
|
181
|
+
)
|
|
182
|
+
for i, app in enumerate(APPS)
|
|
183
|
+
],
|
|
184
|
+
id="apps",
|
|
185
|
+
)
|
|
186
|
+
with Vertical(id="detail"):
|
|
187
|
+
yield Static("", id="big-icon")
|
|
188
|
+
yield Static("", id="app-title")
|
|
189
|
+
yield Static("", id="description")
|
|
190
|
+
yield Static("", id="package")
|
|
191
|
+
yield Static("", id="status")
|
|
192
|
+
with Horizontal(id="actions"):
|
|
193
|
+
yield Button("Launch", id="launch", variant="primary")
|
|
194
|
+
yield Button("Get", id="install")
|
|
195
|
+
yield Button("Update", id="update")
|
|
196
|
+
yield Button("Remove", id="uninstall", variant="error")
|
|
197
|
+
yield Static("", id="activity")
|
|
198
|
+
yield Footer()
|
|
199
|
+
|
|
200
|
+
def on_mount(self) -> None:
|
|
201
|
+
apps = self.query_one("#apps", ListView)
|
|
202
|
+
apps.index = 0
|
|
203
|
+
self.refresh_detail()
|
|
204
|
+
|
|
205
|
+
def selected_app(self) -> WorkspaceApp:
|
|
206
|
+
return APPS[self.selected_index]
|
|
207
|
+
|
|
208
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
209
|
+
if event.item is None:
|
|
210
|
+
return
|
|
211
|
+
item_id = event.item.id or ""
|
|
212
|
+
if item_id.startswith("app-"):
|
|
213
|
+
self.selected_index = int(item_id.split("-", 1)[1])
|
|
214
|
+
self.refresh_detail()
|
|
215
|
+
|
|
216
|
+
def refresh_detail(self) -> None:
|
|
217
|
+
app = self.selected_app()
|
|
218
|
+
status = self.manager.status(app)
|
|
219
|
+
|
|
220
|
+
self.query_one("#big-icon", Static).update(
|
|
221
|
+
f"[bold]\n {app.emoji} {app.emoji} {app.emoji}\n[/bold]"
|
|
222
|
+
)
|
|
223
|
+
self.query_one("#app-title", Static).update(f"[bold]{app.name}[/bold]")
|
|
224
|
+
self.query_one("#description", Static).update(app.description)
|
|
225
|
+
self.query_one("#package", Static).update(f"PyPI package: {app.package}")
|
|
226
|
+
|
|
227
|
+
if status.installed:
|
|
228
|
+
entry = status.entrypoint or "No console entry point detected"
|
|
229
|
+
status_text = (
|
|
230
|
+
f"[bold green]Installed[/bold green]\n"
|
|
231
|
+
f"Version: {status.version}\n"
|
|
232
|
+
f"Command: {entry}"
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
status_text = (
|
|
236
|
+
"[yellow]Not installed[/yellow]\n"
|
|
237
|
+
"Press G or choose Get to install this application."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
self.query_one("#status", Static).update(status_text)
|
|
241
|
+
|
|
242
|
+
rows = recent_activity(8)
|
|
243
|
+
if not rows:
|
|
244
|
+
activity = "[bold]Recent Activity[/bold]\nNo activity yet."
|
|
245
|
+
else:
|
|
246
|
+
lines = ["[bold]Recent Activity[/bold]"]
|
|
247
|
+
for row in rows:
|
|
248
|
+
stamp = row["created_at"].replace("T", " ")[:16]
|
|
249
|
+
lines.append(f"{stamp} {row['app_slug']:<20} {row['action']}")
|
|
250
|
+
activity = "\n".join(lines)
|
|
251
|
+
self.query_one("#activity", Static).update(activity)
|
|
252
|
+
|
|
253
|
+
def notify_error(self, error: Exception) -> None:
|
|
254
|
+
self.notify(str(error), severity="error", timeout=8)
|
|
255
|
+
|
|
256
|
+
@work(thread=True, exclusive=True)
|
|
257
|
+
def install_selected(self) -> None:
|
|
258
|
+
app = self.selected_app()
|
|
259
|
+
try:
|
|
260
|
+
self.call_from_thread(
|
|
261
|
+
self.notify,
|
|
262
|
+
f"Installing {app.name} in the background…",
|
|
263
|
+
timeout=5,
|
|
264
|
+
)
|
|
265
|
+
self.manager.install(app)
|
|
266
|
+
self.call_from_thread(
|
|
267
|
+
self.notify,
|
|
268
|
+
f"{app.name} installed successfully.",
|
|
269
|
+
severity="information",
|
|
270
|
+
)
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
self.call_from_thread(self.notify_error, exc)
|
|
273
|
+
finally:
|
|
274
|
+
self.call_from_thread(self.refresh_detail)
|
|
275
|
+
|
|
276
|
+
@work(thread=True, exclusive=True)
|
|
277
|
+
def update_selected(self) -> None:
|
|
278
|
+
app = self.selected_app()
|
|
279
|
+
try:
|
|
280
|
+
self.call_from_thread(
|
|
281
|
+
self.notify,
|
|
282
|
+
f"Updating {app.name} in the background…",
|
|
283
|
+
timeout=5,
|
|
284
|
+
)
|
|
285
|
+
self.manager.update(app)
|
|
286
|
+
self.call_from_thread(
|
|
287
|
+
self.notify,
|
|
288
|
+
f"{app.name} update complete.",
|
|
289
|
+
)
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
self.call_from_thread(self.notify_error, exc)
|
|
292
|
+
finally:
|
|
293
|
+
self.call_from_thread(self.refresh_detail)
|
|
294
|
+
|
|
295
|
+
def action_install(self) -> None:
|
|
296
|
+
self.install_selected()
|
|
297
|
+
|
|
298
|
+
def action_update(self) -> None:
|
|
299
|
+
self.update_selected()
|
|
300
|
+
|
|
301
|
+
def action_refresh(self) -> None:
|
|
302
|
+
self.refresh_detail()
|
|
303
|
+
self.notify("Workspace refreshed.")
|
|
304
|
+
|
|
305
|
+
def action_launch(self) -> None:
|
|
306
|
+
app = self.selected_app()
|
|
307
|
+
try:
|
|
308
|
+
if not self.manager.status(app).installed:
|
|
309
|
+
self.notify(
|
|
310
|
+
f"{app.name} is not installed. Press G to install.",
|
|
311
|
+
severity="warning",
|
|
312
|
+
)
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
with self.suspend():
|
|
316
|
+
self.manager.launch(app)
|
|
317
|
+
|
|
318
|
+
self.refresh_detail()
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
self.notify_error(exc)
|
|
321
|
+
|
|
322
|
+
def action_uninstall(self) -> None:
|
|
323
|
+
app = self.selected_app()
|
|
324
|
+
|
|
325
|
+
def after_confirm(remove: bool | None) -> None:
|
|
326
|
+
if not remove:
|
|
327
|
+
return
|
|
328
|
+
try:
|
|
329
|
+
self.manager.uninstall(app)
|
|
330
|
+
self.notify(f"{app.name} removed.")
|
|
331
|
+
self.refresh_detail()
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
self.notify_error(exc)
|
|
334
|
+
|
|
335
|
+
self.push_screen(ConfirmUninstall(app.name), after_confirm)
|
|
336
|
+
|
|
337
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
338
|
+
match event.button.id:
|
|
339
|
+
case "launch":
|
|
340
|
+
self.action_launch()
|
|
341
|
+
case "install":
|
|
342
|
+
self.action_install()
|
|
343
|
+
case "update":
|
|
344
|
+
self.action_update()
|
|
345
|
+
case "uninstall":
|
|
346
|
+
self.action_uninstall()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def main() -> None:
|
|
350
|
+
RichmackWorkspace().run()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class WorkspaceApp:
|
|
8
|
+
name: str
|
|
9
|
+
package: str
|
|
10
|
+
emoji: str
|
|
11
|
+
description: str
|
|
12
|
+
slug: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
APPS: tuple[WorkspaceApp, ...] = (
|
|
16
|
+
WorkspaceApp(
|
|
17
|
+
name="Wize Wizard",
|
|
18
|
+
package="wize-wizard",
|
|
19
|
+
emoji="🪄",
|
|
20
|
+
description="Strategy, PERT, communications, tasks, and journal planner.",
|
|
21
|
+
slug="wize-wizard",
|
|
22
|
+
),
|
|
23
|
+
WorkspaceApp(
|
|
24
|
+
name="Language Ninja",
|
|
25
|
+
package="language-ninja",
|
|
26
|
+
emoji="🧠",
|
|
27
|
+
description="NLP sentiment training, analysis, notes, and export workstation.",
|
|
28
|
+
slug="language-ninja",
|
|
29
|
+
),
|
|
30
|
+
WorkspaceApp(
|
|
31
|
+
name="Pot of Mannah",
|
|
32
|
+
package="pot-of-mannah",
|
|
33
|
+
emoji="🍯",
|
|
34
|
+
description="Terminal nutrition and training intelligence.",
|
|
35
|
+
slug="pot-of-mannah",
|
|
36
|
+
),
|
|
37
|
+
WorkspaceApp(
|
|
38
|
+
name="ExerxEye",
|
|
39
|
+
package="ExerxEye",
|
|
40
|
+
emoji="⚙️",
|
|
41
|
+
description="Exercise intelligence, workout tracking, and progress analytics.",
|
|
42
|
+
slug="exerxeye",
|
|
43
|
+
),
|
|
44
|
+
WorkspaceApp(
|
|
45
|
+
name="Hebrew Fuzzy Study",
|
|
46
|
+
package="hebrew-fuzzy-study",
|
|
47
|
+
emoji="📜",
|
|
48
|
+
description="Hebrew lexical search, Tanakh reader, scholar reference, and annotation.",
|
|
49
|
+
slug="hebrew-fuzzy-study",
|
|
50
|
+
),
|
|
51
|
+
WorkspaceApp(
|
|
52
|
+
name="JinnLab",
|
|
53
|
+
package="jinnlab",
|
|
54
|
+
emoji="♟️",
|
|
55
|
+
description="Game theory laboratory for Axelrod matches, Moran processes, and tournaments.",
|
|
56
|
+
slug="jinnlab",
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def by_slug(slug: str) -> WorkspaceApp:
|
|
62
|
+
for app in APPS:
|
|
63
|
+
if app.slug == slug:
|
|
64
|
+
return app
|
|
65
|
+
raise KeyError(slug)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from .paths import DB_PATH, ensure_dirs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def connect() -> sqlite3.Connection:
|
|
10
|
+
ensure_dirs()
|
|
11
|
+
con = sqlite3.connect(DB_PATH)
|
|
12
|
+
con.row_factory = sqlite3.Row
|
|
13
|
+
con.execute(
|
|
14
|
+
"""
|
|
15
|
+
CREATE TABLE IF NOT EXISTS activity (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
created_at TEXT NOT NULL,
|
|
18
|
+
app_slug TEXT NOT NULL,
|
|
19
|
+
action TEXT NOT NULL,
|
|
20
|
+
detail TEXT
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
)
|
|
24
|
+
con.commit()
|
|
25
|
+
return con
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def log_activity(app_slug: str, action: str, detail: str = "") -> None:
|
|
29
|
+
with connect() as con:
|
|
30
|
+
con.execute(
|
|
31
|
+
"INSERT INTO activity(created_at, app_slug, action, detail) VALUES (?, ?, ?, ?)",
|
|
32
|
+
(
|
|
33
|
+
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
34
|
+
app_slug,
|
|
35
|
+
action,
|
|
36
|
+
detail,
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
con.commit()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def recent_activity(limit: int = 20) -> list[sqlite3.Row]:
|
|
43
|
+
with connect() as con:
|
|
44
|
+
return con.execute(
|
|
45
|
+
"""
|
|
46
|
+
SELECT created_at, app_slug, action, detail
|
|
47
|
+
FROM activity
|
|
48
|
+
ORDER BY id DESC
|
|
49
|
+
LIMIT ?
|
|
50
|
+
""",
|
|
51
|
+
(limit,),
|
|
52
|
+
).fetchall()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .catalog import WorkspaceApp
|
|
12
|
+
from .db import log_activity
|
|
13
|
+
from .paths import APPS_DIR, LOG_DIR, ensure_dirs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class AppStatus:
|
|
18
|
+
installed: bool
|
|
19
|
+
version: str | None = None
|
|
20
|
+
entrypoint: str | None = None
|
|
21
|
+
venv_dir: Path | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WorkspaceManager:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
ensure_dirs()
|
|
27
|
+
|
|
28
|
+
def _run_logged(self, app: WorkspaceApp, command: list[str]) -> None:
|
|
29
|
+
"""Run package-management commands without corrupting the Textual screen."""
|
|
30
|
+
log_path = LOG_DIR / f"{app.slug}.log"
|
|
31
|
+
with log_path.open("a", encoding="utf-8") as log:
|
|
32
|
+
log.write("\n$ " + " ".join(command) + "\n")
|
|
33
|
+
log.flush()
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
command,
|
|
36
|
+
stdout=log,
|
|
37
|
+
stderr=subprocess.STDOUT,
|
|
38
|
+
text=True,
|
|
39
|
+
check=False,
|
|
40
|
+
)
|
|
41
|
+
if result.returncode != 0:
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
f"Command failed for {app.name}. See log: {log_path}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def app_dir(self, app: WorkspaceApp) -> Path:
|
|
47
|
+
return APPS_DIR / app.slug
|
|
48
|
+
|
|
49
|
+
def venv_dir(self, app: WorkspaceApp) -> Path:
|
|
50
|
+
return self.app_dir(app) / ".venv"
|
|
51
|
+
|
|
52
|
+
def python_path(self, app: WorkspaceApp) -> Path:
|
|
53
|
+
if os.name == "nt":
|
|
54
|
+
return self.venv_dir(app) / "Scripts" / "python.exe"
|
|
55
|
+
return self.venv_dir(app) / "bin" / "python"
|
|
56
|
+
|
|
57
|
+
def bin_dir(self, app: WorkspaceApp) -> Path:
|
|
58
|
+
if os.name == "nt":
|
|
59
|
+
return self.venv_dir(app) / "Scripts"
|
|
60
|
+
return self.venv_dir(app) / "bin"
|
|
61
|
+
|
|
62
|
+
def _metadata(self, app: WorkspaceApp) -> tuple[str | None, str | None]:
|
|
63
|
+
python = self.python_path(app)
|
|
64
|
+
if not python.exists():
|
|
65
|
+
return None, None
|
|
66
|
+
|
|
67
|
+
code = r"""
|
|
68
|
+
import importlib.metadata as md, json
|
|
69
|
+
package = __import__("sys").argv[1]
|
|
70
|
+
try:
|
|
71
|
+
dist = md.distribution(package)
|
|
72
|
+
except md.PackageNotFoundError:
|
|
73
|
+
print(json.dumps({"version": None, "entrypoint": None}))
|
|
74
|
+
raise SystemExit(0)
|
|
75
|
+
|
|
76
|
+
eps = [
|
|
77
|
+
ep.name for ep in dist.entry_points
|
|
78
|
+
if ep.group == "console_scripts"
|
|
79
|
+
]
|
|
80
|
+
print(json.dumps({
|
|
81
|
+
"version": dist.version,
|
|
82
|
+
"entrypoint": eps[0] if eps else None,
|
|
83
|
+
}))
|
|
84
|
+
"""
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
[str(python), "-c", code, app.package],
|
|
87
|
+
text=True,
|
|
88
|
+
stdout=subprocess.PIPE,
|
|
89
|
+
stderr=subprocess.PIPE,
|
|
90
|
+
check=False,
|
|
91
|
+
)
|
|
92
|
+
if result.returncode != 0:
|
|
93
|
+
return None, None
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(result.stdout.strip() or "{}")
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
return None, None
|
|
98
|
+
return data.get("version"), data.get("entrypoint")
|
|
99
|
+
|
|
100
|
+
def status(self, app: WorkspaceApp) -> AppStatus:
|
|
101
|
+
python = self.python_path(app)
|
|
102
|
+
if not python.exists():
|
|
103
|
+
return AppStatus(False, venv_dir=self.venv_dir(app))
|
|
104
|
+
|
|
105
|
+
version, entrypoint = self._metadata(app)
|
|
106
|
+
return AppStatus(
|
|
107
|
+
installed=bool(version),
|
|
108
|
+
version=version,
|
|
109
|
+
entrypoint=entrypoint,
|
|
110
|
+
venv_dir=self.venv_dir(app),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def install(self, app: WorkspaceApp) -> None:
|
|
114
|
+
app_dir = self.app_dir(app)
|
|
115
|
+
venv = self.venv_dir(app)
|
|
116
|
+
app_dir.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
|
|
118
|
+
if not self.python_path(app).exists():
|
|
119
|
+
self._run_logged(app, [sys.executable, "-m", "venv", str(venv)])
|
|
120
|
+
|
|
121
|
+
python = self.python_path(app)
|
|
122
|
+
self._run_logged(
|
|
123
|
+
app, [str(python), "-m", "pip", "install", "--upgrade", "pip"]
|
|
124
|
+
)
|
|
125
|
+
self._run_logged(
|
|
126
|
+
app, [str(python), "-m", "pip", "install", "--upgrade", app.package]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
status = self.status(app)
|
|
130
|
+
log_activity(
|
|
131
|
+
app.slug,
|
|
132
|
+
"install",
|
|
133
|
+
f"{app.package} {status.version or ''}".strip(),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def update(self, app: WorkspaceApp) -> None:
|
|
137
|
+
if not self.status(app).installed:
|
|
138
|
+
self.install(app)
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
python = self.python_path(app)
|
|
142
|
+
self._run_logged(
|
|
143
|
+
app, [str(python), "-m", "pip", "install", "--upgrade", app.package]
|
|
144
|
+
)
|
|
145
|
+
status = self.status(app)
|
|
146
|
+
log_activity(
|
|
147
|
+
app.slug,
|
|
148
|
+
"update",
|
|
149
|
+
f"{app.package} {status.version or ''}".strip(),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def uninstall(self, app: WorkspaceApp) -> None:
|
|
153
|
+
target = self.app_dir(app)
|
|
154
|
+
if target.exists():
|
|
155
|
+
shutil.rmtree(target)
|
|
156
|
+
log_activity(app.slug, "uninstall", app.package)
|
|
157
|
+
|
|
158
|
+
def launch_command(self, app: WorkspaceApp) -> list[str]:
|
|
159
|
+
status = self.status(app)
|
|
160
|
+
if not status.installed:
|
|
161
|
+
raise RuntimeError(f"{app.name} is not installed.")
|
|
162
|
+
if not status.entrypoint:
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
f"{app.package} is installed but exposes no console_scripts entry point."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
exe = self.bin_dir(app) / status.entrypoint
|
|
168
|
+
if os.name == "nt":
|
|
169
|
+
candidates = [exe, exe.with_suffix(".exe")]
|
|
170
|
+
for candidate in candidates:
|
|
171
|
+
if candidate.exists():
|
|
172
|
+
return [str(candidate)]
|
|
173
|
+
elif exe.exists():
|
|
174
|
+
return [str(exe)]
|
|
175
|
+
|
|
176
|
+
# Fallback through Python's script lookup environment.
|
|
177
|
+
return [status.entrypoint]
|
|
178
|
+
|
|
179
|
+
def launch(self, app: WorkspaceApp) -> int:
|
|
180
|
+
command = self.launch_command(app)
|
|
181
|
+
env = os.environ.copy()
|
|
182
|
+
env["PATH"] = str(self.bin_dir(app)) + os.pathsep + env.get("PATH", "")
|
|
183
|
+
log_activity(app.slug, "launch", " ".join(command))
|
|
184
|
+
return subprocess.run(command, env=env, check=False).returncode
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from platformdirs import user_data_dir
|
|
6
|
+
|
|
7
|
+
DATA_DIR = Path(user_data_dir("richmack-workspace", "Richmack"))
|
|
8
|
+
APPS_DIR = DATA_DIR / "apps"
|
|
9
|
+
DB_PATH = DATA_DIR / "workspace.db"
|
|
10
|
+
LOG_DIR = DATA_DIR / "logs"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ensure_dirs() -> None:
|
|
14
|
+
APPS_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|