penpot-local-stack 0.1.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.
- penpot_local_stack-0.1.0.dist-info/METADATA +180 -0
- penpot_local_stack-0.1.0.dist-info/RECORD +14 -0
- penpot_local_stack-0.1.0.dist-info/WHEEL +4 -0
- penpot_local_stack-0.1.0.dist-info/entry_points.txt +2 -0
- penpot_local_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- penpot_stack/__init__.py +1 -0
- penpot_stack/cli.py +135 -0
- penpot_stack/compose.py +45 -0
- penpot_stack/config/autologin.conf +5 -0
- penpot_stack/config/compose.yaml +86 -0
- penpot_stack/penpot.py +83 -0
- penpot_stack/prompts.py +34 -0
- penpot_stack/settings.py +46 -0
- penpot_stack/templates.py +90 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: penpot-local-stack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A disposable local Penpot stack: import a template, draw, export it back
|
|
5
|
+
Project-URL: Repository, https://github.com/oberon-systems/penpot-local-stack
|
|
6
|
+
Project-URL: Issues, https://github.com/oberon-systems/penpot-local-stack/issues
|
|
7
|
+
Author-email: zombig <me@zombig.name>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: pydantic-settings>=2.4
|
|
21
|
+
Requires-Dist: pydantic>=2
|
|
22
|
+
Requires-Dist: pyyaml>=6
|
|
23
|
+
Requires-Dist: questionary>=2
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# penpot-local-stack
|
|
27
|
+
|
|
28
|
+
A disposable [Penpot](https://penpot.app) stack for drawing UI mockups, built
|
|
29
|
+
for AI-driven UI/UX work. Penpot itself is open source; its code is at
|
|
30
|
+
[penpot/penpot](https://github.com/penpot/penpot).
|
|
31
|
+
|
|
32
|
+
The stack exists so a design session leaves nothing behind but the drawing.
|
|
33
|
+
It runs from a Compose file with no persistent state: the database lives in
|
|
34
|
+
tmpfs and goes away with the containers, the profile is created on every
|
|
35
|
+
start, and the only thing that survives is `templates/` in your directory.
|
|
36
|
+
Mockups are stored there as unpacked Penpot exports, JSON plus SVG, so git
|
|
37
|
+
shows readable diffs and an agent can read a mockup directly instead of
|
|
38
|
+
looking at a picture of it.
|
|
39
|
+
|
|
40
|
+
- [Install](#install)
|
|
41
|
+
- [Settings](#settings)
|
|
42
|
+
- [Design loop](#design-loop)
|
|
43
|
+
- [Commands](#commands)
|
|
44
|
+
- [Templates](#templates)
|
|
45
|
+
- [Connect Penpot MCP](#connect-penpot-mcp)
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
Docker with the Compose plugin and Python 3.12 or newer have to be there
|
|
50
|
+
already, and that `python3` needs `httpx`, `questionary`, `pydantic-settings`
|
|
51
|
+
and `PyYAML`. The installer checks and tells you what is missing; it never
|
|
52
|
+
installs anything itself.
|
|
53
|
+
|
|
54
|
+
Run it in the directory that will hold the mockups:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
curl -fsSL https://raw.githubusercontent.com/oberon-systems/penpot-local-stack/main/install/install.sh | bash
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
It downloads the stack and lays it out:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
bin/ libs/ config/ the stack, run by the system python3
|
|
64
|
+
.penpot.yaml settings, read from the directory you run in
|
|
65
|
+
Makefile up, down, import, export, extract, convert
|
|
66
|
+
templates/ unpacked Penpot exports, empty at first
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Re-running it replaces `bin/`, `libs/` and `config/`, which is the upgrade,
|
|
70
|
+
and keeps `.penpot.yaml` and `templates/`. `--ref` unfolds a branch or a tag
|
|
71
|
+
instead of `main`. See [install/README.md](install/README.md) for the details.
|
|
72
|
+
|
|
73
|
+
## Settings
|
|
74
|
+
|
|
75
|
+
The installer copies `.penpot.yaml.example` from the repository to
|
|
76
|
+
`.penpot.yaml` next to the `Makefile`, and every command reads that file from
|
|
77
|
+
the directory it runs in:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
templates: templates
|
|
81
|
+
host: 127.0.0.1
|
|
82
|
+
port: 9001
|
|
83
|
+
project: penpot-local
|
|
84
|
+
version: 2.17.2
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Key | Default | What it sets |
|
|
88
|
+
| ----------- | ---------------------- | ------------------------------- |
|
|
89
|
+
| `templates` | `templates` | Where the unpacked exports live |
|
|
90
|
+
| `host` | `127.0.0.1` | The address Penpot binds to |
|
|
91
|
+
| `port` | `9001` | The port Penpot binds to |
|
|
92
|
+
| `project` | `penpot-local` | The Compose project name |
|
|
93
|
+
| `version` | `2.17.2` | The Penpot image tag |
|
|
94
|
+
| `email` | `designer@example.com` | The throwaway profile |
|
|
95
|
+
| `password` | `penpot-local` | Its password |
|
|
96
|
+
|
|
97
|
+
Drop a key to fall back to its default. Every key also answers to an
|
|
98
|
+
environment variable with a `PENPOT_` prefix, and the variable wins over the
|
|
99
|
+
file:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
PENPOT_PORT=9100 make up
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Design loop
|
|
106
|
+
|
|
107
|
+
Start the stack and pick a template or `(empty)`:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
make up
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`up` creates a throwaway profile and opens the browser already logged in
|
|
114
|
+
through the printed `/autologin` link. If that session is lost, log in as
|
|
115
|
+
`designer@example.com` with the password `penpot-local`.
|
|
116
|
+
|
|
117
|
+
Draw in the browser at `http://localhost:9001`, or wherever `port` points. When you are done, pick the
|
|
118
|
+
file to export and the template to write it to, then let the stack go down:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
make down
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`down` writes the export into `templates/<name>/` and runs
|
|
125
|
+
`docker compose down -v`, which drops the database and the assets. Pick
|
|
126
|
+
`(skip export)` to throw the work away; the wipe is confirmed first whenever
|
|
127
|
+
the stack still holds a file.
|
|
128
|
+
|
|
129
|
+
## Commands
|
|
130
|
+
|
|
131
|
+
| Target | What it does |
|
|
132
|
+
| -------------- | --------------------------------------------------------- |
|
|
133
|
+
| `make up` | Start the stack, import a template, open the browser |
|
|
134
|
+
| `make down` | Offer the export, confirm the wipe, stop the stack |
|
|
135
|
+
| `make import` | Import a template into the running stack, after a confirm |
|
|
136
|
+
| `make export` | Export a file into `templates/`, after a confirm |
|
|
137
|
+
| `make extract` | Unpack a `.penpot` file into `templates/` |
|
|
138
|
+
| `make convert` | Pack a template into a `.penpot` file |
|
|
139
|
+
|
|
140
|
+
`import` and `export` work against a running stack, so a template can be
|
|
141
|
+
swapped in or a file saved off without ending the session. Both ask before
|
|
142
|
+
they touch anything: `import` names the template it is about to load, `export`
|
|
143
|
+
says whether it writes a new template or overwrites one that is already there.
|
|
144
|
+
|
|
145
|
+
`extract` and `convert` are the offline pair and need no stack at all.
|
|
146
|
+
`extract` lists the `.penpot` files next to you, asks which template to write
|
|
147
|
+
and unpacks the archive under the same rules as `export`: frame thumbnails are
|
|
148
|
+
dropped, JSON is reformatted, and an archive holding raster images is refused
|
|
149
|
+
with the offending files listed. `convert` goes the other way and packs
|
|
150
|
+
`templates/<name>/` into `<name>.penpot`, which is what you hand to a Penpot
|
|
151
|
+
that is not this one. Both confirm before writing, and say whether they are
|
|
152
|
+
creating something or overwriting it.
|
|
153
|
+
|
|
154
|
+
## Templates
|
|
155
|
+
|
|
156
|
+
- One directory per template: `manifest.json`, `files/` and `objects/`.
|
|
157
|
+
- Templates are read from `templates/` in the current directory. Set
|
|
158
|
+
`PENPOT_TEMPLATES` to read them from somewhere else.
|
|
159
|
+
- Images and icons must be SVG. An export that holds raster images is refused
|
|
160
|
+
and the offending files are listed, so you can replace them and export
|
|
161
|
+
again.
|
|
162
|
+
- Frame thumbnails are raster renders Penpot rebuilds on its own, so the
|
|
163
|
+
export leaves them out.
|
|
164
|
+
- Import keeps the Penpot file id, so a round trip changes only what you drew.
|
|
165
|
+
|
|
166
|
+
## Connect Penpot MCP
|
|
167
|
+
|
|
168
|
+
`up` enables MCP for the throwaway profile, issues its key and serves it
|
|
169
|
+
behind the fixed `/mcp/claude` URL, so an agent needs the server added only
|
|
170
|
+
once. Add it, then restart the agent:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
claude mcp add --transport http penpot http://localhost:9001/mcp/claude
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
After later `up` runs, reconnect `penpot` from `/mcp` instead of restarting.
|
|
177
|
+
While the stack is down, the server just shows as failed.
|
|
178
|
+
|
|
179
|
+
The MCP plugin runs inside the Penpot browser tab. Keep a file open in the
|
|
180
|
+
workspace while the agent works; a hidden or unloaded tab stops MCP.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
penpot_stack/config/autologin.conf,sha256=fchV08GGfPkhbXLIX4opQWYnrkGNZTg1hi0f0bfPP0I,154
|
|
2
|
+
penpot_stack/config/compose.yaml,sha256=gvOV6yJmkmvsW-WXhRrSrm9_1YyiZqcZbVbLwW9VE_I,2358
|
|
3
|
+
penpot_stack/__init__.py,sha256=mvRPyADrJna3E_OHJ8kUrIIgt1jcXgn_ZVS2sn0nzh0,87
|
|
4
|
+
penpot_stack/cli.py,sha256=RUUO1wv1zMNZl2Tri0XD0VDHYTvvVxdeOnTAkUyFQ6A,4070
|
|
5
|
+
penpot_stack/compose.py,sha256=oqG1fMgp2Qury_El0XYLaI7VUAjJpQt4GsZPkpDtkN0,1510
|
|
6
|
+
penpot_stack/penpot.py,sha256=eDURPbFXciJz8WFwhN6PJfnktWPt2VueZnUR959brcI,2869
|
|
7
|
+
penpot_stack/prompts.py,sha256=TG1buuhVaZjlySse8Kq3WbufhBuKvpnY2jzr2Snp3Ww,935
|
|
8
|
+
penpot_stack/settings.py,sha256=2r1zD2SZC5CxGwvEbVPzRNTfeYjpXr0Zjs6msqfAeNQ,1456
|
|
9
|
+
penpot_stack/templates.py,sha256=fMSRFINaCqJ8jhgYGvEfxeKaJ54mrcHJpj5eba9hKs4,3079
|
|
10
|
+
penpot_local_stack-0.1.0.dist-info/METADATA,sha256=nyW3ujYqw97TlvDmXyZmvz04D0M_Jmi_q34YjCeOZZ8,7322
|
|
11
|
+
penpot_local_stack-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
12
|
+
penpot_local_stack-0.1.0.dist-info/entry_points.txt,sha256=V41xUQgSPhMykV3mKO1DHMga7qDBRCuYYIpj0gee36Q,55
|
|
13
|
+
penpot_local_stack-0.1.0.dist-info/licenses/LICENSE,sha256=UH3T6qSdLRq_gdfrPiuNx22uO5I8kRBp8NIKGwzLKuQ,1071
|
|
14
|
+
penpot_local_stack-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 oberon-systems
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
penpot_stack/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Disposable Penpot stack for UI mockups: import a template, draw, export it back."""
|
penpot_stack/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""The four commands the stack exposes: up, down, import and export."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import webbrowser
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import questionary
|
|
10
|
+
|
|
11
|
+
from . import prompts
|
|
12
|
+
from .compose import compose, publish
|
|
13
|
+
from .penpot import connect, drafts, files, profile, register, rpc, session
|
|
14
|
+
from .settings import settings
|
|
15
|
+
from .templates import archives, load, names, pack, root, save, unpack
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def choices(client: httpx.Client) -> list[questionary.Choice]:
|
|
19
|
+
return [questionary.Choice(label, value=value) for label, value in files(client)]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write(client: httpx.Client, file_id: str) -> bool:
|
|
23
|
+
name = prompts.target(names())
|
|
24
|
+
if name is None:
|
|
25
|
+
return False
|
|
26
|
+
target = root() / name
|
|
27
|
+
known = "Overwrite" if target.exists() else "Export to"
|
|
28
|
+
if not prompts.confirm(f"{known} templates/{name}?"):
|
|
29
|
+
return False
|
|
30
|
+
save(client, file_id, target)
|
|
31
|
+
print(f"exported to {target}")
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def up() -> None:
|
|
36
|
+
template = prompts.source(names(), extra=[prompts.EMPTY])
|
|
37
|
+
if template is None:
|
|
38
|
+
return
|
|
39
|
+
compose("up", "-d", "--wait")
|
|
40
|
+
url = settings().url
|
|
41
|
+
client = connect(wait=300)
|
|
42
|
+
if client is None:
|
|
43
|
+
sys.exit(f"penpot is not answering on {url}")
|
|
44
|
+
publish(register(client))
|
|
45
|
+
if template != prompts.EMPTY:
|
|
46
|
+
load(client, drafts(client), root() / template)
|
|
47
|
+
login = f"{url}/autologin?token={client.cookies['auth-token']}"
|
|
48
|
+
webbrowser.open(login)
|
|
49
|
+
print(f"\nPenpot: {login}")
|
|
50
|
+
print(f"MCP: {url}/mcp/claude")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def down() -> None:
|
|
54
|
+
client = connect(wait=0)
|
|
55
|
+
if client is not None:
|
|
56
|
+
rpc(client, "login-with-password", **profile())
|
|
57
|
+
stored = choices(client)
|
|
58
|
+
if stored:
|
|
59
|
+
file_id = prompts.select("Export file", [prompts.SKIP, *stored])
|
|
60
|
+
if file_id is None:
|
|
61
|
+
return
|
|
62
|
+
if file_id != prompts.SKIP and not write(client, file_id):
|
|
63
|
+
return
|
|
64
|
+
if not prompts.confirm("Drop the stack and everything left in it?"):
|
|
65
|
+
return
|
|
66
|
+
compose("down", "-v")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def export() -> None:
|
|
70
|
+
client = session()
|
|
71
|
+
stored = choices(client)
|
|
72
|
+
if not stored:
|
|
73
|
+
sys.exit("the stack holds no files to export")
|
|
74
|
+
file_id = prompts.select("Export file", stored)
|
|
75
|
+
if file_id is not None:
|
|
76
|
+
write(client, file_id)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def restore() -> None:
|
|
80
|
+
available = names()
|
|
81
|
+
if not available:
|
|
82
|
+
sys.exit(f"no templates in {root()} to import")
|
|
83
|
+
name = prompts.source(available)
|
|
84
|
+
if name is None or not prompts.confirm(f"Import {name} into the stack?"):
|
|
85
|
+
return
|
|
86
|
+
client = session()
|
|
87
|
+
load(client, drafts(client), root() / name)
|
|
88
|
+
print(f"imported {name}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def extract() -> None:
|
|
92
|
+
found = archives()
|
|
93
|
+
if not found:
|
|
94
|
+
sys.exit("no .penpot files in the current directory")
|
|
95
|
+
source = prompts.select("Penpot file", [questionary.Choice(p.name, value=p) for p in found])
|
|
96
|
+
if source is None:
|
|
97
|
+
return
|
|
98
|
+
name = prompts.target(names())
|
|
99
|
+
if name is None:
|
|
100
|
+
return
|
|
101
|
+
target = root() / name
|
|
102
|
+
known = "Overwrite" if target.exists() else "Extract into"
|
|
103
|
+
if not prompts.confirm(f"{known} templates/{name}?"):
|
|
104
|
+
return
|
|
105
|
+
unpack(zipfile.ZipFile(source), target)
|
|
106
|
+
print(f"extracted to {target}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def convert() -> None:
|
|
110
|
+
available = names()
|
|
111
|
+
if not available:
|
|
112
|
+
sys.exit(f"no templates in {root()} to convert")
|
|
113
|
+
name = prompts.source(available)
|
|
114
|
+
if name is None:
|
|
115
|
+
return
|
|
116
|
+
target = Path.cwd() / f"{name}.penpot"
|
|
117
|
+
known = "Overwrite" if target.exists() else "Write"
|
|
118
|
+
if not prompts.confirm(f"{known} {target.name}?"):
|
|
119
|
+
return
|
|
120
|
+
target.write_bytes(pack(root() / name))
|
|
121
|
+
print(f"packed into {target}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main() -> None:
|
|
125
|
+
commands = {
|
|
126
|
+
"up": up,
|
|
127
|
+
"down": down,
|
|
128
|
+
"import": restore,
|
|
129
|
+
"export": export,
|
|
130
|
+
"extract": extract,
|
|
131
|
+
"convert": convert,
|
|
132
|
+
}
|
|
133
|
+
if len(sys.argv) != 2 or sys.argv[1] not in commands:
|
|
134
|
+
sys.exit(f"usage: {Path(sys.argv[0]).name} {{{'|'.join(commands)}}}")
|
|
135
|
+
commands[sys.argv[1]]()
|
penpot_stack/compose.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The shipped Compose definition and the nginx route that publishes MCP."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .settings import Settings, settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def directory() -> Path:
|
|
12
|
+
here = Path(__file__).resolve().parent
|
|
13
|
+
# Installed, the stack files sit inside the package; in a checkout they are next to libs/.
|
|
14
|
+
roots = (here / "config", here.parent / "config")
|
|
15
|
+
return next(p for p in roots if (p / "compose.yaml").is_file())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def environment(config: Settings) -> dict[str, str]:
|
|
19
|
+
return {
|
|
20
|
+
"PENPOT_PROJECT": config.project,
|
|
21
|
+
"PENPOT_HOST": config.host,
|
|
22
|
+
"PENPOT_PORT": str(config.port),
|
|
23
|
+
"PENPOT_VERSION": config.version,
|
|
24
|
+
"PENPOT_URI": config.url,
|
|
25
|
+
"PENPOT_SECRET_KEY": secrets.token_urlsafe(48),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compose(*args: str) -> None:
|
|
30
|
+
env = {**environment(settings()), **os.environ}
|
|
31
|
+
command = ["docker", "compose", "-f", str(directory() / "compose.yaml"), *args]
|
|
32
|
+
subprocess.run(command, check=True, env=env) # noqa: S603, S607
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def publish(key: str) -> None:
|
|
36
|
+
location = (
|
|
37
|
+
"location = /mcp/claude {\n"
|
|
38
|
+
f" rewrite ^ /mcp?userToken={key} break;\n"
|
|
39
|
+
" proxy_pass http://penpot-mcp:4401;\n"
|
|
40
|
+
" proxy_http_version 1.1;\n"
|
|
41
|
+
" proxy_buffering off;\n"
|
|
42
|
+
"}\n"
|
|
43
|
+
)
|
|
44
|
+
script = 'printf "%s" "$1" > /etc/nginx/overrides/server.d/claude-mcp.conf && nginx -s reload'
|
|
45
|
+
compose("exec", "-T", "penpot-frontend", "sh", "-c", script, "sh", location)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
name: ${PENPOT_PROJECT:-penpot-local}
|
|
2
|
+
|
|
3
|
+
x-flags: &flags
|
|
4
|
+
PENPOT_FLAGS: >-
|
|
5
|
+
disable-email-verification disable-secure-session-cookies disable-onboarding
|
|
6
|
+
enable-access-tokens enable-mcp
|
|
7
|
+
x-uri: &uri
|
|
8
|
+
PENPOT_PUBLIC_URI: ${PENPOT_URI:-http://localhost:9001}
|
|
9
|
+
x-secret-key: &secret-key
|
|
10
|
+
PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
|
|
11
|
+
|
|
12
|
+
volumes:
|
|
13
|
+
assets:
|
|
14
|
+
|
|
15
|
+
services:
|
|
16
|
+
penpot-frontend:
|
|
17
|
+
image: penpotapp/frontend:${PENPOT_VERSION:-2.17.2}
|
|
18
|
+
ports:
|
|
19
|
+
- "${PENPOT_HOST:-127.0.0.1}:${PENPOT_PORT:-9001}:8080"
|
|
20
|
+
volumes:
|
|
21
|
+
- assets:/opt/data/assets
|
|
22
|
+
- ./autologin.conf:/etc/nginx/overrides/server.d/autologin.conf:ro
|
|
23
|
+
depends_on:
|
|
24
|
+
- penpot-backend
|
|
25
|
+
- penpot-exporter
|
|
26
|
+
- penpot-mcp
|
|
27
|
+
environment:
|
|
28
|
+
<<: [*flags, *uri]
|
|
29
|
+
|
|
30
|
+
penpot-backend:
|
|
31
|
+
image: penpotapp/backend:${PENPOT_VERSION:-2.17.2}
|
|
32
|
+
volumes:
|
|
33
|
+
- assets:/opt/data/assets
|
|
34
|
+
depends_on:
|
|
35
|
+
penpot-postgres:
|
|
36
|
+
condition: service_healthy
|
|
37
|
+
penpot-valkey:
|
|
38
|
+
condition: service_healthy
|
|
39
|
+
environment:
|
|
40
|
+
<<: [*flags, *uri, *secret-key]
|
|
41
|
+
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
|
|
42
|
+
PENPOT_DATABASE_USERNAME: penpot
|
|
43
|
+
PENPOT_DATABASE_PASSWORD: penpot
|
|
44
|
+
PENPOT_REDIS_URI: redis://penpot-valkey/0
|
|
45
|
+
PENPOT_OBJECTS_STORAGE_BACKEND: fs
|
|
46
|
+
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
|
|
47
|
+
PENPOT_TELEMETRY_ENABLED: "false"
|
|
48
|
+
|
|
49
|
+
penpot-mcp:
|
|
50
|
+
image: penpotapp/mcp:${PENPOT_VERSION:-2.17.2}
|
|
51
|
+
|
|
52
|
+
penpot-exporter:
|
|
53
|
+
image: penpotapp/exporter:${PENPOT_VERSION:-2.17.2}
|
|
54
|
+
depends_on:
|
|
55
|
+
penpot-valkey:
|
|
56
|
+
condition: service_healthy
|
|
57
|
+
environment:
|
|
58
|
+
<<: [*uri, *secret-key]
|
|
59
|
+
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
|
|
60
|
+
PENPOT_REDIS_URI: redis://penpot-valkey/0
|
|
61
|
+
|
|
62
|
+
penpot-postgres:
|
|
63
|
+
image: postgres:15
|
|
64
|
+
stop_signal: SIGINT
|
|
65
|
+
tmpfs:
|
|
66
|
+
- /var/lib/postgresql/data
|
|
67
|
+
healthcheck:
|
|
68
|
+
test: [CMD-SHELL, pg_isready -U penpot]
|
|
69
|
+
interval: 2s
|
|
70
|
+
timeout: 10s
|
|
71
|
+
retries: 5
|
|
72
|
+
start_period: 2s
|
|
73
|
+
environment:
|
|
74
|
+
POSTGRES_DB: penpot
|
|
75
|
+
POSTGRES_USER: penpot
|
|
76
|
+
POSTGRES_PASSWORD: penpot
|
|
77
|
+
|
|
78
|
+
penpot-valkey:
|
|
79
|
+
image: valkey/valkey:8.1
|
|
80
|
+
command: [valkey-server, --save, "", --appendonly, "no"]
|
|
81
|
+
healthcheck:
|
|
82
|
+
test: [CMD-SHELL, valkey-cli ping | grep PONG]
|
|
83
|
+
interval: 1s
|
|
84
|
+
timeout: 3s
|
|
85
|
+
retries: 5
|
|
86
|
+
start_period: 3s
|
penpot_stack/penpot.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The Penpot RPC API: the throwaway profile, the session and the calls the commands need."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .settings import settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def profile() -> dict[str, str]:
|
|
13
|
+
config = settings()
|
|
14
|
+
return {"email": config.email, "password": config.password}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def rpc(client: httpx.Client, command: str, **params: Any) -> Any:
|
|
18
|
+
response = client.post(f"/api/rpc/command/{command}", json=params)
|
|
19
|
+
if response.is_error:
|
|
20
|
+
sys.exit(f"{command}: {response.status_code} {response.text}")
|
|
21
|
+
return response.json() if response.content else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def stream(client: httpx.Client, command: str, **kwargs: Any) -> str:
|
|
25
|
+
event = ""
|
|
26
|
+
with client.stream("POST", f"/api/rpc/command/{command}", **kwargs) as response:
|
|
27
|
+
if response.is_error:
|
|
28
|
+
sys.exit(f"{command}: {response.status_code} {response.read().decode()}")
|
|
29
|
+
for line in response.iter_lines():
|
|
30
|
+
if line.startswith("event:"):
|
|
31
|
+
event = line.removeprefix("event:").strip()
|
|
32
|
+
elif line.startswith("data:") and event in ("end", "error"):
|
|
33
|
+
if event == "error":
|
|
34
|
+
sys.exit(f"{command}: {line}")
|
|
35
|
+
return line.removeprefix("data:")
|
|
36
|
+
sys.exit(f"{command}: stream closed without a result")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def connect(wait: float) -> httpx.Client | None:
|
|
40
|
+
client = httpx.Client(
|
|
41
|
+
base_url=settings().url,
|
|
42
|
+
headers={"Accept": "application/json"},
|
|
43
|
+
timeout=60,
|
|
44
|
+
follow_redirects=True,
|
|
45
|
+
)
|
|
46
|
+
deadline = time.monotonic() + wait
|
|
47
|
+
while True:
|
|
48
|
+
try:
|
|
49
|
+
if client.post("/api/rpc/command/get-profile", json={}).is_success:
|
|
50
|
+
return client
|
|
51
|
+
except httpx.TransportError:
|
|
52
|
+
pass
|
|
53
|
+
if time.monotonic() > deadline:
|
|
54
|
+
return None
|
|
55
|
+
time.sleep(2)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def session() -> httpx.Client:
|
|
59
|
+
client = connect(wait=0)
|
|
60
|
+
if client is None:
|
|
61
|
+
sys.exit(f"penpot is not answering on {settings().url}, run `up` first")
|
|
62
|
+
rpc(client, "login-with-password", **profile())
|
|
63
|
+
return client
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def register(client: httpx.Client) -> str:
|
|
67
|
+
token = rpc(client, "prepare-register-profile", fullname="Designer", **profile())["token"]
|
|
68
|
+
rpc(client, "register-profile", token=token)
|
|
69
|
+
rpc(client, "update-profile-props", props={"mcpEnabled": True})
|
|
70
|
+
return str(rpc(client, "create-access-token", name="mcp", type="mcp")["token"])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def drafts(client: httpx.Client) -> str:
|
|
74
|
+
projects = rpc(client, "get-all-projects")
|
|
75
|
+
return str(next(p["id"] for p in projects if p["isDefault"] and p["isDefaultTeam"]))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def files(client: httpx.Client) -> list[tuple[str, str]]:
|
|
79
|
+
return [
|
|
80
|
+
(f"{project['name']} / {file['name']}", file["id"])
|
|
81
|
+
for project in rpc(client, "get-all-projects")
|
|
82
|
+
for file in rpc(client, "get-project-files", projectId=project["id"])
|
|
83
|
+
]
|
penpot_stack/prompts.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Every question the commands ask, so cancelling one always means the same no."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
|
|
9
|
+
EMPTY = "(empty)"
|
|
10
|
+
NEW = "(new template)"
|
|
11
|
+
SKIP = "(skip export)"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def valid(name: str) -> bool:
|
|
15
|
+
return bool(re.fullmatch(r"[a-z0-9][a-z0-9-]*", name))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def select(question: str, choices: Sequence[Any]) -> Any | None:
|
|
19
|
+
return questionary.select(question, choices=list(choices)).ask()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def confirm(question: str) -> bool:
|
|
23
|
+
return bool(questionary.confirm(question, default=False).ask())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def source(existing: Sequence[str], extra: Sequence[str] = ()) -> str | None:
|
|
27
|
+
return select("Template", [*extra, *existing])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def target(existing: Sequence[str]) -> str | None:
|
|
31
|
+
choice = select("Template", [NEW, *existing])
|
|
32
|
+
if choice is None or choice != NEW:
|
|
33
|
+
return choice
|
|
34
|
+
return questionary.text("Template name", validate=valid).ask()
|
penpot_stack/settings.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Settings: defaults, .penpot.yaml in the working directory, then PENPOT_* variables."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic_settings import (
|
|
6
|
+
BaseSettings,
|
|
7
|
+
PydanticBaseSettingsSource,
|
|
8
|
+
SettingsConfigDict,
|
|
9
|
+
YamlConfigSettingsSource,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
FILE = ".penpot.yaml"
|
|
13
|
+
WILDCARD = ("0.0.0.0", "::") # noqa: S104
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Settings(BaseSettings):
|
|
17
|
+
model_config = SettingsConfigDict(env_prefix="PENPOT_", yaml_file=FILE, extra="forbid")
|
|
18
|
+
|
|
19
|
+
templates: Path = Path("templates")
|
|
20
|
+
host: str = "127.0.0.1"
|
|
21
|
+
port: int = 9001
|
|
22
|
+
project: str = "penpot-local"
|
|
23
|
+
version: str = "2.17.2"
|
|
24
|
+
email: str = "designer@example.com"
|
|
25
|
+
password: str = "penpot-local" # noqa: S105
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def url(self) -> str:
|
|
29
|
+
# A wildcard bind is still reached as localhost by the browser opened on this machine.
|
|
30
|
+
host = "localhost" if self.host in (*WILDCARD, "127.0.0.1") else self.host
|
|
31
|
+
return f"http://{host}:{self.port}"
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def settings_customise_sources(
|
|
35
|
+
cls,
|
|
36
|
+
settings_cls: type[BaseSettings],
|
|
37
|
+
init_settings: PydanticBaseSettingsSource,
|
|
38
|
+
env_settings: PydanticBaseSettingsSource,
|
|
39
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
40
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
41
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
42
|
+
return (init_settings, env_settings, YamlConfigSettingsSource(settings_cls))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def settings() -> Settings:
|
|
46
|
+
return Settings()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Templates on disk: unpacked Penpot exports the stack imports from and exports back to."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .penpot import rpc, stream
|
|
14
|
+
from .settings import settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def root() -> Path:
|
|
18
|
+
return settings().templates.resolve()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def names() -> list[str]:
|
|
22
|
+
return sorted(p.parent.name for p in root().glob("*/manifest.json"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def archives() -> list[Path]:
|
|
26
|
+
return sorted(Path.cwd().glob("*.penpot"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pack(source: Path) -> bytes:
|
|
30
|
+
buffer = io.BytesIO()
|
|
31
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
32
|
+
for path in sorted(p for p in source.rglob("*") if p.is_file()):
|
|
33
|
+
archive.write(path, path.relative_to(source).as_posix())
|
|
34
|
+
return buffer.getvalue()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load(client: httpx.Client, project: str, source: Path) -> None:
|
|
38
|
+
file_id = json.loads((source / "manifest.json").read_text())["files"][0]["id"]
|
|
39
|
+
rpc(client, "create-file", id=file_id, name=source.name, projectId=project)
|
|
40
|
+
stream(
|
|
41
|
+
client,
|
|
42
|
+
"import-binfile",
|
|
43
|
+
data={"name": source.name, "project-id": project, "file-id": file_id},
|
|
44
|
+
files={"file": (f"{source.name}.penpot", pack(source), "application/zip")},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def download(client: httpx.Client, file_id: str) -> zipfile.ZipFile:
|
|
49
|
+
result = stream(
|
|
50
|
+
client,
|
|
51
|
+
"export-binfile",
|
|
52
|
+
json={"fileId": file_id, "includeLibraries": False, "embedAssets": True},
|
|
53
|
+
)
|
|
54
|
+
# Transit encodes the link either as a tagged string or as a tagged map, depending on Penpot.
|
|
55
|
+
url = re.search(r'"~r([^"]+)"|"~#uri"\s*:\s*"([^"]+)"', result)
|
|
56
|
+
if url is None:
|
|
57
|
+
sys.exit(f"export-binfile: no download link in {result}")
|
|
58
|
+
response = client.get(url.group(1) or url.group(2))
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
return zipfile.ZipFile(io.BytesIO(response.content))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def entries(archive: zipfile.ZipFile) -> list[str]:
|
|
64
|
+
# Frame thumbnails are raster renders Penpot regenerates, so they never reach the template.
|
|
65
|
+
thumbnails = [n for n in archive.namelist() if "/thumbnails/" in n]
|
|
66
|
+
objects = {f"objects/{json.loads(archive.read(n))['mediaId']}." for n in thumbnails}
|
|
67
|
+
return [
|
|
68
|
+
n
|
|
69
|
+
for n in archive.namelist()
|
|
70
|
+
if n not in thumbnails and not n.startswith(tuple(objects)) and not n.endswith("/")
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def unpack(archive: zipfile.ZipFile, target: Path) -> None:
|
|
75
|
+
kept = entries(archive)
|
|
76
|
+
raster = [n for n in kept if not n.endswith((".json", ".svg"))]
|
|
77
|
+
if raster:
|
|
78
|
+
sys.exit("only SVG images are allowed, replace these:\n " + "\n ".join(raster))
|
|
79
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
80
|
+
for name in kept:
|
|
81
|
+
path = target / name
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
content = archive.read(name)
|
|
84
|
+
if name.endswith(".json"):
|
|
85
|
+
content = (json.dumps(json.loads(content), indent=2) + "\n").encode()
|
|
86
|
+
path.write_bytes(content)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def save(client: httpx.Client, file_id: str, target: Path) -> None:
|
|
90
|
+
unpack(download(client, file_id), target)
|