A Nextcloud app that mirrors your Penpot design files into the Files app — a click-through to every design, a real backup for the ones that matter, and a folder tree you can organise however you like.
Status: pre-alpha — most of what follows is design, not shipped behavior.
What works today:
- The app installs on Nextcloud and you can point it at a Penpot instance — an admin setting plus
occ penpot_sync:set-url/occ penpot_sync:show-config.- A Penpot API client: Transit decoding, the per-command parameter table, and typed errors. Store a service-account token with
occ penpot_sync:set-token, then check the connection withocc penpot_sync:test-connection— it reports which teams that token can actually see, which is what decides what you can map.- The complete admin surface: instance URL, service-account credential, team mappings, scheduled-pull settings, and an optional per-user token for attribution. Every control persists and has an
occtwin (list-teams,add-mapping,list-mappings,remove-mapping, …).- The pull (Penpot → Nextcloud):
occ penpot_sync:sync pullmirrors a mapped team into a plain Nextcloud folder — projects become folders and files become.penpotfiles, each stamped with Penpot metadata, and re-pulling reconciles in place instead of duplicating.occ penpot_sync:status <path>shows a node's metadata, what the file actually holds, and the project/team the membership resolver derives by walking its ancestor folders.- Moving things — the two illegal moves are refused before they happen (a project folder leaving its team folder; a
linkfile changing project), and moving a stored design between project folders re-files it in Penpot for real viamove-files.syncmode — real archives. A mapping made with--mode=syncexports each design from Penpot and stores the actual.penpotZIP in Nextcloud; alinkmapping stores nothing but the pointer. A pull re-exports asyncfile only when its Penpot revision moved or its archive went missing, so a team of links costs zero exports.- The prune, with a parachute. A design deleted in Penpot no longer leaves a mirror that opens nothing: the pull moves it to the Nextcloud trash — never a hard delete — and a pointer gets one last export on the way out, so what lands in the trash is a real, openable archive. Any incomplete listing switches pruning off entirely, because a network blip and "everything was deleted" look identical from here.
Verified against a real Nextcloud and a real Penpot in CI — including a pull asserted end-to-end against a project seeded directly in Penpot, and a promotion asserted to leave real ZIP bytes on disk.
It mirrors, it keeps the bytes you ask it to, and it respects your folder layout. It is still narrow: only the plain admin-owned folder backend (the groupfolders Team Folder backend, the Files-app surface, and the remaining write-back paths are the next slices), and controls that configure an unbuilt part still say so. The admin surface was built whole first so that every later feature is something you configure rather than something that ships twice.
Everything else below is the design, written as if it already worked because that is how the spec is written. Each
.featurefile stays tagged@todountil its slice ships. Treat it as a detailed design document backed by a live-verified API survey. See Status.
Penpot Sync walks your mapped Penpot teams on a schedule and reflects them into
Nextcloud. Each Penpot team becomes a Team Folder; each project inside it
becomes a folder; each design becomes a .penpot file with a real icon and a
deep link back to the live design in Penpot.
Penpot Nextcloud
───────────────────────────── ─────────────────────────────────────
team "Northwind" ⟶ Team Folder Northwind/
└ project "My Stuff" ⟶ folder My Stuff/
└ file "My firsty" ⟶ file My firsty.penpot
A .penpot file is a plain ZIP (a manifest.json, a files/ tree of
pages/colors/components/typographies, and an objects/ tree of binary assets),
fetched through Penpot's export-binfile RPC. You can unzip and inspect one.
Every mirrored file is one of two things, and you choose per file:
| Mode | What it is | What it costs |
|---|---|---|
link (default) |
An empty file that points at the live design. Opens in Penpot. | Nothing — never exports, stores no bytes |
sync (opt-in) |
A real, downloaded .penpot archive you can open offline |
One export whenever the design changes |
Neither mode ever pushes design content to Penpot. In the sibling apps for
n8n and
Grafana, sync means "edits flow
back." Here the axis decides only whether we store the bytes — a sync file
is still a read-only mirror.
Why: a .penpot export is a full archive with embedded images and fonts, not a
small JSON document. Backing up every design in a large team would be expensive
and mostly pointless — most designs need to be findable and clickable, not
duplicated. So link is the default, and a team worth backing up is mapped with
--mode=sync instead.
Links stay where Penpot put them. A link file holds no bytes, so moving one
out of its project would hand you an empty husk that looks like a design and
isn't. Links can be filed freely within their own project — including into
plain subfolders — but can't be moved to another project, out to Drafts, or out
of the mapping entirely. Deleting one just hides it. sync files have none of
these limits, because they hold something real — which is a property of the
mapping they were mirrored under, not something a single file can be switched
into.
This app never edits your designs. A .penpot export is an opaque archive of
nested shape data — there is no sane way to hand-edit it in Nextcloud and
re-import it coherently. Design happens in Penpot; Nextcloud holds the backup and
the click-through.
- No "Edit as text" action — not even as a fallback, unlike both siblings.
- No content writeback. Editing a mirrored file's bytes never reaches Penpot.
- No tag sync — Penpot's API has no tags, labels, or annotations at all (confirmed by scanning its full RPC surface: 149 commands, zero hits).
- No design content is ever written back. Deleting is passed on — to Penpot's own trash, which is as reversible as your Nextcloud one, and restoring the file brings the design back with it. Nothing is destroyed until you empty the trash, and even then only if Penpot still has it in its own.
The app isn't inert — it just never does anything destructive by accident. The complete list:
| What you do | What happens in Penpot | Reversible? |
|---|---|---|
| Drag a design into another project folder | It moves to that project | Drag it back |
| Drag it to the team root | It moves to Drafts | Drag it back |
| New → Penpot design | A design is created | Delete it |
| Copy a design file | A real copy is created | Delete it |
| Rename a design or project folder | It's renamed | Rename it back |
| Delete a mirror | Moved to Penpot's trash | Restore it from your trash |
| Restore a mirror from your trash | Taken back out of Penpot's trash | Delete it again |
| Empty your trash | Permanently deleted — only if still in Penpot's trash | NO |
Exactly one operation in the entire app destroys anything — emptying your Nextcloud trash, the one gesture Nextcloud itself treats as irreversible. Everything else is additive or reversible, and the delete/restore pair mirrors Penpot's own trash step for step. That, rather than a short list, is what the read-only promise actually protects.
Penpot's hierarchy is rigid — team → project → file, no sub-projects. Nextcloud is a file manager. This app doesn't force Penpot's flatness onto your Nextcloud folders.
A file's project is the nearest ancestor folder carrying a Penpot project id. That one rule buys a lot:
Northwind/ ← Team Folder (team id in metadata)
├── Clients/ ← just a folder you made. Penpot never sees it
│ ├── Acme/ ← project folder (project id in metadata) 🏷
│ │ ├── Homepage.penpot → belongs to the "Acme" project
│ │ └── wip/ ← just a folder
│ │ └── Draft.penpot → still belongs to "Acme" (nearest ancestor)
│ └── Globex/ ← project folder 🏷
│ └── Brand.penpot → belongs to "Globex"
└── notes.txt ← ignored entirely, never touched
Identity lives in metadata, not in path, so a project folder works the same
at any depth and you can reorganise freely. Project folders also carry a visible
penpot tag (🏷) so you can spot and search for them among ordinary folders.
And the tag works in reverse. Every Penpot project becomes a folder here
automatically — but a folder of yours becomes a Penpot project only if you ask.
Put the penpot tag on any folder inside a mapped folder and it becomes a real
Penpot project, taking any designs already inside it with it. Everything else
stays an ordinary folder: notes, exports, whatever you like. Removing the tag
never deletes the project — it is a label, not a lifetime.
The one restriction: a project folder may move anywhere inside its Team Folder, but not out of it. Moving a project between teams is a destructive cross-team change that belongs in Penpot, not in a drag gesture.
Project names are plain names here, and a / in one is not allowed — a Nextcloud
folder name cannot carry it, and inventing an intermediate folder from it would
create a folder no Penpot object corresponds to. Such a project is skipped and
reported by name so you can rename it in Penpot.
The alternative — a project's name being its path, so foo/bar mirrors as two
folders — was designed and never built. Either / carries structure or it
doesn't, and it can't do both, so it would have to be a per-team choice rather
than a fallback. Until that is built there is nothing to choose, so there is no
setting for it.
Every Penpot account has a personal "Default" team. It gets no Team Folder — a personal space isn't a sharing boundary. Instead, its projects mount as folders at the root of your Nextcloud home:
Your home/
├── Sketches/ ← your personal Penpot project 🏷
└── Logos/ ← another one 🏷
This is the one part of the mirror that uses your own token — a service account can never be a member of your personal team.
Penpot has no service-account or admin credential type: every access token is
scoped to a personal user account (confirmed structurally — there is no
admin/system RPC module, and the organization layer above teams is
permission-gated off on self-hosted instances). So the app splits the job in two:
| Service-account token | Your personal token | |
|---|---|---|
| Required? | Yes, per mapped team | No — optional |
| Set by | Admin, once | You, in personal settings |
| Does | All mirroring: list, export, pull | Attributes your changes to you |
| Pulls your personal projects |
Why a service account is required. It does all the reading, as one background job. If the pull ran per-user, two people on the same Penpot team would both write the same mirrored file from separate jobs — a real data race. One puller, no race.
The cost: someone with authority over each Penpot team must invite the service
account as a viewer before that team can be mapped. That's not us being
strict — Penpot gives no credential an instance-wide view, so a team has to be
brought into scope explicitly either way. It doubles as a clean opt-in gate:
inviting the service account is how a team says "yes, Nextcloud may manage this."
Why your own token is still worth setting. Penpot attributes every change to whoever's token made it. Without one, every change from Nextcloud shows up in Penpot's history as the service account — forever, unfixable after the fact.
An admin maps a Penpot team; its projects come along automatically as folders. There is no project-level mapping to configure — nothing to add, and nothing that could get out of sync with what Penpot actually contains.
You name the folder; Penpot names the projects. A mapping binds a Penpot team to a Nextcloud folder, and the folder can be called whatever suits your instance. Leave the name blank and it defaults to the Penpot team's own name — the same rule the Grafana integration uses for its folder mappings, so the two behave alike. The mapping is keyed on the team id, so renaming the team in Penpot never breaks it, and never silently renames the folder you chose.
Project folders inside the mapped folder are the exception: they always match their Penpot project's name exactly, in both directions. A rename in Penpot propagates down on the pull, and renaming a project folder in Nextcloud renames the project upstream. The reasoning for the split: a team folder is a mount point you chose to create, so naming it is yours; a project folder is a mirror of a Penpot object, and letting its name drift would break the identity the pull uses to match folders to projects.
Two mappings cannot target the same Nextcloud folder — their project subfolders would interleave and the pull would fight over the same names on every run.
Most of a mapping is fixed once it is created, following the same rule the n8n and Grafana integrations use: a field is immutable when changing it would force a live migration of already-mirrored content. The team, the Nextcloud folder, the Team Folder setting and the default mode are all set at creation; the groups the folder is shared with stay editable. To change anything else, remove the mapping and add it again — which makes the cost visible instead of hiding it behind a dropdown.
The groups are the folder's, not the mapping's. Nothing about sharing is
stored here: the app applies the groups you give it when it creates the folder,
and after that it simply reads whatever the folder says. Re-share it from the
Files app or with occ and this app reports the change; syncing never puts back
a group you removed. occ penpot_sync:set-groups is a convenience that does the
right thing on either backend, not a separate source of truth — which also means
two of these integrations can point at the same folder without fighting over its
sharing.
The mode is immutable, exactly as it is in both sibling integrations. It decides whether the app holds the bytes, so flipping it on a live mapping would either delete every downloaded archive under it or export every file at once. To change it, remove the mapping and map the team again — the same designs come back, by the same ids, into the same folder.
Non-Penpot content inside a mapped folder is expected and never touched. The pull only acts on files it recognizes by their metadata.
Each mapping carries the Nextcloud groups its folder is shared with, exactly as the n8n and Grafana integrations do — same control, same meaning, same defaults, so configuring all three is the same act each time. Groups start empty and are opt-in.
Team Folders are optional, not a hard dependency. When the groupfolders app
is installed, a mapped team becomes a real Team Folder — the closest match to
Penpot's own model, where the team is the access boundary. Without it, the app
falls back to an ordinary folder shared to the mapping's groups, and everything
else behaves identically (folder metadata works the same on both). Note that Team
Folder creation is admin-only by default in Nextcloud, so mapping a team is an
admin action unless delegation has been configured.
The groups and Team Folder settings persist today and are honoured when the pull provisions the folder (not yet built) — the same "saved now, applied later" state the Grafana integration ships them in.
Laid out to match the n8n and Grafana integrations, so an admin who has configured one already knows where to look:
| Panel | What's in it |
|---|---|
| Instance | The Penpot base URL and the service-account token. |
| Sync Settings | Whether the pull runs on a schedule, and how often. |
| Team mappings | One card per mapped team — folder name, mode, sharing. |
| Sync Actions | Every button in the section: Test connection, and (later) bulk sync and purge. |
Each user also gets a personal Penpot section holding their own optional access token, used only to attribute their changes in Penpot's history.
get-project-files returns every file's revn (revision number) and modifiedAt
for a whole project in one response. So the pull compares revisions against a
listing it already has, and only exports files that actually changed and are in
sync mode:
per team: 1 × get-projects + 1 × get-project-files per project
per file: an export ONLY if mode == sync AND revn moved
A 100-file team where nothing changed costs a handful of API calls and zero bytes — while still reconciling every rename and project move, because names and project ids come back in that same listing.
Renames and moves in Penpot are reflected on the next pull. Renames and moves in Nextcloud are yours to make freely, as long as a file stays under a folder mapping to its real project.
New → Penpot design, the same affordance the sibling apps offer. It appears only where the target project is unambiguous:
| Where you are | Where the design is created |
|---|---|
| Inside a project folder | That project |
| At a Team Folder's root | That team's Drafts |
| In a plain folder under a team | That team's Drafts |
| Anywhere with no team above it | The action isn't offered |
Penpot calls a design that belongs to a team but sits in no project a draft. It has one Drafts bucket per team, because a flat system has nowhere else to put an unfiled design.
We never create a "Drafts" folder. Being in Drafts simply means "under a team, but not under a project folder" — which falls straight out of the nearest-ancestor rule. So this all works, and all of it is Drafts on Penpot's side:
Northwind/
├── Inbox/2026/sketch.penpot → Drafts
├── Scratch/idea.penpot → Drafts
├── quick-thing.penpot → Drafts
└── Acme/ ← a project folder 🏷
└── Homepage.penpot → the "Acme" project
Nextcloud is more expressive than Penpot here, for free — one flat bucket on their side can be any folder tree you like on ours.
And filing a draft is just a drag. Move a file from anywhere under the team into a project folder and the design moves into that project in Penpot. Drag it back out and it returns to Drafts. The gesture you already know is the Penpot operation.
Copying a mirrored file never creates a design in Penpot. Someone dragging a file with Ctrl held is organising files, not authoring work — and a Penpot design appearing out of nowhere is something a whole team would see.
A copy made under a mapped project is stripped of its penpot_id and becomes
ordinary untracked content — keeping the id would give the pull two files claiming
to be the same design. A copy made outside every mapping keeps the id as a
historical record of where the archive came from, which is what makes a later
restore possible.
(Penpot does have a real duplicate-file endpoint, and it works. A deliberate
"Duplicate in Penpot" action would be one cheap call — recorded as available, not
adopted.)
Copying a project folder is refused. Three reasons, any one sufficient: the
copy would claim the same project id for its whole subtree; Nextcloud's automatic
My Stuff (2) suffix instantly breaks the name-matching rule, and "fixing" it by
renaming would rename the original Penpot project; and on a cluster running all
three sibling apps, one folder can carry Penpot, n8n and Grafana mappings at
once — a folder copy asks three independent apps to agree on what a duplicate
means. Copying ordinary folders and individual files is unaffected.
Project folders and their Penpot projects always share a name — the two are never allowed to diverge. Rename the project in Penpot and the folder follows on the next pull; rename the folder in Nextcloud and the project follows immediately. Position stays yours; only the name is pinned.
That invariant is what makes the project tag meaningful: a tagged folder named "Acme" is the Penpot project "Acme", at any depth, with no ambiguity.
Renaming a project folder is a genuinely different operation from renaming a file
— different Nextcloud event, different Penpot endpoint, no file extension to
handle — which is why the two are specified apart, in
projects/rename.feature and
designs/rename.feature.
One caveat runs backwards from expectation. Penpot's naming rules are looser
than Nextcloud's: it accepts essentially any non-empty string, including /,
which can never be a folder name. So a project called Has/Slash gets a sanitised
folder name and the app tells you the names couldn't match — the project id stays
authoritative. Going the other way, anything you can name a folder, Penpot will
accept.
Files are different. Renaming a design in Penpot renames the mirror on the next pull, in both modes, with no export needed. Whether renaming a file in Nextcloud propagates back is a genuine open decision — a file's name is cosmetic, where a project folder's name is identity-bearing. If it's ratified, the behavior is already settled: your personal token attributes it to you, and a failure leaves your local rename standing rather than reverting your work.
Tag a sync file with the app's ignore marker and this app takes its hands off:
never refreshed, never renamed, never moved, never pruned — even if the design
is deleted in Penpot. The archive stays yours.
This is the same state as moving a file out of every mapped folder — one mechanism, two entrances. Either way, nothing is deleted in Penpot. "Taken out of Penpot" describes the mirroring relationship ending, not a remote deletion.
Ignoring is refused on link files: a link file holds no archive, so an
"ignored link" is a pointer to something nobody is tracking — it looks like a
backup and isn't one.
"Restore" can mean several different things. The app picks the best path available and tells you which one it used — best first:
| # | Situation | What you get back | |
|---|---|---|---|
| 1 | Only the Nextcloud file was deleted | Everything — the design never moved | ✅ |
| 2 | It's in Penpot's trash (~7 days) | Everything — id, revision, history, links | ✅ |
| 3 | Deleted in Penpot, you have a sync archive |
The design, not its id or history | not yet |
| 4 | Deleted in Penpot, rescued by a final snapshot | The design, not its id or history | not yet |
| 5 | A link whose design vanished over a week ago |
Nothing | — |
Rows 1 and 2 need no thought and no clicks: just restore the file from your Nextcloud trash. The app works out which case it is and does the least destructive thing that applies. If the design never left Penpot, nothing is sent at all; if it's in Penpot's trash, the app takes it back out — with its id, revision, history and links intact — and confirms by re-reading, because Penpot's restore command reports success for ids it did not restore. Either way you end up with one mirror, not two, and the next pull leaves it alone.
Rows 3 and 4 are best-effort and not built yet: a deleted Penpot design cannot be resurrected at its original id, verified against a live instance, so importing returns the artwork rather than the file — same name, pages and assets, new identity, no edit history. That's a trade worth making, but it's yours to make, so it needs a confirmation step rather than a listener. Until it lands, the app tells you when you're in this case instead of failing quietly: the design is gone from Penpot and your file is the only copy of it.
Row 5 is the only real loss, and the app works hard to avoid it. When a pull
notices a link file's design was deleted, it takes a final snapshot first —
Penpot still lets us export a deleted design for about a week — writes that
archive into the file, and then moves it to the trash. You end up holding a
real .penpot file instead of a dead pointer.
Penpot has its own safety net, and it's better than ours. Deleting a design in Penpot doesn't erase it immediately — Penpot retains the data for roughly 7 days before a purge worker removes it. That grace period isn't reachable through the API, so this app can't drive it, but if you deleted something recently, recovering it in Penpot's own UI keeps the id, the links, and the history. The app says so rather than quietly offering you the worse option.
Each Nextcloud gesture gets the Penpot operation with the same reversibility — that symmetry is the whole design:
| You do | Penpot does | Get it back by |
|---|---|---|
| Delete a mirror (→ your trash) | The design goes to Penpot's trash, ~7 days | Restoring the file |
| Restore it from your trash | The design comes back out, losslessly | — |
| Empty your trash | The design is permanently deleted | Nothing. This is the irreversible one |
An earlier version of this app kept the delete purely local, on the belief that Penpot's trash was unreachable by API. It isn't — and once that's true, "purely local" stops being the safe choice and starts being the surprising one: someone who deletes a design in Nextcloud and finds it still in Penpot hasn't been protected, they've been ignored.
Restoring inside the window costs nothing. The app calls Penpot's own restore and the design comes back exactly as it was — verified against a live instance: same id, same revision, deep links working again.
It does not trust the reply. Penpot's restore answers "success" for ids it did not restore, and it answers before its own transaction settles, so the app re-reads the design's project listing — the same listing the sync reads — and calls the restore a second time if the design is not in it yet. That is the difference between your file coming back and your file coming back for ninety seconds until the next sync tidies it away again.
There is no trash-bin setting to configure. An earlier design built a parallel "trash project" inside a service account's team, on the same mistaken belief. Penpot's own trash preserves strictly more, with no configuration and without a design vanishing into a robot's private team.
Emptying your trash is the irreversible one, and it has a guard. Penpot's
permanently-delete-team-files does not check that a design is in the trash —
proven live on a design that had been restored, which it destroyed anyway. So the
app reads Penpot's trash listing first and passes on only ids that come back in
it. If someone restored the design in Penpot in the meantime, emptying your trash
leaves it alone.
sync file holds a
real archive and a link holds none, so if a link's design is deleted in Penpot
the pull takes a final snapshot on its way out
rather than leaving you a dead pointer. Rebuilding a design from that archive
is the one restore path still to come — see rows 3 and 4 above.
Deleting a link currently behaves like deleting a sync — the design goes
to Penpot's trash, and restoring the file brings it back. The intended end state
is different: a link holds no content, so trashing one should be a visibility
choice that Penpot never hears about, with the trashed file itself acting as the
"hidden" marker. That needs the pull to read your trash before it recreates a
mirror, or a dismissed link would simply reappear on the next run — so the two
land together, in a later release, and until then the delete is uniform.
The sync notices on its next run and moves your mirror to the Nextcloud trash — never a hard delete, and never anything else. Three things are worth knowing about that, in order of how likely they are to surprise you.
Nextcloud never empties its own trash because Penpot did. Even a design permanently deleted in Penpot leaves your mirror sitting in the trash, with whatever archive it had. The tidy-looking alternative — mirror Penpot's purge too — is a trap: the two trashes expire on their own schedules (Penpot's is about 7 days and not configurable, yours might be 30), so every design that quietly ages out of Penpot's trash would take your last copy with it, on a schedule nobody chose. Emptying your trash stays your decision.
It goes to the trash of whoever owns the folder. The sync runs as the account that owns the mapped folder — the service account, for a shared Team Folder — so that is whose trash the mirror lands in. This is ordinary Nextcloud behaviour for shared files rather than anything this app invents: the owner's delete fills the owner's trash. If you are a member of a shared design folder, you will see the file disappear and find nothing in your own trash. Ask whoever owns the folder, or check the account the mapping was created under.
Want a copy that no Penpot deletion can ever touch? Move the file out of
the mapped folder first. The sync only ever prunes inside folders it manages,
so a design parked anywhere else is yours permanently — and if it is in sync
mode it is a real archive, not a pointer. That is the honest way to keep
something before deleting it in Penpot.
Penpot's transport has more ways to fail than a plain REST call — export-binfile
and import-binfile are both SSE streams, in Transit encoding, and the actual
bytes come from a second authenticated request. HTTP 200 does not mean success
here; an error arrives as an event inside a 200 response.
The rules that follow from that:
- A failed export or download keeps the existing mirror, never truncating it.
- Archives are written atomically — a file is the old version or the new one, never a half-written ZIP.
- Pruning requires a clean listing. A failed listing looks exactly like "everything was deleted." An expired token, a network blip, or a lost team invitation never prunes anything.
- A failed write leaves your local change standing and reports the divergence.
Mirrored files get a custom Penpot mimetype and icon rather than showing as generic archives, and expose their state over WebDAV:
| Property | What it is |
|---|---|
nc:metadata-penpot_id |
The Penpot design id — stable across renames and moves |
nc:metadata-penpot_revision |
Penpot's revn + modifiedAt, the drift signal |
nc:metadata-penpot_mode |
sync or link |
Folders carry penpot_project_id / penpot_team_id the same way. All of it is
read-only over DAV — the sync engine owns these properties.
The mimetype is application/vnd.penpot, and it carries no +json / +zip
suffix on purpose: a sync mirror really is a ZIP archive while a link mirror
holds nothing at all, so either suffix would be wrong for half your files.
Removing the app reverts the registration and leaves Nextcloud as it found it.
A link file is empty — zero bytes. Everything that identifies it (the
design id, the revision it reflects, its mode) lives in the metadata above, so a
body would only be a second copy of the same facts, free to drift from the first.
It is deliberately not a small placeholder archive either: that would be
indistinguishable from a real export, which is how you end up trusting a backup
that was never taken. occ penpot_sync:status tells you which a file is.
Clicking a mirrored .penpot file opens the live design in Penpot. That is the
only opener it gets — unlike this app's siblings for n8n and Grafana, there is no
"edit as text" action, in any mode, for any file. A .penpot archive is opaque
nested design data; there is nothing coherent to hand-edit and no way to
re-import it if there were.
sync and link files open identically. The mode decides whether the
archive is stored on your Nextcloud, never whether the design can be opened.
The link is built from the design id the file already carries, so it keeps working after you rename the file or drag it somewhere else — including out of its mapped folder entirely. The one case where the action disappears is a file whose design was deleted in Penpot: that id is permanently dead, so the app hides the action rather than send you to a 404.
Two cards, deliberately separate:
- Instance — the base Penpot URL. Admin-scoped, no credential field.
- Service account — the required token that does all mirroring. Stored encrypted, never echoed back.
A connection test distinguishes unset from rejected, and names the required Penpot instance flag if it's missing.
Each user can store their own Penpot access token. Optional everywhere except personal projects. Clearing it degrades attribution; it never stops team mirroring and never deletes anything.
| Flag | Why |
|---|---|
enable-access-tokens |
Lets a Penpot user mint the token this app authenticates with. Off by default upstream. |
If Nextcloud reaches Penpot at a private or in-cluster address — a Kubernetes
service name, a LAN IP, localhost — Nextcloud's SSRF guard blocks the request
before it leaves:
Host "penpot.cloud.svc.cluster.local" violates local access rules
occ config:system:set allow_local_remote_servers --value=true --type=booleanocc penpot_sync:probe reports this case by name rather than as a generic
connection failure, so you should not have to guess. Not needed when Penpot is
on a public hostname.
enable-webhookswas expected to be a second requirement, as a fast-path trigger for the pull. It isn't currently part of the design: webhook creation works, but delivery has never been observed — two confirmed mutations against a validated webhook produced zero deliveries. Until that's explained, the scheduled pull is the only trigger.
Early development, pre-alpha, version 0.1.0.
Implemented: the complete admin surface — Instance (URL + service-account
token), Sync Settings, Team mappings, and Sync Actions — plus a personal
per-user token page. Every control persists and has an occ twin
(set-url, set-token, test-connection, list-teams, add-mapping,
list-mappings, remove-mapping, set-personal-token, show-config, probe).
On top of that, the mirror itself: sync pull walks a mapped team into a
plain Nextcloud folder, status inspects any node (metadata, resolved
membership, and whether the file holds a real archive or a pointer), a move
between project folders is either refused or propagated to Penpot, and a design
deleted in Penpot has its mirror snapshotted and moved to the Nextcloud trash.
Covered by unit tests and by Behat scenarios that install the app on a real
Nextcloud and drive the CLI against a real Penpot — including an export asserted
to land real ZIP bytes on disk.
The Files-app surface has opened: a mirrored design carries its own file type and
icon, and "Open in Penpot" is the default click — a deep link built from the
id the file already carries, so it survives being renamed and moved. It is the
only opener a .penpot file gets; there is deliberately no "edit as text".
Not implemented: creating designs from Nextcloud, the ignore and restore
actions, the mode pills, refusing to download a link file as though it were an
archive, adopting a mirror back out of the Nextcloud trash, and personal
projects. The scheduled pull is configurable but does not yet run.
The saga/ is the authoritative "where are we" record, ahead of
this README and the feature files.
Chapter 1: First Contact is the API survey and
the decisions it forced — read §6.18–§6.48 first if you want the decisions rather
than the survey that produced them, and its closing section for what's settled,
what's open, and where to build next. Chapter 2: The
Colony is what has actually been built, course by
course, and its table is the honest map of what is done and what is next.
The specs are the requirements, read before any code lands.
| Folder / file | What it covers |
|---|---|
connection/ |
|
admin.feature |
Live. The connection details as one form, and a failure that names the field. |
personal.feature |
A user's own token — attribution, and their personal team. |
sync-now.feature |
Live. The instance-wide sync: what every mapped folder holds once it has run. |
team-mapping/ |
|
create.feature |
Live. Mapping a team, and every value a mapping may not take. |
view.feature |
What a mapping resolves to, and why a rename upstream does not move it. |
manage-groups.feature |
Live. The one field of a mapping that is editable. |
delete.feature |
Tearing a mapping down; Penpot is never contacted. |
sync-now.feature |
Live. The card's own button — one mapping, on demand. |
designs/ |
|
create.feature |
New → Penpot design, and Drafts semantics. |
view.feature |
Looking at a mirror: its file type, and the metadata it publishes. |
edit.feature |
A design's content changing in Penpot, and what the mirror does about it. |
copy.feature |
Copies never create designs. |
move.feature |
Free nesting; a link cannot leave its project. |
rename.feature |
Live. Both name guards, both directions. |
delete.feature |
Penpot's trash, the 7-day grace period, and the one irreversible gesture. |
restore.feature |
Putting a design back, and what it cannot recover. |
purge.feature |
Reset the Nextcloud side without touching Penpot. |
open-with.feature |
Open in Penpot — no text-editor fallback, ever. |
projects/ |
|
create.feature |
How a folder becomes a project, and the penpot tag that marks one. |
view.feature |
Live. Telling a project folder from an ordinary one. |
copy.feature |
Why copying a project is refused rather than half-done. |
move.feature |
Where a project folder may and may not be dragged. |
rename.feature |
Live. A project changing name, and the name guards. |
delete.feature |
Deleting a project — one call, not one per design. |
restore.feature |
Bringing a project back whole, and the one case that cannot be. |
| top level | |
lifecycle.feature |
App enable, disable and removal — including the mimetype it registers and reverts. |
The folders are the nouns. A design and a project are two Penpot objects with
different calls, different failure modes and different blast radii, so each gets
its own verbs; a mapping and the connection are configuration rather than
content, so they get theirs. features/README.md explains the split.
Deliberately not ported from either sibling: tag-sync.feature and
reserved-tags.feature (Penpot has no tags at all).
See CONTRIBUTING.md for process and AGENTS.md for a cold-start orientation.
This is a community integration and is not affiliated with, endorsed by, or sponsored by Penpot (Kaleidos Ventures SL). "Penpot" and the Penpot logo are trademarks of their respective owner, used here only to identify the service this app integrates with.