Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-07-08 15:44:37 +02:00
commit a004f7cd84
28 changed files with 1589 additions and 965 deletions

View File

@ -52,7 +52,7 @@ built fresh from `shared/codex.toml` + `templates/codex.toml` at launch.
`manage.sh`.
* **`mcp/`** (Claude Code, opencode) is the result of merging `shared/` with
the port-substituted `templates/`. `manage.sh` writes these on every
`run-devenv-agentic` pass. Gitignored, dedicated paths with no developer
`run-devenv --agentic` pass. Gitignored, dedicated paths with no developer
content — never edit by hand, your edits will be overwritten on the next
reconcile.
* **`.vscode/mcp.json`** is the same merge, but written to the path VS Code

View File

@ -0,0 +1,27 @@
---
name: create-issue
description: Create or update GitHub issues (from PR, from draft body, retitle existing). Routes to the canonical flow in `mem:workflow/creating-issues`.
---
# Skill: create-issue
Entry point for all GitHub issue work. All rules (title derivation, metadata,
body templates, Issue Type IDs), all flows, and all `gh` / GraphQL commands
live in `mem:workflow/creating-issues` (file:
`.serena/memories/workflow/creating-issues.md`). This skill routes to the
right flow.
## When to Use
- **Create from PR** — PR exists; the issue is the changelog/release unit,
the PR is the implementation. Issue = WHAT, PR = HOW.
→ memory section **Creating Issues from PRs**
- **Create from draft body** — Taiga story, user report, discussion; no PR
yet.
→ memory section **Creating Issues from Draft Body**
- **Retitle existing issue** — current title is vague, prefixed, or stale.
→ memory section **Retitling an Existing Issue**
Everything else (title derivation, metadata policy, body templates, Issue
Type IDs, create/verify/cleanup commands) lives in the memory — go to the
matching section there.

View File

@ -1,227 +0,0 @@
---
name: gh-issue-from-pr
description: Create a user-facing GitHub issue from a PR, separating the WHAT from the HOW, with correct milestone, project, labels, and issue type.
---
# Skill: gh-issue-from-pr
Create a GitHub issue that captures the **WHAT** (user-facing feature or
bug) from an existing PR that describes the **HOW** (implementation).
Used when the project board needs an issue as the primary changelog/release unit.
## When to Use
- Create a tracking issue from a PR for changelog purposes
- Extract the user-facing problem/feature from a PR's implementation details
- Assign milestone, project, labels, and issue type to a new issue derived from a PR
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
- Permission to create issues and edit PRs in the target repository
## Workflow
### 1. Understand the PR
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot \
--json title,body,author,labels,baseRefName,mergedAt,state,milestone
```
Identify:
- **WHAT** — user-facing problem or feature. Goes into the issue.
Describe symptoms and impact, not internal mechanisms.
- **HOW** — implementation details. These belong in the PR, not the issue.
### 2. Determine metadata
| Field | Source | Rule |
|-------|--------|------|
| **Title** | PR title | Rewrite from user perspective. Strip leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on observable behavior. Use imperative mood. Use the `issue-title` skill to generate this. |
| **Labels** | PR labels | Copy `community contribution` if present. Skip `bug` and `enhancement` (redundant with Issue Type). Skip workflow labels (`backport candidate`, `team-qa`). |
| **Milestone** | PR milestone | **Always copy what's on the PR.** Fetch with: `gh pr view <PR_NUMBER> --json milestone --jq '.milestone.title'` If the PR has no milestone, create the issue without one. |
| **Project** | Always `Main` | Penpot uses the `Main` project (number 8) for all issues. |
| **Body** | PR's user-facing section | Extract steps to reproduce or feature description. Omit internal details. Use templates below. |
| **Issue Type** | PR labels / title | Map: `bug` label or `:bug:` title → `Bug`. `enhancement` label or `:sparkles:` title → `Enhancement`. Feature/epic → `Feature`. Default → `Task`. |
### 3. Write the issue body
**Bug template:**
```markdown
### Description
<what breaks, what the user experiences>
### Steps to reproduce
1. <step 1>
2. <step 2>
### Expected behavior
<what should happen instead>
### Affected versions
<version>
```
**Enhancement template:**
```markdown
### Description
<what the user can now do that they couldn't before>
### Use case
<why this is useful, who benefits>
### Affected versions
<version>
```
### 4. Create the issue
Write the body to a temp file to avoid shell quoting issues:
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
```
Create:
```bash
gh issue create \
--repo penpot/penpot \
--title "<Title>" \
--label "community contribution" \ # only if PR has this label
--milestone "<milestone>" \
--project "Main" \
--body-file /tmp/issue-body.md
```
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
### 5. Assign to the PR author
Assign the issue to the PR author so they're responsible for it:
```bash
AUTHOR=$(gh pr view <PR_NUMBER> --repo penpot/penpot --json author --jq '.author.login')
gh issue edit <ISSUE_NUMBER> --repo penpot/penpot --add-assignee "$AUTHOR"
```
### 6. Set the Issue Type
`gh issue create` can't set the Issue Type directly. Use GraphQL.
Get the issue's GraphQL node ID:
```bash
ISSUE_ID=$(gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <ISSUE_NUMBER>) { id }
}}' --jq '.data.repository.issue.id')
```
Issue Type IDs for the Penpot repo:
| Type | ID |
|------|----|
| Bug | `IT_kwDOAcyBPM4AX5Nb` |
| Enhancement | `IT_kwDOAcyBPM4B_IQN` |
| Feature | `IT_kwDOAcyBPM4AX5Nf` |
| Task | `IT_kwDOAcyBPM4AX5NY` |
| Question | `IT_kwDOAcyBPM4B_IQj` |
| Docs | `IT_kwDOAcyBPM4B_IQz` |
Set it:
```bash
gh api graphql -f query='
mutation {
updateIssue(input: {
id: "'"$ISSUE_ID"'"
issueTypeId: "<TYPE_ID>"
}) {
issue { number issueType { name } }
}
}'
```
### 7. Verify
```bash
gh issue view <ISSUE_NUMBER> --repo penpot/penpot \
--json title,milestone,projectItems,labels \
--jq '{title, milestone: .milestone.title, projects: [.projectItems[].title], labels: [.labels[].name]}'
gh api graphql -f query='
query { repository(owner: "penpot", name: "penpot") {
issue(number: <ISSUE_NUMBER>) { issueType { name } }
}}' --jq '.data.repository.issue.issueType.name'
```
### 8. Link the PR to the issue
Append `Closes #<ISSUE_NUMBER>` to the PR body:
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md
printf "\n\nCloses #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md
gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md
# Verify
gh pr view <PR_NUMBER> --repo penpot/penpot --json body \
--jq '.body | test("Closes #<ISSUE_NUMBER>")'
```
**Note:** If the PR is already merged, `Closes` won't auto-close the issue
— it only creates the "Development" sidebar link. This is the desired
behavior since the issue is a tracking artifact.
### 9. Clean up
```bash
rm -f /tmp/issue-body.md /tmp/pr-body.md
```
## Label rules
| PR has | Issue gets |
|--------|-----------|
| `community contribution` | `community contribution` |
| `bug`, `enhancement` | *(skip — redundant with Issue Type)* |
| `backport candidate` | *(skip — workflow label)* |
| `team-qa` | *(skip — workflow label)* |
## Issue Type mapping
| PR label(s) / title prefix | Issue Type |
|----------------------------|-----------|
| `bug` or `:bug:` | Bug |
| `enhancement` or `:sparkles:` or `:tada:` | Enhancement |
| Feature / epic | Feature |
| Documentation | Docs |
| None of the above | Task |
## Key Principles
- **Issue = WHAT, PR = HOW.** Never put implementation details in the
issue body. The issue is for users, QA, and changelog readers.
- **Copy the milestone from the PR.** Don't guess based on branch names.
If the PR has no milestone, create the issue without one.
- **Set Issue Type via GraphQL**`gh issue create` can't set it.
- **Link via PR body**`Closes #<NUMBER>` creates the "Development"
sidebar link automatically.
- **One issue per PR** — even if a PR fixes multiple things, create a
single issue that summarizes the overall change.
- **Community attribution:** if the PR has the `community contribution`
label or the author is not a core team member, add the label to the issue.

View File

@ -1,123 +0,0 @@
---
name: issue-title
description: Derive a clear, well-formatted title for a GitHub issue from its description body, using descriptive present-tense for bugs and imperative mood for features, always including the "where" (location in the UI/module).
---
# Skill: issue-title
Derive a concise, descriptive title for a GitHub issue based on its body
content. Use **descriptive present tense for bugs** (e.g. "Plugin API
crashes when setting text fills") and **imperative mood for features** (e.g.
"Add customizable dash and gap controls"). No emoji or type prefixes
(`feat:`, `bug:`, `feature:`, etc.).
Can be used both when **creating a new issue** and when **updating an
existing one** that has a vague or outdated title.
## When to Use
- Creating a new issue and need a well-formatted title from the draft body
- An existing issue has a vague, outdated, or auto-generated title (e.g.
`[PENPOT FEEDBACK]: ...`, `feature: ...`)
- The current title doesn't reflect the actual content of the description
- The title is missing the "where" (which part of the UI/module is affected)
## Prerequisites
- `gh` CLI authenticated (`gh auth status`)
## Workflow
### 1. Get the issue body
For an **existing issue**, fetch it:
```bash
gh issue view <NUMBER> --repo penpot/penpot --json title,body
```
For a **new issue**, read the draft body from wherever it was provided
(Taiga link, user report, discussion, etc.).
### 2. Read the body and derive a title
Extract the core problem or request from the description. Distinguish between
bug reports and feature requests:
**Bug titles (descriptive, present tense):**
Describe the symptom as it appears to the user. Format:
`[Where] [present-tense verb] when [condition]`
- *"Plugin API crashes when setting text fills"*
- *"Canvas renders glitches when zooming quickly"*
- *"French Canada locale falls back to French (fr) translations"*
- *"Text layer content is not deleted when WebGL render is enabled"*
Do **not** start bug titles with "Fix" or any imperative verb. The title
should state what's broken, not command a fix.
**Feature / Enhancement titles (imperative mood):**
Command what should be built. Format:
`[Imperative verb] [what] in/on [where]`
- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"*
- *"Show user, timestamp, and hash in the workspace history panel like git commits"*
- *"Validate shape on add-object to catch malformed inputs early"*
**Universal rules (both types):**
- **Include the "where"** — specify the UI location or module (e.g.
"in the sidebar", "in the workspace history panel", "on the stroke
options")
- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`,
`[PENPOT FEEDBACK]`, etc.
- **No emoji** — plain text only
- **Be specific** — prefer concrete detail over generality. If the
description mentions two related problems, capture both.
**Examples:**
| Original / draft title | Type | New title |
|---|---|---|
| `[PENPOT FEEDBACK]: WebGL` | Bug | `Canvas renders glitches when zooming quickly — text appears distorted and nodes have background-colored rectangles` |
| `bug: flatten-nested-tokens-json uses $type instead of $value as the DTCG token/group discriminator` | Bug | `Token import fails when group-level type inheritance is used — parser misidentifies groups as tokens` |
| `feature: Dashed stroke customization` | Feature | `Add customizable dash and gap length controls to dashed strokes in the sidebar` |
| `feature: Add more detail to history of actions` | Feature | `Show user, timestamp, and hash in the workspace history panel like git commits` |
### 3. Apply the title
**If updating an existing issue:**
```bash
gh issue edit <NUMBER> --repo penpot/penpot --title "<NEW TITLE>"
```
**If creating a new issue:**
```bash
gh issue create --repo penpot/penpot --title "<NEW TITLE>" --body "<BODY>"
```
### 4. Confirm
For updates, the command returns the issue URL. Verify by optionally fetching
again:
```bash
gh issue view <NUMBER> --repo penpot/penpot --json title
```
## Key Principles
- **Bug titles describe the symptom** — present tense, 3rd person:
"crashes", "fails", "shows", "is cut off", "does not load". Do not
start with "Fix" or "Bug:".
- **Feature titles use imperative mood** — command form: "Add", "Show",
"Use", "Validate", "Support", "Toggle".
- **Always include the "where"** — a title like "Crashes when zooming"
is too vague; "Canvas crashes when zooming quickly" is clear.
- **No prefixes, no emoji** — strip all type labels and decorative
characters from the title.
- **Derive from the body, not the current title** — the body contains
the real detail; the current title may be auto-generated or stale.
- **Two problems → cover both** — if the description has two distinct
but related issues, capture both in the title joined by "and".

View File

@ -39,6 +39,9 @@ Backend RPC command areas without focused memories include access tokens, binfil
Database migrations live in `backend/src/app/migrations/`; pure SQL migrations are under `backend/src/app/migrations/sql/`. SQL filenames conventionally start with a sequence and verb/table description, e.g. `0026-mod-profile-table-add-is-active-field`. Applied migrations are tracked in the `migrations` table.
For interactive PostgreSQL access with correct dev defaults, use `tools/psql`; to dump
the current DDL schema, use `tools/db-schema` (see `mem:tools/psql`).
For deeper details on transaction semantics, advisory locks, Transit vs JSON helpers, and dev/test DB URLs: `mem:backend/rpc-db-worker-subtleties`.
## Background tasks

View File

@ -9,7 +9,11 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
# Development workflow
- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. Issue creation (titles, labels, body templates, Issue Types): `mem:workflow/creating-issues`.
- Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples:
- Before `git commit``mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer)
- Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
- Before `gh pr create` / `gh pr edit``mem:workflow/creating-prs` (title format, body structure, "Note:" line)
- **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked.
- You have access to the GitHub CLI `gh` or corresponding MCP tools.
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.
@ -45,7 +49,8 @@ module. You can read it from `mem:<MODULE>/core`
tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s
`*-devenv` commands, read `mem:devenv/core`.
- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval),
`paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), and
`paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`),
`psql` / `db-schema` (PostgreSQL client and schema dump wrappers, see `mem:tools/psql`), and
`taiga.py` / `gh.py` (issue management helpers).
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.

View File

@ -25,7 +25,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
## Worker policy
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv-agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
Backend workers run only on ws0. `_env` gates `enable-backend-worker` on `PENPOT_BACKEND_WORKER`; ws1+ inject it as false. ws0 must be running whenever any ws1+ is running, and is the last instance to stop — `run-devenv --agentic --ws N` (N≥1) auto-starts ws0 first; `stop-devenv` refuses to stop ws0 while any ws1+ is up. Workers are pure fire-and-forget: `wrk/submit!` inserts a row into the shared Postgres `task` table and returns; RPC handlers never wait on completion and workers never publish to msgbus. The reason for "ws0 only" is avoiding multi-instance worker races (cron dedup is best-effort across instances, `wrk/submit!` `dedupe` is racy across submitters); details in `mem:prod-infra/core`.
## Port layout
@ -44,13 +44,13 @@ Everything else (frontend dev, backend API, exporter, storybook, REPLs, plugin d
## Tmux + MCP routing
`docker/devenv/files/start-tmux.sh` is session-level idempotent. Reads `PENPOT_TMUX_ATTACH`. If the session exists it attaches or exits; otherwise creates 4 base windows (frontend watch / storybook / exporter / backend) plus `mcp` (when `enable-mcp` in `PENPOT_FLAGS`) and `serena` (when `SERENA_ENABLED=true`). `run-devenv-agentic` always sets both env vars. The legacy `run-devenv` alias doesn't, hence its 4-window-only session. To switch from a legacy session to agentic, `stop-devenv` then `run-devenv-agentic` — the conditional windows are only added at session create time.
`docker/devenv/files/start-tmux.sh` is session-level idempotent. Reads `PENPOT_TMUX_ATTACH`. If the session exists it attaches or exits; otherwise creates 4 base windows (frontend watch / storybook / exporter / backend) plus `mcp` (when `enable-mcp` in `PENPOT_FLAGS`) and `serena` (when `SERENA_ENABLED=true`). `run-devenv --agentic` always sets both env vars. The legacy `run-devenv` alias doesn't, hence its 4-window-only session. To switch from a legacy session to agentic, `stop-devenv` then `run-devenv --agentic` — the conditional windows are only added at session create time.
MCP plugin routing is same-origin: frontend uses `<public-uri>/mcp/ws`, per-instance nginx proxies to MCP port 4401 in-container. For the plugin↔MCP server wiring (how the browser plugin discovers the URL, the in-memory connection registry, why DB-mediated routing isn't needed), see `mem:mcp/core`.
## Workspace orchestration (ws1+)
Workspace directories are user-maintained at `${PENPOT_WORKSPACES_DIR}/wsN`. `run-devenv-agentic --ws i` syncs only when `--sync` is passed, with one exception: if the workspace directory is missing on first use, sync runs implicitly to seed it.
Workspace directories are user-maintained at `${PENPOT_WORKSPACES_DIR}/wsN`. `run-devenv --agentic --ws i` syncs only when `--sync` is passed, with one exception: if the workspace directory is missing on first use, sync runs implicitly to seed it.
`sync-workspace wsN`:
1. `assert-clean-git-state` — refuses on `.git/{rebase-apply,rebase-merge,MERGE_HEAD,CHERRY_PICK_HEAD,index.lock}`. No `--sync-force` escape.
@ -63,7 +63,7 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
## CLI surface
- `run-devenv-agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up.
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). Auto-starts ws0 first when the target is ws1+ and ws0 is not yet up.
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` (N≥1) stops just that workspace. `--ws 0` or no flag stops ws0 + shared infra, refused while any ws1+ is running. `--all` stops every ws highest-first then ws0, then infra.
- `run-devenv`: legacy alias, ws0 non-agentic attached.
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.

View File

@ -0,0 +1,51 @@
# PostgreSQL client wrapper
`tools/psql` is a wrapper around the `psql` command with defaults preconfigured
for the Penpot development environment.
## When to use
- Running SQL queries against the dev database (`penpot`) or test database
(`penpot_test`).
- Inspecting table structures, running migrations manually, or debugging
database state.
- Any time you need PostgreSQL access and want the correct host/user/password
without typing them each time.
## How to use
```bash
# Interactive session (penpot database)
tools/psql
# Interactive session (penpot_test database)
tools/psql --test
# Inline query (penpot)
tools/psql -c "SELECT 1"
# Inline query (penpot_test)
tools/psql --test -c "SELECT 1"
# Override defaults
tools/psql -h other-host -U other-user -d other-db
# Pipe SQL from a file
tools/psql -f some-query.sql
```
All standard `psql` flags are passed through after the wrapper's own flags.
## Defaults
| Setting | Default | Env override |
|----------|-----------|---------------------|
| Host | `postgres` | `PENPOT_DB_HOST` |
| User | `penpot` | `PENPOT_DB_USER` |
| Password | `penpot` | `PENPOT_DB_PASSWORD` |
| Database | `penpot` | `PENPOT_DB_NAME` |
## See also
`tools/db-schema` — a companion script that dumps the current DDL schema
using `pg_dump --schema-only`, with the same defaults and `--test` flag.

View File

@ -79,6 +79,8 @@ Write the body to a temp file to avoid shell quoting issues:
<version>
```
Note: do not soft-wrap paragraphs in the body. Each paragraph is a single line in the source; newlines are reserved for structural breaks (section headers, list items, code-block fences, blank-line separators). List items stay on a single line each. GitHub renders single-line paragraphs correctly, and wrapping makes diffs noisy on every small wording change. Same rule applies to PR bodies.
## Creating the Issue
```bash
@ -155,6 +157,199 @@ query { repository(owner: "penpot", name: "penpot") {
rm -f /tmp/issue-body.md
```
## Creating Issues from PRs
Used when the project board needs an issue as the primary changelog/release
unit and the PR describes the implementation. The issue is the **WHAT**
(user-facing), the PR is the **HOW** (implementation).
### Fetch the PR
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot \
--json title,body,author,labels,baseRefName,mergedAt,state,milestone
```
Identify:
- **WHAT** — user-facing problem or feature. Goes into the issue.
Describe symptoms and impact, not internal mechanisms.
- **HOW** — implementation details. These belong in the PR, not the issue.
### Determine metadata
- **Title:** rewrite from user perspective using the title rules above. Strip
leading emoji prefixes (`:bug:`, `:sparkles:`, `:tada:`). Focus on
observable behavior.
- **Labels:** copy `community contribution` if present on the PR.
- **Milestone:** always copy what's on the PR.
```bash
gh pr view <PR_NUMBER> --json milestone --jq '.milestone.title'
```
If the PR has no milestone, create the issue without one.
- **Project:** `Main`.
- **Body:** extract the user-facing section (steps to reproduce or feature
description). Omit internal details. Use the templates above.
- **Issue Type:** use the mapping table above (also handles `:bug:` /
`:sparkles:` / `:tada:` title prefixes).
### Create the issue
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
gh issue create \
--repo penpot/penpot \
--title "<Title>" \
--label "community contribution" \ # only if PR has this label
--milestone "<milestone>" \
--project "Main" \
--body-file /tmp/issue-body.md
```
Output: `https://github.com/penpot/penpot/issues/<NUMBER>`
### Assign to the PR author
```bash
AUTHOR=$(gh pr view <PR_NUMBER> --repo penpot/penpot --json author --jq '.author.login')
gh issue edit <ISSUE_NUMBER> --repo penpot/penpot --add-assignee "$AUTHOR"
```
### Set Issue Type and verify
See the **Setting the Issue Type** and **Verification** sections above — the
GraphQL mutations and `gh issue view` calls are identical regardless of how
the issue was sourced.
### Link the PR to the issue
Append `Closes #<ISSUE_NUMBER>` to the PR body:
```bash
gh pr view <PR_NUMBER> --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md
printf "\n\nCloses #<ISSUE_NUMBER>\n" >> /tmp/pr-body.md
gh pr edit <PR_NUMBER> --repo penpot/penpot --body-file /tmp/pr-body.md
# Verify
gh pr view <PR_NUMBER> --repo penpot/penpot --json body \
--jq '.body | test("Closes #<ISSUE_NUMBER>")'
```
**Note:** If the PR is already merged, `Closes` won't auto-close the issue —
it only creates the "Development" sidebar link. This is the desired
behavior since the issue is a tracking artifact.
### Clean up
```bash
rm -f /tmp/issue-body.md /tmp/pr-body.md
```
### Rules for this flow
- **One issue per PR** — even if a PR fixes multiple things, create a single
issue that summarizes the overall change.
- **Community attribution:** if the PR has the `community contribution`
label or the author is not a core team member, add the label to the issue.
- **Don't put implementation details in the issue body** — the issue is for
users, QA, and changelog readers.
## Creating Issues from Draft Body
Used when the user provides a draft body from elsewhere (Taiga story, user
report, discussion transcript) and there is no PR yet.
### Get the body
Read the draft body from wherever it was provided. If the user gives only a
vague one-liner, ask them to expand it (steps to reproduce, expected vs.
actual, use case) before proceeding.
### Derive the title
Apply the title rules in the **Title Derivation** section above. Distinguish
bug vs. feature from the body content:
- Steps to reproduce + expected vs. actual → bug
- "would be nice", "add support for", "allow users to" → feature / enhancement
### Choose a body template
Use the bug or enhancement template from the **Issue Body Template** section
above. Fill in placeholders with the user-provided details. If the body
doesn't fit either, ask the user which template to use.
### Determine metadata
- **Project:** `Main` (always).
- **Milestone:** ask the user if not obvious; otherwise omit.
- **Labels:** usually none for new user-reported issues. Add
`community contribution` if the user is a non-team contributor.
- **Issue Type:** use the mapping table above (bug description → Bug; feature
request → Enhancement or Feature).
### Create the issue
```bash
cat > /tmp/issue-body.md << 'ISSUE_BODY'
<body content here>
ISSUE_BODY
gh issue create \
--repo penpot/penpot \
--title "<Title>" \
--label "community contribution" \ # only if applicable
--milestone "<milestone>" \ # only if provided
--project "Main" \
--body-file /tmp/issue-body.md
```
### Set Issue Type and verify
Same GraphQL mutation and `gh issue view` commands as in the
**Setting the Issue Type** and **Verification** sections above.
### Clean up
```bash
rm -f /tmp/issue-body.md
```
## Retitling an Existing Issue
Used when an issue's current title is vague, prefixed, or no longer matches
the body (e.g. `[PENPOT FEEDBACK]: ...`, `feature: ...`).
### Fetch the issue
```bash
gh issue view <NUMBER> --repo penpot/penpot --json title,body
```
### Derive a new title
Read the body (not the current title) and apply the title rules in the
**Title Derivation** section above.
### Apply the new title
```bash
gh issue edit <NUMBER> --repo penpot/penpot --title "<NEW TITLE>"
```
### Confirm
```bash
gh issue view <NUMBER> --repo penpot/penpot --json title
```
## See Also
- Creating issues **from PRs** (separating WHAT from HOW): `mem:workflow/creating-prs`
- End-to-end orchestration entry point: the `create-issue` skill at
`.opencode/skills/create-issue/SKILL.md`. The skill is a thin entry
point; this memory is the canonical home for all issue-creation rules.

View File

@ -1,5 +1,20 @@
# AI AGENT GUIDE
## Hard rules (always apply — no exceptions)
- **Never `git push`, force-push, or modify `git origin`** (or any other remote).
The user pushes from their own shell. If a push is required to surface the
agent's work (e.g. force-push after an amend), state this in the response and
wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS.
- **Never amend a commit that has been pushed** unless the user explicitly asks.
If the user pushes, treat that commit as final from the agent's side.
- **Read the workflow memory BEFORE the corresponding action**:
- Before `git commit``mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
- Before `gh issue create``mem:workflow/creating-issues` (title derivation, body template, Issue Type)
- Before `gh pr create` / `gh pr edit``mem:workflow/creating-prs` (title format, body structure, AI note)
Don't infer format from the title of a previous commit/issue/PR — the memory
is the source of truth.
## CRITICAL: Read module memories BEFORE writing any code
Do this **before planning, before coding, before touching any file**:

View File

@ -64,6 +64,7 @@
- Add resource limits to font processing child processes [#10234](https://github.com/penpot/penpot/issues/10234) (PR: [#10274](https://github.com/penpot/penpot/pull/10274))
- Add color variants and positioning to selection size badge (by @bittoby) [#10258](https://github.com/penpot/penpot/issues/10258) (PR: [#9210](https://github.com/penpot/penpot/pull/9210))
- Use hard reload for render engine switching in the workspace menu [#10441](https://github.com/penpot/penpot/issues/10441) (PR: [#10444](https://github.com/penpot/penpot/pull/10444))
- Rotate size badge when shape is rotated [#10386](https://github.com/penpot/penpot/issues/10386) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
### :bug: Bugs fixed
@ -171,6 +172,14 @@
- Fix emojis without fill but with outer stroke not being rendered [#10473](https://github.com/penpot/penpot/issues/10473) (PR: [#10519](https://github.com/penpot/penpot/pull/10519))
- Fix texts with lots of emojis not being rendered across multiple tiles [#10474](https://github.com/penpot/penpot/issues/10474) (PR: [#10504](https://github.com/penpot/penpot/pull/10504))
- Fix artifacts in texts with inner strokes [#10476](https://github.com/penpot/penpot/issues/10476) (PR: [#10509](https://github.com/penpot/penpot/pull/10509))
- Fix open overlay position control showing only center icon instead of full grid [#10176](https://github.com/penpot/penpot/issues/10176) (PR: [#10512](https://github.com/penpot/penpot/pull/10512))
- Fix wrong text color in selection size badge [#10256](https://github.com/penpot/penpot/issues/10256) (PR: [#10393](https://github.com/penpot/penpot/pull/10393))
- Fix several issues with plugins [#10394](https://github.com/penpot/penpot/issues/10394)
- Fix pixel grid rendered on top of the rulers [#10426](https://github.com/penpot/penpot/issues/10426) (PR: [#10430](https://github.com/penpot/penpot/pull/10430))
- Fix double-clicking text without fills creating a black file [#10472](https://github.com/penpot/penpot/issues/10472) (PR: [#10483](https://github.com/penpot/penpot/pull/10483))
- Fix SVG raw caching prevented by unnecessary Cow wrapping [#10488](https://github.com/penpot/penpot/issues/10488) (PR: [#10492](https://github.com/penpot/penpot/pull/10492))
- Add component reset operation to plugin API [#10561](https://github.com/penpot/penpot/issues/10561) (PR: [#10533](https://github.com/penpot/penpot/pull/10533))
- Fix blur menu alignment in Firefox [#10576](https://github.com/penpot/penpot/issues/10576) (PR: [#10575](https://github.com/penpot/penpot/pull/10575))
## 2.16.2

View File

@ -1,30 +1,22 @@
---
title: 3.11. Agentic Development Environment
title: 3.11. Agentic Dev Environment
desc: Dive into agentic Penpot development.
---
# Agentic Development Environment
# Agentic Dev Environment
The agentic DevEnv is an extension of the standard DevEnv (the
[general DevEnv instructions](/technical-guide/developer/devenv/) apply),
optimised for AI agent-based development. It adds MCP servers (Penpot,
Serena, Playwright) and supports a launcher that wires them into your AI client.
Two things to know up front:
- **Parallel workspaces are first-class.** Run several devenv instances side
by side - one per AI agent if you like - each with its own source-tree
clone, ports, and tmux session. Pass `--ws N` to target one.
- **Your existing AI-client config is preserved.** The launcher loads a
per-workspace MCP config on top of your global one.
## Quick Start
1. **Bring up one or more workspaces**[^cfg]:
```bash
./manage.sh run-devenv-agentic # ws0 (the live repo)
./manage.sh run-devenv-agentic --ws 1 # ws1 (sibling clone)
./manage.sh run-devenv --agentic --attach # ws0 (the live repo)
./manage.sh run-devenv --agentic --ws 1 # ws1 (sibling clone)
```
Add `--ws 2`, `--ws 3`, … for more parallel workspaces.
@ -47,20 +39,20 @@ Two things to know up front:
"MCP Server" on. The agentic DevEnv runs the MCP server in single-user
mode - the key and proxied URL shown in the UI are not needed.
4. **Launch your AI client** against the workspace you want it to drive:
4. **Launch your AI client**. Either use your manually configured client
(as described below) or conveniently launch an explicitly supported client
against the workspace you want it to drive:
```bash
./manage.sh start-coding-agent claude # ws0
./manage.sh start-coding-agent claude # ws0 (main)
./manage.sh start-coding-agent claude --ws 1 # ws1
```
Supported clients: `claude` | `opencode` | `vscode` | `codex`.
5. **Attach to the tmux session** for the workspace (optional):
Note: The launcher loads a per-workspace MCP config on top of your global one (if any).
```bash
./manage.sh attach-devenv # ws0
./manage.sh attach-devenv --ws 1 # ws1
```
5. **Work within your client**, which is now equipped with extended capabilities
for Penpot development (see below for details).
6. **Shut down workspaces** with `./manage.sh stop-devenv`, either one by one or all at once.
You cannot shut down `ws0` if any other workspace is still running, since it's the worker-bearer.
@ -113,10 +105,10 @@ var penpotFlags = "enable-mcp";
```
The file is gitignored and lives in the live repo only. On every
`run-devenv-agentic` call it is read directly for ws0; for wsN (N ≥ 1) it is
`run-devenv --agentic` call it is read directly for ws0; for wsN (N ≥ 1) it is
copied into the workspace clone on the **initial** sync only - subsequent
`--sync` passes leave the workspace's copy alone so per-workspace
customisations survive. `run-devenv-agentic` refuses to start if the file is
customisations survive. `run-devenv --agentic` refuses to start if the file is
missing.
**Browser remote debugging.** The Playwright MCP server drives a real
@ -148,13 +140,13 @@ devenv image itself (add a tool, change a base layer):
./manage.sh build-devenv --local
```
The default `run-devenv-agentic` flow pulls the published image
The default `run-devenv --agentic` flow pulls the published image
automatically, so regular users never run this.
### Bringing up workspaces
```bash
./manage.sh run-devenv-agentic \
./manage.sh run-devenv --agentic \
[--ws N] [--sync] [--serena-context CTX] \
[--git-user-name NAME] [--git-user-email EMAIL]
```
@ -171,7 +163,7 @@ every bring-up so you don't compute offsets by hand. See the
semantics, and stop ordering.
**Git identity for agent commits.** Coding agents typically need to commit
inside the devenv, so `run-devenv-agentic` wires a Git identity into the
inside the devenv, so `run-devenv --agentic` wires a Git identity into the
container's global config on every bring-up. By default it propagates the
host's effective `git config user.{name,email}` (local repo override wins
over `~/.gitconfig`, matching what `git commit` on the host would record).
@ -189,7 +181,7 @@ the full mechanics.
>
> ```bash
> ./manage.sh stop-devenv
> ./manage.sh run-devenv-agentic
> ./manage.sh run-devenv --agentic
> ```
### Launching an AI client
@ -198,7 +190,7 @@ The agentic environment supports any AI client, one just needs to set the right
see [manual configuration](#manual-ai-client-configuration) below. For some popular clients, the `manage.sh`
CLI offers direct support through the following mechanism:
Every `run-devenv-agentic` regenerates three MCP-client config files with
Every `run-devenv --agentic` regenerates three MCP-client config files with
the workspace's ports baked in; Codex is wired up at launch instead (see
below):

View File

@ -45,31 +45,38 @@ This is an incomplete list of devenv related subcommands found on
manage.sh script:
```bash
./manage.sh build-devenv --local # builds the local devenv docker image
./manage.sh start-devenv # brings up the shared infra + ws0 in background
./manage.sh run-devenv # ws0 with non-agentic tmux, attached (legacy alias)
./manage.sh run-devenv-agentic # one agentic instance; --ws to target ws1+; see below
./manage.sh attach-devenv # re-attaches to the tmux session of a running instance
./manage.sh stop-devenv # stops one instance (or --all); infra stops with the last
./manage.sh drop-devenv # removes containers (data volumes preserved)
./manage.sh build-devenv --local # builds the local devenv docker image
./manage.sh start-devenv # brings up the shared infra + ws0 in background
./manage.sh run-devenv --attach # bring up main devenv instance and attach to its tmux session
./manage.sh run-devenv --agentic --attach # bring up main devenv instance in agentic mode and attach tmux
./manage.sh attach-devenv # re-attaches to the tmux session of a running instance
./manage.sh stop-devenv # stops one instance (or --all); infra stops with the last
./manage.sh drop-devenv # removes containers (data volumes preserved)
```
### Agentic Mode
The `--agentic` flag enables additional features for AI-assisted development.
See the dedicated section [Agentic Dev Environment](../agentic-devenv/) for details.
### Parallel workspaces
The devenv runs as separate compose projects: shared infra (`penpotdev-infra`:
Postgres, MinIO, mailer, LDAP) plus one `penpotdev-wsN` project per runtime
instance. `ws0` (a.k.a. `main`) binds the live repo; `ws1+` bind clones the
developer maintains explicitly under `${PENPOT_WORKSPACES_DIR}/wsN/`
(default `~/.penpot/penpot_workspaces/`).
The devenv runs as separate compose projects:
* shared infra (`penpotdev-infra`: Postgres, MinIO, mailer, LDAP)
* `penpotdev-wsN` project per runtime instance.
- `ws0` (a.k.a. `main`) is the current state of your repo;
- `ws1` and up are clones that you maintain explicitly under `${PENPOT_WORKSPACES_DIR}/wsN/`
(default `~/.penpot/penpot_workspaces/`). You can explicitly sync them
with the `--sync` flag (automatic on first start).
Each call to `run-devenv-agentic` brings up one instance, and ws0 is always
Each call to `run-devenv` brings up one instance, and ws0 is always
running whenever any ws1+ is — `--ws N` (N≥1) auto-starts ws0 first if it
isn't already up:
```bash
./manage.sh run-devenv-agentic # main (ws0)
./manage.sh run-devenv-agentic --ws 1 # ws0 if needed, then ws1
./manage.sh run-devenv-agentic --ws 2 --sync # ws2, re-seeding from the live repo
./manage.sh run-devenv # main (ws0)
./manage.sh run-devenv --ws 1 # ws0 if needed, then ws1
./manage.sh run-devenv --ws 2 --sync # ws2, re-seeding from the live repo
```
Starting an instance that is already running is an error. `--sync` is only
@ -102,12 +109,12 @@ Host ports are offset by `10000 × N`:
| Serena MCP | `http://localhost:14181` | `http://localhost:24181` | `http://localhost:34181` |
Container-internal ports stay fixed. Target a specific instance with
`--ws N` on `attach-devenv`, `run-devenv-agentic`, `stop-devenv`,
`--ws N` on `attach-devenv`, `run-devenv`, `stop-devenv`,
`start-coding-agent`, `run-devenv-shell`, and `isolated-shell`. `--ws`
accepts a **non-negative integer only**`--ws main` or `--ws ws1` is
rejected, keeping the flag shape uniform across commands. `run-devenv` is
ws0-only and takes no workspace flag. `run-devenv-agentic` also accepts
`--serena-context CTX` and `--git-user-name NAME` / `--git-user-email
ws0-only and takes no workspace flag. `run-devenv` also accepts
`--serena-context CTX` (used together with `--agentic`) and `--git-user-name NAME` / `--git-user-email
EMAIL` (see below).
Configuration lives in one tracked file, `docker/devenv/defaults.env` (the
@ -116,7 +123,7 @@ derived and injected automatically, so there is no per-instance file to edit.
### Git identity inside the container
`run-devenv-agentic` wires a Git author identity into the container's
`run-devenv` wires a Git author identity into the container's
**global** git config (`git config --global user.{name,email}`) so commits
made from inside the devenv carry a real author/committer. Without this,
the container would commit as the unconfigured `penpot@<container>`
@ -130,7 +137,7 @@ returns at the working directory `manage.sh` is invoked from — local
`git commit` on the host would record. If neither is available the script
prints a warning and continues — commits will fail inside the container
until you set an identity. The values are applied every time
`run-devenv-agentic` brings an instance up (idempotent), so re-running
`run-devenv` brings an instance up (idempotent), so re-running
with different flags is the way to change the in-container identity.
### Shared state and workers
@ -185,11 +192,10 @@ docker rm penpotdev-postgres-1 penpotdev-minio-1 penpotdev-minio-setup-1 \
docker network rm penpotdev_default 2>/dev/null
# Bring up infra + ws0 under the new project layout.
./manage.sh run-devenv-agentic
./manage.sh run-devenv
```
After the cleanup, normal `./manage.sh start-devenv` / `run-devenv` /
`run-devenv-agentic` commands work against the new layout. The legacy
After the cleanup, normal `./manage.sh start-devenv` / `run-devenv` work against the new layout. The legacy
`penpotdev` compose project is no longer used.
Having the container running and tmux opened inside the container,

View File

@ -65,6 +65,7 @@
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.variants :as dwva]
[app.main.data.workspace.viewport :as dwv]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.data.workspace.zoom :as dwz]
[app.main.errors]
[app.main.features :as features]
@ -441,6 +442,14 @@
;; Keep comment thread positions in sync on undo/redo
(rx/of (dwcm/watch-comment-thread-position-changes stoper-s))
;; Resize auto-grow text shapes whose selrect does not match
;; the WASM text layout once their fonts finish loading.
(->> stream
(rx/filter (ptk/type? :app.render-wasm.api/stale-text-selrects))
(rx/map deref)
(rx/map (fn [{:keys [ids]}]
(dwwt/resize-wasm-text-all ids))))
(let [local-commits-s
(->> stream
(rx/filter dch/commit?)

View File

@ -48,12 +48,14 @@
(wasm.api/set-shape-text-images id content))
(let [dimension (when (not= :fixed grow-type)
(wasm.api/get-text-dimensions))]
{:width (if (#{:fixed :auto-height} grow-type)
(:width selrect)
(:width dimension))
:height (if (= :fixed grow-type)
(:height selrect)
(:height dimension))}))))
;; nil dimension = shape not present in WASM state; skip the resize.
(when (or (= :fixed grow-type) (some? dimension))
{:width (if (#{:fixed :auto-height} grow-type)
(:width selrect)
(:width dimension))
:height (if (= :fixed grow-type)
(:height selrect)
(:height dimension))})))))
(defn resize-wasm-text-modifiers
([shape]

View File

@ -575,15 +575,19 @@
(wasm.api/push-ruler-theme-colors!)
(wasm.api/request-render "rulers-colors-theme")))))
;; Ruler overlay updates below only change the UI surface, not the shapes.
;; They use `render-from-cache!` (cached tiles + UI, atomic) instead of a full
;; `request-render`, which would kick off a progressive tile-by-tile shape
;; re-render that flashes on zoomed-in views (see penpot ruler-selection flash).
(mf/with-effect [@canvas-init? frame-visible?]
(when @canvas-init?
(wasm.api/set-rulers-frame-visible! frame-visible?)
(wasm.api/request-render "rulers-frame")))
(wasm.api/render-from-cache!)))
(mf/with-effect [@canvas-init? show-rulers?]
(when @canvas-init?
(wasm.api/set-rulers-visible! show-rulers?)
(wasm.api/request-render "rulers-visible")))
(wasm.api/render-from-cache!)))
(mf/with-effect [@canvas-init? show-rulers? offset-x offset-y]
(when (and @canvas-init? show-rulers?)
@ -594,7 +598,7 @@
(some-> ruler-selection :width) (some-> ruler-selection :height)]
(when (and @canvas-init? show-rulers?)
(wasm.api/set-rulers-selection! ruler-selection)
(wasm.api/request-render "rulers-selection")))
(wasm.api/render-from-cache!)))
;; Paint background + rulers instantly, before shapes finish loading. Runs
;; after the ruler push effects so the WASM ruler state is already set.

View File

@ -58,6 +58,7 @@
[app.util.timers :as timers]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]
[promesa.core :as p]
[rumext.v2 :as mf]))
@ -459,6 +460,20 @@
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_ui_only")))
(defn render-from-cache!
"Blit the shapes from the cached tile atlas and redraw the UI overlay
(rulers, selection band) fresh on top, in a single atomic frame. The
*shapes* are the cached part (already-rasterized tiles, not rebuilt); the UI
is re-rendered every call, which is what lets it reflect a new selection.
Use for UI-only updates that don't change shapes (e.g. the ruler selection
band): unlike `request-render`, it never kicks off a progressive,
tile-by-tile shape re-render, so it does not flash on zoomed-in views where
the scene spans multiple tiles."
[]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_from_cache" 0)))
;; CSS-pixel blur radius for the page-transition snapshot (DPR-scaled in WASM).
(def ^:private TRANSITION_BLUR_RADIUS 4.0)
@ -1282,17 +1297,19 @@
([]
(if-not (initialized?)
{:x 0 :y 0 :width 0 :height 0 :max-width 0}
(let [offset (-> (h/call wasm/internal-module "_get_text_dimensions")
(mem/->offset-32))
heapf32 (mem/get-heap-f32)
width (aget heapf32 (+ offset 0))
height (aget heapf32 (+ offset 1))
max-width (aget heapf32 (+ offset 2))
(let [ptr (h/call wasm/internal-module "_get_text_dimensions")]
;; NULL pointer when there is no current shape or it is not a text.
(when-not (zero? ptr)
(let [offset (mem/->offset-32 ptr)
heapf32 (mem/get-heap-f32)
width (aget heapf32 (+ offset 0))
height (aget heapf32 (+ offset 1))
max-width (aget heapf32 (+ offset 2))
x (aget heapf32 (+ offset 3))
y (aget heapf32 (+ offset 4))]
(mem/free)
{:x x :y y :width width :height height :max-width max-width}))))
x (aget heapf32 (+ offset 3))
y (aget heapf32 (+ offset 4))]
(mem/free)
{:x x :y y :width width :height height :max-width max-width}))))))
(defn intersect-position-in-shape
[id position]
@ -1452,19 +1469,45 @@
[text-ids]
(run! f/force-update-text-layout text-ids))
(defn- text-selrect-stale?
"Check if the WASM-measured dimensions of an auto-grow text shape differ
from its stored selrect (same 0.1px tolerance as the classic renderer)."
[{:keys [id selrect grow-type]}]
(when-let [{:keys [width height]} (get-text-dimensions id)]
(case grow-type
:auto-width (or (not (mth/close? width (:width selrect) 0.1))
(not (mth/close? height (:height selrect) 0.1)))
:auto-height (not (mth/close? height (:height selrect) 0.1))
false)))
(defn- sync-stale-text-selrects!
"Emit the ids of auto-grow text shapes whose selrect no longer matches the
measured layout, so the workspace resizes them (data-event instead of a
direct call to avoid a circular dependency; see the watcher in
`app.main.data.workspace/initialize-workspace`)."
[shapes]
(let [stale-ids (into []
(comp (filter cfh/text-shape?)
(filter (comp #{:auto-width :auto-height} :grow-type))
(filter text-selrect-stale?)
(map :id))
shapes)]
(when (seq stale-ids)
(st/emit! (ptk/data-event ::stale-text-selrects {:ids stale-ids})))))
(defn- relayout-after-fonts!
"Relayout text shapes once their pending fonts have resolved. Shapes in
`font-pending-ids` had a font fetched, so they get a forced relayout to pick
up the real glyph metrics; the remaining text shapes get a normal layout."
"Relayout text shapes once their pending fonts have resolved. Font fetches
are deduped per URL and storing a font does not invalidate cached layouts,
so every text shape (not only the fetch triggers in `font-pending-ids`)
needs a forced relayout; then re-sync selrects that drifted."
[shapes font-pending-ids]
(let [force-ids (set font-pending-ids)
text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)
forced (filterv force-ids text-ids)
rest-ids (filterv (complement force-ids) text-ids)]
(when (seq forced)
(force-update-text-layouts forced))
(when (seq rest-ids)
(update-text-layouts rest-ids))))
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(if (seq font-pending-ids)
(do
(force-update-text-layouts text-ids)
(sync-stale-text-selrects! shapes))
(update-text-layouts text-ids)))))
(defn process-pending
[shapes thumbnails full font-pending-ids on-complete]

View File

@ -1,21 +1,61 @@
#!/usr/bin/env node
// Entry point (bin) of the published @penpot/mcp package.
// This script is not intended to be run from a source checkout; there, use
// `pnpm run bootstrap` directly, as described in the README.
const { execSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
const root = path.resolve(__dirname, "..");
const pkg = require(path.join(root, "package.json"));
const packageRoot = path.resolve(__dirname, "..");
const pkg = require(path.join(packageRoot, "package.json"));
function pnpmVersion() {
const match = (pkg.packageManager || "").match(/^pnpm@([^+]+)/);
return match ? match[1] : "latest";
}
function run(command) {
execSync(command, { cwd: root, stdio: "inherit" });
/**
* Prepares the directory in which to bootstrap and run.
*
* The package contents are copied once per version to a runtime directory
* owned by this launcher. The npm-owned package directory cannot be used;
* we have to assume it is read-only (#9947).
*
* @returns {string} the directory to run the bootstrap in
*/
function prepareRuntimeRoot() {
const cacheBase = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
const runtimeRoot = path.join(cacheBase, "penpot-mcp", pkg.version);
if (fs.existsSync(runtimeRoot)) {
return runtimeRoot;
}
// copy to a temporary sibling first and rename, so an interrupted copy
// cannot leave a partial runtime directory behind
console.log(`Preparing runtime directory ${runtimeRoot} ...`);
const tempRoot = `${runtimeRoot}.tmp-${process.pid}`;
fs.rmSync(tempRoot, { recursive: true, force: true });
fs.cpSync(packageRoot, tempRoot, {
recursive: true,
filter: (src) => path.basename(src) !== "node_modules",
});
try {
fs.renameSync(tempRoot, runtimeRoot);
} catch (error) {
fs.rmSync(tempRoot, { recursive: true, force: true });
if (!fs.existsSync(runtimeRoot)) {
throw error;
}
// a concurrent launch created the runtime directory in the meantime; use it
}
return runtimeRoot;
}
const root = prepareRuntimeRoot();
// pnpm-lock.yaml is hard-excluded by npm pack; it is shipped as pnpm-lock.dist.yaml
// and restored here before bootstrap runs.
const distLock = path.join(root, "pnpm-lock.dist.yaml");
@ -25,7 +65,7 @@ if (fs.existsSync(distLock)) {
}
try {
run(`npx -y pnpm@${pnpmVersion()} run bootstrap`);
execSync(`npx -y pnpm@${pnpmVersion()} run bootstrap`, { cwd: root, stdio: "inherit" });
} catch (error) {
process.exit(error.status ?? 1);
}

View File

@ -1,6 +1,6 @@
{
"name": "@penpot/mcp",
"version": "2.16.0",
"version": "2.17.0",
"description": "MCP server for Penpot integration",
"license": "MPL-2.0",
"bin": {

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "@penpot/plugin-types",
"version": "1.4.2",
"version": "1.5.0",
"typings": "./index.d.ts",
"type": "module",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@penpot/plugins-runtime",
"version": "1.4.2",
"version": "1.5.0",
"dependencies": {
"@penpot/plugin-types": "workspace:^",
"ses": "^2.1.0",

View File

@ -1,6 +1,6 @@
{
"name": "@penpot/plugin-styles",
"version": "1.4.2",
"version": "1.5.0",
"dependencies": {},
"scripts": {
"build": "node ../../tools/scripts/build-css.mjs",

View File

@ -1,16 +1,19 @@
import esbuild from 'esbuild';
import { copy } from 'fs-extra';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const source = 'libs/plugins-styles';
const dist = 'dist/plugins-styles';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const source = resolve(root, 'libs/plugins-styles');
const dist = resolve(root, 'dist/plugins-styles');
const handleErr = (err) => {
console.error(err);
process.exit(1);
};
esbuild
.build({
Promise.all([
esbuild.build({
entryPoints: [`${source}/src/lib/styles.css`],
bundle: true,
outfile: `${dist}/styles.css`,
@ -18,9 +21,8 @@ esbuild
loader: {
'.svg': 'dataurl',
},
})
.catch(handleErr);
copy(`${source}/package.json`, `${dist}/package.json`).catch(handleErr);
copy(`${source}/README.md`, `${dist}/README.md`).catch(handleErr);
copy(`LICENSE`, `${dist}/LICENSE`).catch(handleErr);
}),
copy(`${source}/package.json`, `${dist}/package.json`),
copy(`${source}/README.md`, `${dist}/README.md`),
copy(resolve(root, 'LICENSE'), `${dist}/LICENSE`),
]).catch(handleErr);

View File

@ -1,14 +1,19 @@
import { copy } from 'fs-extra';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const source = 'libs/plugin-types';
const dist = 'dist/plugin-types';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const source = resolve(root, 'libs/plugin-types');
const dist = resolve(root, 'dist/plugin-types');
const handleErr = (err) => {
console.error(err);
process.exit(1);
};
copy(`${source}/package.json`, `${dist}/package.json`).catch(handleErr);
copy(`${source}/README.md`, `${dist}/README.md`).catch(handleErr);
copy(`${source}/index.d.ts`, `${dist}/index.d.ts`).catch(handleErr);
copy(`LICENSE`, `${dist}/LICENSE`).catch(handleErr);
Promise.all([
copy(`${source}/package.json`, `${dist}/package.json`),
copy(`${source}/README.md`, `${dist}/README.md`),
copy(`${source}/index.d.ts`, `${dist}/index.d.ts`),
copy(resolve(root, 'LICENSE'), `${dist}/LICENSE`),
]).catch(handleErr);

View File

@ -1,5 +1,5 @@
import { execSync } from 'child_process';
import { readFileSync, writeFileSync } from 'fs';
import { cpSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
@ -66,6 +66,21 @@ const writePackageJson = (packagePath: string, content: PackageJson): void => {
writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n');
};
const getPublishPath = (packagePath: string): string => {
return join('dist', packagePath.split('/').pop()!);
};
const prepareRuntimePackage = (): void => {
const source = 'libs/plugins-runtime';
const dist = getPublishPath(source);
mkdirSync(dist, { recursive: true });
cpSync(join(source, 'package.json'), join(dist, 'package.json'));
cpSync(join(source, 'README.md'), join(dist, 'README.md'));
cpSync('LICENSE', join(dist, 'LICENSE'));
cpSync(join(source, 'dist'), join(dist, 'dist'), { recursive: true });
};
const incrementVersion = (
currentVersion: string,
specifier: string,
@ -164,6 +179,7 @@ const log = (message: string, verbose: boolean, forceLog = false): void => {
stdio: 'inherit',
},
);
prepareRuntimePackage();
} else {
console.log(' [DRY RUN] Skipping build\n');
}
@ -176,7 +192,7 @@ const log = (message: string, verbose: boolean, forceLog = false): void => {
for (const packagePath of PACKAGES) {
const pkg = readPackageJson(packagePath);
const distPath = join('dist', packagePath.split('/').pop()!);
const distPath = getPublishPath(packagePath);
if (args.dryRun) {
console.log(
@ -188,7 +204,7 @@ const log = (message: string, verbose: boolean, forceLog = false): void => {
`pnpm publish --tag ${tag} --access public --no-git-checks`,
{
cwd: join(process.cwd(), distPath),
stdio: args.verbose ? 'inherit' : 'pipe',
stdio: 'inherit',
},
);
console.log(` ✅ Published ${pkg.name}@${newVersion}`);

34
tools/db-schema Executable file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
HOST="${PENPOT_DB_HOST:-postgres}"
USER="${PENPOT_DB_USER:-penpot}"
PASSWORD="${PENPOT_DB_PASSWORD:-penpot}"
DB="${PENPOT_DB_NAME:-penpot}"
while [[ $# -gt 0 ]]; do
case "$1" in
--test)
DB="penpot_test"
shift
;;
--host|-h)
HOST="$2"
shift 2
;;
--user|-U)
USER="$2"
shift 2
;;
--db|-d)
DB="$2"
shift 2
;;
*)
break
;;
esac
done
export PGPASSWORD="$PASSWORD"
exec pg_dump -h "$HOST" -U "$USER" -d "$DB" --schema-only --no-owner --no-privileges "$@"

34
tools/psql Executable file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
HOST="${PENPOT_DB_HOST:-postgres}"
USER="${PENPOT_DB_USER:-penpot}"
PASSWORD="${PENPOT_DB_PASSWORD:-penpot}"
DB="${PENPOT_DB_NAME:-penpot}"
while [[ $# -gt 0 ]]; do
case "$1" in
--test)
DB="penpot_test"
shift
;;
--host|-h)
HOST="$2"
shift 2
;;
--user|-U)
USER="$2"
shift 2
;;
--db|-d)
DB="$2"
shift 2
;;
*)
break
;;
esac
done
export PGPASSWORD="$PASSWORD"
exec psql -h "$HOST" -U "$USER" -d "$DB" "$@"