Package Penpot 2.18.3 for Nix with NixOS module; afk devShell
Vendored from debling/nix-penpot (MIT). Fixes on top: - pnpm fetchers: drop --config.fetch-* flags (UND_ERR_INVALID_ARG on pnpm 11.27) and pin real store hashes (frontend x3, exporter, mcp) - deps-lock.json: add transitive funcool/beicon2 v2.0 so fake-git resolves potok2's pin during the shadow-cljs classpath build - expose frontend pnpm stores via passthru for rehashing - x86_64-linux only; devShell ships afk, jj, git
This commit is contained in:
commit
08b5496980
21 files changed
+7840
No files matched your search
@@ -0,0 +1,5 @@
|
||||
.direnv/
|
||||
/result
|
||||
/result-*
|
||||
/penpot
|
||||
/penpot-vm.qcow2
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Denilson dos Santos Ebling
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,143 @@
|
||||
# penpot-nix
|
||||
|
||||
Nix packages and a NixOS module for self-hosting [Penpot](https://penpot.app)
|
||||
(pinned to release **2.18.3**). Every component is built from upstream
|
||||
source: the Clojure API backend, the static frontend (served by nginx), the
|
||||
headless-Chromium exporter, the MCP server and the Rust/WASM renderer.
|
||||
One module wires the stack together with PostgreSQL and Valkey, either
|
||||
provisioned locally or pointed at infrastructure you already run.
|
||||
|
||||
Derived from [debling/nix-penpot](https://github.com/debling/nix-penpot)
|
||||
(MIT, see [LICENSE](LICENSE)). `x86_64-linux` only.
|
||||
|
||||
## Packages
|
||||
|
||||
| Attribute | Output |
|
||||
|-----------------------------|------------------------------------------------------------------------|
|
||||
| `penpot-backend` | `bin/penpot-backend` (JVM wrapper), `bin/penpot-manage` |
|
||||
| `penpot-frontend` | `dist/` static bundle (+ `plugins/`, `plugins/mcp/`), `config.js` |
|
||||
| `penpot-exporter` | `bin/penpot-exporter` (node + nixpkgs chromium) |
|
||||
| `penpot-mcp` | `bin/penpot-mcp` |
|
||||
| `penpot-render-wasm` | `render-wasm.{js,wasm}`, `shared.js`, `worker/render.js` (frontend) |
|
||||
| `penpot-render-wasm-export` | export flavor of the renderer (unused on 2.18.x) |
|
||||
|
||||
The same packages are exposed as an overlay (`overlays.default`:
|
||||
`pkgs.penpot-backend`, `pkgs.penpot-frontend`, `pkgs.penpot-exporter`,
|
||||
`pkgs.penpot-mcp`). The module's package defaults resolve through the
|
||||
overlay, so it is required.
|
||||
|
||||
## Deploying on NixOS
|
||||
|
||||
```nix
|
||||
# flake.nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
penpot-nix.url = "git+https://<this repo>";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, penpot-nix }: {
|
||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
./configuration.nix
|
||||
{ nixpkgs.overlays = [ penpot-nix.overlays.default ]; }
|
||||
penpot-nix.nixosModules.default
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```nix
|
||||
# configuration.nix
|
||||
{
|
||||
services.penpot = {
|
||||
enable = true;
|
||||
publicUri = "https://penpot.example.com";
|
||||
secretKeyFile = "/run/secrets/penpot-secret-key";
|
||||
};
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 ];
|
||||
}
|
||||
```
|
||||
|
||||
That is a complete deployment: PostgreSQL and Valkey are provisioned
|
||||
locally, the nginx vhost is generated, exporter and MCP run behind it.
|
||||
Secrets are files loaded through systemd credentials; generate the master
|
||||
key once (`openssl rand -base64 64 | tr -d '\n'`) and manage it with
|
||||
agenix/sops-nix.
|
||||
|
||||
Commonly tuned options (all documented inline in
|
||||
[`modules/penpot.nix`](modules/penpot.nix)):
|
||||
|
||||
| Option | Default | What it does |
|
||||
|----------------------------------|-----------------------------|-------------------------------------------------------------|
|
||||
| `publicUri` | – (required) | Public URL of the instance |
|
||||
| `secretKeyFile` | – (required) | Master secret key file |
|
||||
| `flags` | `[ "enable-prepl-server" ]` | Upstream feature flags |
|
||||
| `admins` | `[ ]` | Emails granted admin on login |
|
||||
| `database.createLocally` | `true` | Local PostgreSQL, or set `database.uri` for an external one |
|
||||
| `redis.createLocally` | `true` | Local Valkey, or set `redis.uri` |
|
||||
| `storage.backend` | `"fs"` | `"fs"` or `"s3"` (+ endpoint/bucket/keys) |
|
||||
| `smtp.*` / `mailpit.enable` | off | Outgoing email, or a local throwaway SMTP sink |
|
||||
| `exporter.enable` / `mcp.enable` | `true` | Toggle the auxiliary services |
|
||||
| `nginx.forceHttps` | `false` | Force HTTPS (configure TLS yourself) |
|
||||
|
||||
### Users and admins
|
||||
|
||||
No default credentials. Register through the UI, or on the backend host:
|
||||
|
||||
```bash
|
||||
penpot-manage create-profile -n "Jane Doe" -e jane@example.org -p 'secret'
|
||||
```
|
||||
|
||||
Admin rights come from `services.penpot.admins = [ "jane@example.org" ];`.
|
||||
|
||||
## Trying it in a VM
|
||||
|
||||
```bash
|
||||
nix build .#nixosConfigurations.penpot-vm.config.system.build.vm
|
||||
./result/bin/run-penpot-vm-vm
|
||||
```
|
||||
|
||||
Host port 8080 → nginx, 8025 → mailpit UI. `curl http://127.0.0.1:8080/readyz`
|
||||
once the backend is up (~1–2 min). Needs ~3 GB RAM.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
direnv allow # or: nix develop — devShell ships the afk harness, jj, git
|
||||
nix flake check --no-build
|
||||
nix build .#checks.x86_64-linux.penpot-vm-test # end-to-end VM test
|
||||
nix fmt
|
||||
```
|
||||
|
||||
Development runs under [afk](https://github.com/DavHau/afk) (`afk` is on
|
||||
PATH in the devShell).
|
||||
|
||||
## Updating Penpot
|
||||
|
||||
1. Bump `penpot.url` **and** `penpotVersion` in `flake.nix` (upstream's
|
||||
`package.json` version is stale, so it is pinned by hand).
|
||||
2. Regenerate `deps-lock.json` with clj-nix from a writable copy of the
|
||||
penpot source with the `RELEASE` pins from `packages/frontend.nix`
|
||||
(`pinDepsEdn`) applied:
|
||||
```bash
|
||||
nix run github:jlesquembre/clj-nix/<pinned-rev>#deps-lock -- \
|
||||
--deps-include backend/deps.edn --deps-include common/deps.edn \
|
||||
--deps-include frontend/deps.edn --deps-include exporter/deps.edn \
|
||||
--alias-exclude outdated --alias-exclude jvm-repl
|
||||
```
|
||||
3. Rehash the fixed-output derivations (set to `lib.fakeHash`, build, copy
|
||||
`got:`): `penpot-frontend.{frontendPnpmDeps,pluginsPnpmDeps,mcpPnpmDeps}`,
|
||||
`penpot-exporter.pnpmDeps`, `penpot-mcp.pnpmDeps`,
|
||||
`penpot-backend.builtinTemplates` (after `nix flake update penpot-files`),
|
||||
and `cargoDeps` / Rust / Skia hashes in `packages/render-wasm.nix` if
|
||||
`render-wasm/Cargo.lock`, the Rust version or the Skia URL changed.
|
||||
4. Rebase `packages/patches/*` if `frontend/src/app/plugins/library.cljs`
|
||||
moved.
|
||||
|
||||
## License
|
||||
|
||||
Nix code: MIT ([LICENSE](LICENSE)). Penpot: MPL-2.0. The remaining stack
|
||||
(PostgreSQL, Valkey, Chromium, …) ships under its own licenses.
|
||||
+3983
File diff suppressed because it is too large.
Load diff
Generated
+347
@@ -0,0 +1,347 @@
|
||||
{
|
||||
"nodes": {
|
||||
"afk": {
|
||||
"inputs": {
|
||||
"llm-agents": "llm-agents",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"superpowers": "superpowers",
|
||||
"wrappers": "wrappers"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792156344,
|
||||
"narHash": "sha256-A9FdczKIes4uhqbpEh1yHY6RI6V2UqAou22SBgLDY/Y=",
|
||||
"owner": "DavHau",
|
||||
"repo": "afk",
|
||||
"rev": "c1cc2da48640276ed39fd9035aef5005577cdde4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "DavHau",
|
||||
"repo": "afk",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"bun2nix": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
"afk",
|
||||
"llm-agents",
|
||||
"flake-parts"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"afk",
|
||||
"llm-agents",
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": [
|
||||
"afk",
|
||||
"llm-agents",
|
||||
"systems"
|
||||
],
|
||||
"treefmt-nix": [
|
||||
"afk",
|
||||
"llm-agents",
|
||||
"treefmt-nix"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790001035,
|
||||
"narHash": "sha256-1/SLBjSYBzDdDhvLQ83wrzBakyS/xNGgtxHGNuveOEQ=",
|
||||
"owner": "Mic92",
|
||||
"repo": "bun2nix",
|
||||
"rev": "07a5bfc8ac36c5370199343fa620b69f63186e33",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Mic92",
|
||||
"ref": "llm-agents",
|
||||
"repo": "bun2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"clj-nix": {
|
||||
"inputs": {
|
||||
"devshell": "devshell",
|
||||
"nix-fetcher-data": "nix-fetcher-data",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1776760934,
|
||||
"narHash": "sha256-WVzvTigFwWcJ0VkAFMzA3jE8kbHGahCJ0FJFqVYhPqM=",
|
||||
"owner": "jlesquembre",
|
||||
"repo": "clj-nix",
|
||||
"rev": "2b1290ee56e9bbd50e9b5874c985d34ad2f1b458",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "jlesquembre",
|
||||
"repo": "clj-nix",
|
||||
"rev": "2b1290ee56e9bbd50e9b5874c985d34ad2f1b458",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devshell": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"clj-nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768818222,
|
||||
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"afk",
|
||||
"llm-agents",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790898470,
|
||||
"narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1719745305,
|
||||
"narHash": "sha256-xwgjVUpqSviudEkpQnioeez1Uo2wzrsMaJKJClh+Bls=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "c3c5ecc05edc7dafba779c6c1a61cd08ac6583e9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"llm-agents": {
|
||||
"inputs": {
|
||||
"bun2nix": "bun2nix",
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": [
|
||||
"afk",
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792149628,
|
||||
"narHash": "sha256-z6OJhOCXlLXdP1GdqS+6wLe/blU6KGZFrib+b/nxYE8=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "e85b99217baf13a38fc887b31338b1833f1159c0",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/numtide/llm-agents.nix"
|
||||
},
|
||||
"original": {
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/numtide/llm-agents.nix"
|
||||
}
|
||||
},
|
||||
"nix-fetcher-data": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nixpkgs": [
|
||||
"clj-nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1755022803,
|
||||
"narHash": "sha256-/QtBdVfZlrRJW5enUoWlBE2wrLXJBMJ45X0rZh0jiaU=",
|
||||
"owner": "jlesquembre",
|
||||
"repo": "nix-fetcher-data",
|
||||
"rev": "9da3926b1459d6ff15268072d1c51351b82811b9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "jlesquembre",
|
||||
"repo": "nix-fetcher-data",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792210252,
|
||||
"narHash": "sha256-Mh0PUaIv4nFY+8wAGonaEh8iBzwsmdNlwjVHmFg1Biw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "09063bd1f2b36fd9fd793c36dfade7bd40a93dfc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1717284937,
|
||||
"narHash": "sha256-lIbdfCsf8LMFloheeE6N31+BMIeixqyQWbSr2vk79EQ=",
|
||||
"type": "tarball",
|
||||
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://github.com/NixOS/nixpkgs/archive/eb9ceca17df2ea50a250b6b27f7bf6ab0186f198.tar.gz"
|
||||
}
|
||||
},
|
||||
"penpot": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1791289550,
|
||||
"narHash": "sha256-K5PkVl/3+CTtmFIrHRG3kMzPlkZZA5QYeyXHzGVb9wI=",
|
||||
"owner": "penpot",
|
||||
"repo": "penpot",
|
||||
"rev": "ff7a66f51938b2293260ea27a64e2c4e0833b5e3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "penpot",
|
||||
"ref": "2.18.3",
|
||||
"repo": "penpot",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"penpot-files": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1791873278,
|
||||
"narHash": "sha256-KrfH2lXbbC50kw4T6cPZssk1PcUqKzZ5qHQo0JbxbbE=",
|
||||
"owner": "penpot",
|
||||
"repo": "penpot-files",
|
||||
"rev": "be9b1c33ff7546f2a5246b4c52a96f1183c2fe35",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "penpot",
|
||||
"ref": "main",
|
||||
"repo": "penpot-files",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"afk": "afk",
|
||||
"clj-nix": "clj-nix",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"penpot": "penpot",
|
||||
"penpot-files": "penpot-files"
|
||||
}
|
||||
},
|
||||
"superpowers": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1790359587,
|
||||
"narHash": "sha256-BWPiXoXV+jePP+wn/Z+Af4iehIL7oei00plaWaTzq8s=",
|
||||
"ref": "main",
|
||||
"rev": "8ca22dba9a94f28898bbce59f2537ff4d87c747d",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/obra/superpowers.git"
|
||||
},
|
||||
"original": {
|
||||
"ref": "main",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/obra/superpowers.git"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1791244623,
|
||||
"narHash": "sha256-uuanbkFk8a313Zfrxdcz8Us/yQtaxNnKJtX8v0ZPpd8=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "d36eeed142f88b5a636f2e3856a53c61654cf29b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"afk",
|
||||
"llm-agents",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1791885023,
|
||||
"narHash": "sha256-C3alQ2MEt0zVrUSho5NUDo3jmhbff72++bfwoqT9Omo=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "0d6d739a833ba9a9c93034862e4a7b061a062a1c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"wrappers": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"afk",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783783341,
|
||||
"narHash": "sha256-zoxaqE02t2HdbDnDAAPbEJGFKDEaot52CepH5v39+f0=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "b85926076618a9a57f450df8a660fba5880aeb21",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/lassulus/wrappers"
|
||||
},
|
||||
"original": {
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/lassulus/wrappers"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
description = "penpot-nix — Nix packages and NixOS module for self-hosting Penpot";
|
||||
|
||||
nixConfig = {
|
||||
extra-substituters = [ "https://nix-penpot.cachix.org" ];
|
||||
extra-trusted-public-keys = [
|
||||
"nix-penpot.cachix.org-1:cbXoCSnLa6QmBex5EYzUSsswqSZA6RvJWl0dR+WgOns="
|
||||
];
|
||||
};
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
penpot.url = "github:penpot/penpot/2.18.3";
|
||||
penpot.flake = false;
|
||||
|
||||
# Clojure dependency locking (deps-lock.json) + eval-time dep cache
|
||||
# (mk-deps-cache) + offline git shim (fake-git). Pinned to a main
|
||||
# commit: the only release (0.4.0, 2024) predates our toolchain.
|
||||
# Only the overlay's clojure helpers are used (mk-deps-cache, fake-git,
|
||||
# deps-lock, clj-builder); clj-nix's own apps/devshells stay unevaluated.
|
||||
clj-nix.url = "github:jlesquembre/clj-nix/2b1290ee56e9bbd50e9b5874c985d34ad2f1b458";
|
||||
clj-nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
# Onboarding template files fetched by the backend build. No tags
|
||||
# upstream, so track main; the locked rev makes it immutable. Bump with
|
||||
# `nix flake update penpot-files` (see builtinTemplates in
|
||||
# packages/backend.nix for the rehash procedure).
|
||||
penpot-files.url = "github:penpot/penpot-files/main";
|
||||
penpot-files.flake = false;
|
||||
|
||||
# Development harness: https://github.com/DavHau/afk
|
||||
afk.url = "github:DavHau/afk";
|
||||
afk.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
penpot,
|
||||
penpot-files,
|
||||
clj-nix,
|
||||
afk,
|
||||
}:
|
||||
let
|
||||
systems = [ "x86_64-linux" ];
|
||||
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
|
||||
pkgsFor =
|
||||
system:
|
||||
import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [
|
||||
# Provides mk-deps-cache, fake-git, deps-lock, clj-builder.
|
||||
clj-nix.overlays.default
|
||||
];
|
||||
};
|
||||
|
||||
penpotVersion =
|
||||
# Keep in sync with the `penpot` input pin. Upstream leaves
|
||||
# package.json's version stale ("2.17.0" even for 2.18.3), so
|
||||
# mkPenpotVersion's package.json read lies; pin the real release
|
||||
# instead. Changing this also changes asset version-tag URLs.
|
||||
"2.18.3";
|
||||
in
|
||||
{
|
||||
lib = {
|
||||
mkPenpotVersion =
|
||||
src:
|
||||
let
|
||||
pkg = builtins.readFile (src + "/package.json");
|
||||
pkgVersion = builtins.match ".*\"version\": \"([^\"]+)\".*" pkg;
|
||||
in
|
||||
if pkgVersion != null then
|
||||
builtins.head pkgVersion
|
||||
else if src ? shortRev then
|
||||
src.shortRev
|
||||
else
|
||||
"dev";
|
||||
|
||||
# Restrict a source tree to a set of top-level paths (directories or
|
||||
# files). `paths` entries are matched against the path relative to
|
||||
# `src`, so "backend" keeps "backend" itself and everything below it.
|
||||
#
|
||||
# Type: Path -> [String] -> Path
|
||||
subSrc =
|
||||
src: paths:
|
||||
nixpkgs.lib.cleanSourceWith {
|
||||
src = nixpkgs.lib.cleanSource src;
|
||||
filter =
|
||||
path: _type:
|
||||
let
|
||||
rel = nixpkgs.lib.removePrefix "${toString src}/" (toString path);
|
||||
in
|
||||
rel == "" || nixpkgs.lib.any (p: rel == p || nixpkgs.lib.hasPrefix "${p}/" rel) paths;
|
||||
};
|
||||
};
|
||||
|
||||
overlays.default = final: _prev: {
|
||||
penpot-backend = self.packages.${final.stdenv.hostPlatform.system}.penpot-backend;
|
||||
penpot-frontend = self.packages.${final.stdenv.hostPlatform.system}.penpot-frontend;
|
||||
penpot-exporter = self.packages.${final.stdenv.hostPlatform.system}.penpot-exporter;
|
||||
penpot-mcp = self.packages.${final.stdenv.hostPlatform.system}.penpot-mcp;
|
||||
};
|
||||
|
||||
packages = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
callPackage = pkgs.callPackage;
|
||||
penpotRenderWasm = callPackage ./packages/render-wasm.nix {
|
||||
inherit penpot;
|
||||
version = penpotVersion;
|
||||
};
|
||||
in
|
||||
{
|
||||
penpot-backend = callPackage ./packages/backend.nix {
|
||||
inherit penpot penpot-files;
|
||||
version = penpotVersion;
|
||||
inherit (self.lib) subSrc;
|
||||
};
|
||||
penpot-render-wasm = penpotRenderWasm;
|
||||
# Exposed separately so CI/flake-check keeps building the export
|
||||
# flavor (nothing consumes it on the 2.17.x line).
|
||||
penpot-render-wasm-export = penpotRenderWasm.passthru.export;
|
||||
penpot-frontend = callPackage ./packages/frontend.nix {
|
||||
inherit penpot;
|
||||
version = penpotVersion;
|
||||
inherit (self.lib) subSrc;
|
||||
};
|
||||
penpot-exporter = callPackage ./packages/exporter.nix {
|
||||
inherit penpot;
|
||||
version = penpotVersion;
|
||||
inherit (self.lib) subSrc;
|
||||
};
|
||||
penpot-mcp = callPackage ./packages/mcp.nix {
|
||||
inherit penpot;
|
||||
version = penpotVersion;
|
||||
inherit (self.lib) subSrc;
|
||||
};
|
||||
default = self.packages.${system}.penpot-backend;
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules = {
|
||||
default = self.nixosModules.penpot;
|
||||
penpot = import ./modules/penpot.nix;
|
||||
};
|
||||
|
||||
nixosConfigurations.penpot-vm = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
{ nixpkgs.overlays = [ self.overlays.default ]; }
|
||||
self.nixosModules.default
|
||||
./tests/vm.nix
|
||||
];
|
||||
};
|
||||
|
||||
checks = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
in
|
||||
{
|
||||
# Full end-to-end VM test of the module (boots PostgreSQL, Valkey,
|
||||
# backend, nginx, exporter and MCP, then probes every endpoint).
|
||||
penpot-vm-test = pkgs.testers.nixosTest (
|
||||
import ./tests/vm-test.nix {
|
||||
inherit (pkgs) lib;
|
||||
inherit self;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
devShells = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
afk.packages.${system}.afk
|
||||
pkgs.jujutsu
|
||||
pkgs.git
|
||||
pkgs.nixfmt-tree
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
formatter = forAllSystems (system: (pkgsFor system).nixfmt-tree);
|
||||
};
|
||||
}
|
||||
+1210
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
||||
# sfnt2woff / woff2sfnt, Jonathan Kew's reference WOFF <-> sfnt converters.
|
||||
# Required by the Penpot backend for font format conversion, but not packaged
|
||||
# in nixpkgs. Upstream's page (people.mozilla.org/~jkew/woff) is offline, so
|
||||
# this uses the source mirror several distributions rely on.
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchFromGitHub,
|
||||
zlib,
|
||||
}:
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "woff-tools";
|
||||
version = "20091003";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "wget";
|
||||
repo = "woff-tools";
|
||||
rev = "20091003";
|
||||
hash = "sha256-GDF07R4dNfVz9S1bWkDndq4Zy916kkuaWu6nXYlZRkU=";
|
||||
};
|
||||
|
||||
buildInputs = [ zlib ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 sfnt2woff $out/bin/sfnt2woff
|
||||
install -Dm755 woff2sfnt $out/bin/woff2sfnt
|
||||
install -Dm644 LICENSE $out/share/licenses/woff-tools/LICENSE
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Convert TrueType/OpenType (sfnt) fonts to WOFF format and back";
|
||||
homepage = "https://github.com/wget/woff-tools";
|
||||
# woff.c carries the classic Mozilla tri-license.
|
||||
license = with lib.licenses; [
|
||||
mpl11
|
||||
gpl2Plus
|
||||
lgpl21Plus
|
||||
];
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "sfnt2woff";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
# Penpot backend (Clojure API server), packaged as the upstream source
|
||||
# uberjar. Reproduces `backend/scripts/build`: a non-AOT uberjar executed
|
||||
# through `clojure.main`, shipped with the log4j2 config and the onboarding
|
||||
# template files mapped from the pinned penpot-files flake input.
|
||||
#
|
||||
# The uberjar is repacked by packages/jar-strip-deterministic.py (in the penpot
|
||||
# checkout), replacing stripJavaArchivesHook, whose reorder is O(n^2) and took
|
||||
# ~32 minutes on this jar.
|
||||
#
|
||||
# Output contract:
|
||||
# $out/share/penpot-backend/{penpot.jar,log4j2.xml,version.txt,manage.py}
|
||||
# $out/share/penpot-backend/builtin-templates/
|
||||
# $out/bin/penpot-backend (java wrapper, cwd = share dir)
|
||||
# $out/bin/penpot-manage (manage.py CLI via python3 + tabulate)
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
makeWrapper,
|
||||
callPackage,
|
||||
clojure,
|
||||
jdk25_headless,
|
||||
babashka,
|
||||
imagemagick,
|
||||
fontforge,
|
||||
woff2,
|
||||
fontconfig,
|
||||
util-linux,
|
||||
python3,
|
||||
penpot,
|
||||
penpot-files,
|
||||
version,
|
||||
subSrc,
|
||||
}:
|
||||
|
||||
let
|
||||
src = subSrc penpot [
|
||||
"backend"
|
||||
"common"
|
||||
"CHANGES.md"
|
||||
];
|
||||
|
||||
# Replaces stripJavaArchivesHook: a stdlib-python deterministic repack of the
|
||||
# uberjar, replicating strip-nondeterminism's jar/zip handlers (see the
|
||||
# script's docstring for the full list). Its per-member reorder is O(n^2),
|
||||
# which cost ~32 minutes on this jar's 54000 entries; this does it in ~20 s.
|
||||
jarNormalizer = ./jar-strip-deterministic.py;
|
||||
in
|
||||
let
|
||||
# sfnt2woff/woff2sfnt are not in nixpkgs; see backend-woff-tools.nix.
|
||||
woff-tools = callPackage ./backend-woff-tools.nix { };
|
||||
|
||||
# The clojure CLI with a JDK matching the runtime (upstream run.sh flags
|
||||
# need >= 24; the nixpkgs default CLI bundles JDK 21).
|
||||
clojureJdk25 = clojure.override { jdk = jdk25_headless; };
|
||||
|
||||
magickPolicy = callPackage ./imagemagick-policy.nix { inherit penpot; };
|
||||
|
||||
pythonEnv = python3.withPackages (ps: [ ps.tabulate ]);
|
||||
|
||||
# NOTE: upstream's docker image runs Zulu JDK 26 at 2.17.x; any JDK >= 24
|
||||
# satisfies the run.sh flags, so the nixpkgs JDK 25 is used here. Revisit
|
||||
# when bumping the penpot input.
|
||||
# Binaries the backend shells out to at runtime (image/font processing),
|
||||
# baked into the wrapper's PATH.
|
||||
# util-linux: provides prlimit; upstream runs font-processing subprocesses
|
||||
# under it (app.media.local/exec-font!), see also app.util.shell/prlimit-cmd.
|
||||
runtimeBinPath = lib.makeBinPath [
|
||||
imagemagick
|
||||
fontforge
|
||||
woff2
|
||||
woff-tools
|
||||
fontconfig
|
||||
util-linux
|
||||
];
|
||||
|
||||
# Offline Clojure dependencies: eval-time cache from deps-lock.json
|
||||
# (no fixed-output derivation) + fake-git shim + short-SHA expansion.
|
||||
cljOffline = callPackage ./clj-offline.nix { };
|
||||
|
||||
builtinTemplates = stdenv.mkDerivation {
|
||||
name = "penpot-backend-builtin-templates";
|
||||
src = "${src}/backend";
|
||||
|
||||
nativeBuildInputs = [
|
||||
babashka
|
||||
];
|
||||
|
||||
outputHashAlgo = "sha256";
|
||||
outputHashMode = "recursive";
|
||||
# The file contents come from the pinned penpot-files flake input, so
|
||||
# this hash only changes when that input (or the template list in
|
||||
# onboarding.edn) changes. On a penpot input bump: run
|
||||
# `nix flake update penpot-files` first (a missing file fails the build
|
||||
# loudly), then set to lib.fakeHash, build once, copy the `got:` hash
|
||||
# from the failure message.
|
||||
outputHash = "sha256-b03i34xCfKHie5AjT7is3o9kqIzcSU/nPl7mV1gu+u0=";
|
||||
dontFixup = true;
|
||||
|
||||
dontBuild = true;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
export HOME="$TMPDIR/home"
|
||||
mkdir -p "$HOME"
|
||||
|
||||
# Map onboarding.edn (id, file-uri) pairs to files from the pinned
|
||||
# penpot-files input. EDN-native parsing via babashka; basenames are
|
||||
# URL-decoded (upstream file-uris contain %20 etc.).
|
||||
cat > "$TMPDIR/map-templates.clj" <<'EOF'
|
||||
(require '[babashka.fs :as fs]
|
||||
'[clojure.edn :as edn]
|
||||
'[clojure.string :as str])
|
||||
(import '[java.net URLDecoder])
|
||||
|
||||
(let [[defs-path files-dir dest] *command-line-args*
|
||||
data (edn/read-string (slurp defs-path))]
|
||||
(fs/create-dirs dest)
|
||||
(doseq [{:keys [id file-uri]} data]
|
||||
;; Basename of file-uri, minus any query/fragment; literal `+`
|
||||
;; is protected before URL-decoding (`URLDecoder` maps `+` to
|
||||
;; space, but upstream names use `%20` for spaces).
|
||||
(let [fname (-> file-uri
|
||||
(str/split #"/") last
|
||||
(str/split #"[?#]") first
|
||||
(str/replace "+" "%2B")
|
||||
(URLDecoder/decode "UTF-8"))
|
||||
src-file (fs/file files-dir fname)]
|
||||
(when-not (fs/exists? src-file)
|
||||
(println (format "template file %s (id: %s) not found" fname id))
|
||||
(System/exit 1))
|
||||
(println (format "=> installing %s" id))
|
||||
(fs/copy src-file (fs/file dest id)))))
|
||||
EOF
|
||||
|
||||
bb "$TMPDIR/map-templates.clj" \
|
||||
resources/app/onboarding.edn \
|
||||
"${penpot-files}" \
|
||||
"$out/templates"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "penpot-backend";
|
||||
inherit version src;
|
||||
|
||||
nativeBuildInputs = [
|
||||
clojureJdk25
|
||||
cljOffline.fake-git
|
||||
cljOffline.clj-builder
|
||||
makeWrapper
|
||||
# Repacks the jar deterministically during fixup. Replaces
|
||||
# stripJavaArchivesHook, whose O(n^2) member reorder needs ~32 minutes on
|
||||
# this jar (54000 entries); see jarNormalizer above.
|
||||
python3
|
||||
];
|
||||
|
||||
configurePhase = ''
|
||||
runHook preConfigure
|
||||
|
||||
# Offline dependency resolution from the eval-time clj-nix cache.
|
||||
${cljOffline.setup}
|
||||
|
||||
runHook postConfigure
|
||||
'';
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
(
|
||||
cd backend
|
||||
mkdir -p target/classes
|
||||
echo "$version" > target/classes/version.txt
|
||||
cp ../CHANGES.md target/classes/changelog.md
|
||||
clojure -T:build jar
|
||||
)
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = /* sh */ ''
|
||||
runHook preInstall
|
||||
|
||||
share="$out/share/penpot-backend"
|
||||
mkdir -p "$share" "$out/bin"
|
||||
|
||||
install -Dm644 backend/target/penpot.jar "$share/penpot.jar"
|
||||
install -Dm644 backend/resources/log4j2.xml "$share/log4j2.xml"
|
||||
cp -r "${builtinTemplates}/templates" "$share/builtin-templates"
|
||||
echo "$version" > "$share/version.txt"
|
||||
|
||||
# Deterministic repack (see jarNormalizer). The uber task stamps
|
||||
# entries with wall-clock time, so this can't be skipped.
|
||||
python3 ${jarNormalizer} "$share/penpot.jar"
|
||||
|
||||
# manage.py hardcodes the prepl endpoint as the argparse default; patch
|
||||
# it to honor the PREPL_URI environment variable instead.
|
||||
install -Dm755 backend/scripts/manage.py "$share/manage.py"
|
||||
substituteInPlace "$share/manage.py" \
|
||||
--replace-fail 'import argparse' 'import argparse
|
||||
import os' \
|
||||
--replace-fail 'default="tcp://localhost:6063"' 'default=os.environ.get("PREPL_URI", "tcp://localhost:6063")'
|
||||
|
||||
# Flags mirror backend/scripts/run.template.sh, except
|
||||
# --add-opens=java.base/java.nio (not upstream; needed by
|
||||
# yetti/lettuce for direct NIO buffer access on modern JDKs).
|
||||
# $JAVA_OPTS and $PENPOT_ENTRYPOINT are embedded verbatim by
|
||||
# makeWrapper and expand at runtime, like in the upstream run.sh.
|
||||
makeWrapper "${jdk25_headless}/bin/java" "$out/bin/penpot-backend" \
|
||||
--chdir "$share" \
|
||||
--prefix PATH : "${runtimeBinPath}" \
|
||||
--set MAGICK_CONFIGURE_PATH "${magickPolicy}/etc/ImageMagick-7" \
|
||||
--set-default PENPOT_ENTRYPOINT app.main \
|
||||
--add-flags '-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager' \
|
||||
--add-flags '-Dlog4j2.configurationFile=log4j2.xml' \
|
||||
--add-flags '-XX:-OmitStackTraceInFastThrow' \
|
||||
--add-flags '--sun-misc-unsafe-memory-access=allow' \
|
||||
--add-flags '--enable-native-access=ALL-UNNAMED' \
|
||||
--add-flags '--add-opens=java.base/java.nio=ALL-UNNAMED' \
|
||||
--add-flags '--enable-preview' \
|
||||
--add-flags '$JAVA_OPTS' \
|
||||
--add-flags '-jar penpot.jar -m $PENPOT_ENTRYPOINT'
|
||||
|
||||
makeWrapper "${pythonEnv}/bin/python" "$out/bin/penpot-manage" \
|
||||
--add-flags "$share/manage.py"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit builtinTemplates;
|
||||
version = version;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "Penpot backend API server (Clojure uberjar)";
|
||||
homepage = "https://penpot.app";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "penpot-backend";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Shared offline Clojure dependency wiring, backed by clj-nix.
|
||||
#
|
||||
# `mk-deps-cache` materializes `deps-lock.json` (committed at the repo root)
|
||||
# purely at eval time — `fetchurl` per Maven artifact, `fetchgit` per git
|
||||
# dep — so consumer builds resolve fully offline and deterministically, with
|
||||
# no fixed-output derivation involved.
|
||||
#
|
||||
# Consumers: point tools.deps at the cache (same recipe as clj-nix's own
|
||||
# builder), expand short `:git/sha` pins to full SHAs from the lock, and
|
||||
# keep `fake-git` (a `git` shim answering rev-parse/tag/merge-base from lock
|
||||
# metadata) on PATH for any stray git invocation.
|
||||
{
|
||||
mk-deps-cache,
|
||||
fake-git,
|
||||
clj-builder,
|
||||
}:
|
||||
let
|
||||
cache = mk-deps-cache {
|
||||
lockfile = ../deps-lock.json;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit cache fake-git clj-builder;
|
||||
|
||||
# Shell fragment for consumer preBuild/configure phases. Must run in the
|
||||
# project root (the directory `deps-lock.json` is copied into), before any
|
||||
# `clojure` invocation. Uses a writable HOME with symlinks into the
|
||||
# read-only cache (pnpm and friends need a writable HOME; clojure
|
||||
# resolution follows the symlinks).
|
||||
setup = ''
|
||||
export HOME="$TMPDIR/home"
|
||||
mkdir -p "$HOME"
|
||||
ln -sfn "${cache}/.m2" "$HOME/.m2"
|
||||
ln -sfn "${cache}/.gitlibs" "$HOME/.gitlibs"
|
||||
ln -sfn "${cache}/.clojure" "$HOME/.clojure"
|
||||
# The JVM resolves user.home from /etc/passwd rather than $HOME; force
|
||||
# it so tools.deps resolves from the cache instead of attempting
|
||||
# network downloads.
|
||||
export JAVA_TOOL_OPTIONS="-Duser.home=$HOME"
|
||||
export CLJ_CONFIG="$HOME/.clojure"
|
||||
export CLJ_CACHE="$TMPDIR/cp_cache"
|
||||
export GITLIBS="$HOME/.gitlibs"
|
||||
cp "${../deps-lock.json}" deps-lock.json
|
||||
clj-builder patch-git-sha "$(pwd)"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
# Penpot exporter: the ClojureScript -> Node.js headless export service
|
||||
# (renders designs via Playwright/Chromium, talks to redis + the Penpot
|
||||
# internal API).
|
||||
#
|
||||
# Upstream builds this with a pnpm workspace + shadow-cljs and ships the
|
||||
# `target/` dir of exporter/scripts/build as the runtime bundle. This package
|
||||
# reproduces that bundle with everything materialized offline: node_modules
|
||||
# from a pre-fetched pnpm store and maven/git deps from the eval-time
|
||||
# clj-nix cache, and chromium comes
|
||||
# from nixpkgs instead of `playwright install`. (The render-wasm based
|
||||
# exporter rendering postdates the 2.17.x line this package tracks, so the
|
||||
# wasm artifacts are not consumed here.)
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
runCommand,
|
||||
callPackage,
|
||||
penpot,
|
||||
version,
|
||||
subSrc,
|
||||
nodejs_24,
|
||||
pnpm_11,
|
||||
fetchPnpmDeps,
|
||||
pnpmConfigHook,
|
||||
clojure,
|
||||
jdk25_headless,
|
||||
chromium,
|
||||
makeWrapper,
|
||||
imagemagick,
|
||||
poppler-utils,
|
||||
potrace,
|
||||
netpbm,
|
||||
makeFontsConf,
|
||||
noto-fonts-color-emoji,
|
||||
unifont,
|
||||
liberation_ttf,
|
||||
freefont_ttf,
|
||||
ipafont,
|
||||
wqy_zenhei,
|
||||
tlwg,
|
||||
}:
|
||||
|
||||
let
|
||||
# The exporter workspace plus the `common` cljs sources it compiles in
|
||||
# (exporter/deps.edn and shadow-cljs.edn reference ../common).
|
||||
src = subSrc penpot [
|
||||
"exporter"
|
||||
"common"
|
||||
];
|
||||
|
||||
# exporter/deps.edn's :shadow-cljs alias passes
|
||||
# `--sun-misc-unsafe-memory-access=allow` to the JVM; that option needs
|
||||
# JDK >= 23 while nixpkgs' default clojure bundles JDK 21.
|
||||
clojureJdk25 = clojure.override { jdk = jdk25_headless; };
|
||||
|
||||
# npm dependencies of the exporter workspace, fetched once into a pnpm
|
||||
# store in a fixed-output derivation and materialized offline in the main
|
||||
# build via pnpmConfigHook. Lifecycle scripts are skipped: nothing in this
|
||||
# dependency set needs them for runtime (playwright's postinstall is
|
||||
# informational only), and the playwright browser download is replaced by
|
||||
# the browsers dir below.
|
||||
exporterSrc = subSrc penpot [ "exporter" ];
|
||||
|
||||
# Same storeDir workaround as frontend.nix: 2.18 adds a shared
|
||||
# `storeDir: ../.pnpm-store` to the workspace's pnpm-workspace.yaml, which
|
||||
# breaks the per-workspace fixed-output fetch and the offline install;
|
||||
# strip it in this pre-fetch source copy (and again in the consumer build's
|
||||
# postPatch below). Named "source" so the dir-source unpack lands where
|
||||
# sourceRoot expects it.
|
||||
exporterSrcP = runCommand "source" { } ''
|
||||
mkdir "$out"
|
||||
cp -r "${exporterSrc}"/. "$out"/
|
||||
chmod -R u+w "$out"
|
||||
find "$out" -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
|
||||
'';
|
||||
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
pname = "penpot-exporter";
|
||||
inherit version;
|
||||
src = exporterSrcP;
|
||||
sourceRoot = "source/exporter";
|
||||
pnpm = pnpm_11;
|
||||
# fetcherVersion 4 is required for pnpm 11 (older fetchers are rejected).
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-fGjKhjCEb4+2Nwd5xppow6YfjmgFQjXddHlsSSmr6Eo=";
|
||||
};
|
||||
|
||||
# Offline Clojure dependencies: eval-time cache from deps-lock.json
|
||||
# (no fixed-output derivation) + fake-git shim + short-SHA expansion.
|
||||
cljOffline = callPackage ./clj-offline.nix { };
|
||||
|
||||
magickPolicy = callPackage ./imagemagick-policy.nix { inherit penpot; };
|
||||
|
||||
# Fonts matching the upstream exporter image (docker/Dockerfile.exporter):
|
||||
# emoji, bitmap fallback (covers the cyrillic/scalable X fonts), latin,
|
||||
# gothic, Thai (tlwg) and CJK coverage.
|
||||
fontsConf = makeFontsConf {
|
||||
fontDirectories = [
|
||||
noto-fonts-color-emoji
|
||||
unifont
|
||||
liberation_ttf
|
||||
freefont_ttf
|
||||
ipafont
|
||||
wqy_zenhei
|
||||
tlwg
|
||||
];
|
||||
};
|
||||
|
||||
# Executable path layouts that playwright-core resolves below
|
||||
# $PLAYWRIGHT_BROWSERS_PATH (see its registry: EXECUTABLE_PATHS differ
|
||||
# between linux-x64 and linux-arm64). NOTE: these paths are
|
||||
# registry-version-dependent; re-verify against the vendored
|
||||
# playwright-core when the lockfile bumps playwright.
|
||||
# The headless-shell slot is served by the full chromium build (nixpkgs
|
||||
# does not package the separate headless shell).
|
||||
chromeDir = if stdenv.hostPlatform.isx86_64 then "chrome-linux64" else "chrome-linux";
|
||||
headlessShellDir =
|
||||
if stdenv.hostPlatform.isx86_64 then "chrome-headless-shell-linux64" else "chrome-linux";
|
||||
headlessShellBin =
|
||||
if stdenv.hostPlatform.isx86_64 then "chrome-headless-shell" else "headless_shell";
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "penpot-exporter";
|
||||
inherit version;
|
||||
|
||||
inherit src;
|
||||
|
||||
nativeBuildInputs = [
|
||||
clojureJdk25
|
||||
cljOffline.fake-git
|
||||
cljOffline.clj-builder
|
||||
makeWrapper
|
||||
nodejs_24
|
||||
pnpm_11
|
||||
pnpmConfigHook
|
||||
];
|
||||
|
||||
env.NODE_ENV = "production";
|
||||
|
||||
inherit pnpmDeps;
|
||||
|
||||
dontConfigure = true;
|
||||
# pnpmConfigHook is invoked manually inside installPhase (configure is
|
||||
# disabled), after cd'ing into the exporter workspace.
|
||||
dontPnpmConfigure = true;
|
||||
dontBuild = true;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Offline clojure dependency caches for the shadow-cljs release build.
|
||||
# Runs at the source root so patch-git-sha sees exporter/deps.edn and
|
||||
# common/deps.edn. The JVM resolves user.home from /etc/passwd rather
|
||||
# than $HOME; force it so tools.deps resolves from the eval-time cache
|
||||
# instead of attempting network downloads.
|
||||
${cljOffline.setup}
|
||||
|
||||
cd exporter
|
||||
|
||||
# Strip 2.18's shared pnpm storeDir so the offline install uses the
|
||||
# fetched store (see the note on exporterSrcP).
|
||||
find .. -name pnpm-workspace.yaml -maxdepth 3 -exec sed -i '/^storeDir:/d' {} +
|
||||
|
||||
# Materialize node_modules offline from the pre-fetched pnpm store.
|
||||
pnpmConfigHook
|
||||
|
||||
clojure -M:dev:shadow-cljs release main
|
||||
|
||||
# The compiled app.js carries a literal %version% placeholder
|
||||
# (upstream exporter/scripts/build substitutes it the same way).
|
||||
sed -i "s/%version%/${finalAttrs.version}/g" target/app.js
|
||||
|
||||
# Assemble the runtime dir (the equivalent of upstream's target/).
|
||||
mkdir -p $out/lib/penpot-exporter
|
||||
cp target/app.js target/app.js.map $out/lib/penpot-exporter/
|
||||
cp package.json pnpm-lock.yaml $out/lib/penpot-exporter/
|
||||
# Upstream ships pnpm-workspace.yaml emptied out; keep the same.
|
||||
touch $out/lib/penpot-exporter/pnpm-workspace.yaml
|
||||
cp -a --preserve=links node_modules $out/lib/penpot-exporter/node_modules
|
||||
|
||||
# Pre-materialized playwright browser dir: playwright-core resolves
|
||||
# $PLAYWRIGHT_BROWSERS_PATH/chromium-<rev>/... from its own
|
||||
# browsers.json, so derive the revision from the exporter's tree and
|
||||
# point both chromium flavors at the nixpkgs build.
|
||||
pwRevision=$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).browsers.find(b => b.name === 'chromium').revision" \
|
||||
node_modules/.pnpm/playwright-core@*/node_modules/playwright-core/browsers.json)
|
||||
browsers=$out/lib/penpot-exporter-browsers
|
||||
mkdir -p $browsers/chromium-$pwRevision/${chromeDir}
|
||||
ln -s ${chromium}/bin/chromium $browsers/chromium-$pwRevision/${chromeDir}/chrome
|
||||
mkdir -p $browsers/chromium_headless_shell-$pwRevision/${headlessShellDir}
|
||||
ln -s ${chromium}/bin/chromium \
|
||||
$browsers/chromium_headless_shell-$pwRevision/${headlessShellDir}/${headlessShellBin}
|
||||
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs_24}/bin/node $out/bin/penpot-exporter \
|
||||
--add-flags "$out/lib/penpot-exporter/app.js" \
|
||||
--chdir $out/lib/penpot-exporter \
|
||||
--prefix PATH : ${
|
||||
lib.makeBinPath [
|
||||
imagemagick
|
||||
poppler-utils
|
||||
potrace
|
||||
netpbm
|
||||
nodejs_24
|
||||
]
|
||||
} \
|
||||
--set NODE_ENV production \
|
||||
--set MAGICK_CONFIGURE_PATH "${magickPolicy}/etc/ImageMagick-7" \
|
||||
--set FONTCONFIG_FILE ${fontsConf} \
|
||||
--set PLAYWRIGHT_BROWSERS_PATH $out/lib/penpot-exporter-browsers
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru.version = finalAttrs.version;
|
||||
|
||||
meta = {
|
||||
description = "Penpot exporter service (headless design export via Chromium)";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "penpot-exporter";
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
# Penpot frontend: compiles the upstream ClojureScript/JS monorepo into the
|
||||
# static site served by nginx ($out/dist, mirroring upstream `bundles/frontend`).
|
||||
#
|
||||
# Mirrors upstream `frontend/scripts/build`:
|
||||
# 1. render-wasm artifacts are placed where the wasm cljs glue expects them
|
||||
# (instead of building the wasm in-tree).
|
||||
# 2. MCP plugin is built (its own pnpm workspace).
|
||||
# 3. Official plugins are built (their own pnpm workspace); the plugins
|
||||
# runtime library is built first since the cljs build links it.
|
||||
# 4. shadow-cljs release build of the `main` and `worker` builds.
|
||||
# 5. esbuild bundling of the JS libs.
|
||||
# 6. Asset compilation (styles, sprites, translations, templates, ...).
|
||||
# 7. Finalization: worker render.js cache-busting, rsync into dist,
|
||||
# plugins, version.txt.
|
||||
{
|
||||
lib,
|
||||
callPackage,
|
||||
penpot,
|
||||
version,
|
||||
subSrc,
|
||||
stdenv,
|
||||
stdenvNoCC,
|
||||
runCommand,
|
||||
nodejs_24,
|
||||
pnpm_11,
|
||||
clojure,
|
||||
jdk25_headless,
|
||||
rsync,
|
||||
patchelf,
|
||||
fetchPnpmDeps,
|
||||
pnpmConfigHook,
|
||||
# Named renderWasm (not `wasm`) so pkgs.callPackage cannot auto-fill it
|
||||
# with nixpkgs' unrelated `pkgs.wasm` (the OCaml interpreter).
|
||||
renderWasm ? callPackage ./render-wasm.nix { inherit penpot version; },
|
||||
}:
|
||||
|
||||
let
|
||||
src = subSrc penpot [
|
||||
"frontend"
|
||||
"common"
|
||||
"mcp"
|
||||
"plugins"
|
||||
];
|
||||
|
||||
# The frontend workspace links `../plugins/libs/plugins-runtime` during
|
||||
# install, so plugins/ must be present in its fetch source too.
|
||||
frontendSrc = subSrc penpot [
|
||||
"frontend"
|
||||
"plugins"
|
||||
];
|
||||
pluginsSrc = subSrc penpot [ "plugins" ];
|
||||
mcpSrc = subSrc penpot [ "mcp" ];
|
||||
|
||||
# One pnpm store per workspace (fetched once, reused for the offline
|
||||
# installs in the main build via pnpmConfigHook).
|
||||
#
|
||||
# 2.18 adds a shared `storeDir: ../.pnpm-store` to every workspace's
|
||||
# pnpm-workspace.yaml; our fetches and consumer installs are per-workspace,
|
||||
# so strip that line before the fixed-output fetch (the main build removes
|
||||
# it again in postPatch).
|
||||
patchedPnpmSrc =
|
||||
name: wsrc:
|
||||
# Named "source" (see the note on `mcpSrcP` in mcp.nix): the deps FOD
|
||||
# relies on the dir-source unpack landing at /build/source so that
|
||||
# sourceRoot = "source/<workspace>" resolves.
|
||||
runCommand "source" { } ''
|
||||
mkdir "$out"
|
||||
cp -r "${wsrc}"/. "$out"/
|
||||
chmod -R u+w "$out"
|
||||
find "$out" -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
|
||||
'';
|
||||
|
||||
fetchWorkspaceDeps =
|
||||
pname: sourceRoot: wsrc: hash:
|
||||
fetchPnpmDeps {
|
||||
inherit pname sourceRoot hash;
|
||||
pnpm = pnpm_11;
|
||||
fetcherVersion = 4;
|
||||
src = patchedPnpmSrc pname wsrc;
|
||||
};
|
||||
|
||||
frontendPnpmDeps =
|
||||
fetchWorkspaceDeps "penpot-frontend-frontend" "source/frontend" frontendSrc
|
||||
"sha256-zZxUFuyWLMxIse5AjPwEAmq/fJ9tbGYkcG2kG68HV4U=";
|
||||
pluginsPnpmDeps =
|
||||
fetchWorkspaceDeps "penpot-frontend-plugins" "source/plugins" pluginsSrc
|
||||
"sha256-Tq6aSL0lHFCfQo5H94d+XNGeY2oMNYG2J8YOQhYl3T8=";
|
||||
mcpPnpmDeps =
|
||||
fetchWorkspaceDeps "penpot-frontend-mcp" "source/mcp" mcpSrc
|
||||
"sha256-99ef/Qk3tx+etEqepQQVqAW3XL7T1Kr6qQJ7Uxf1z0Y=";
|
||||
|
||||
# Upstream's deps.edn passes --sun-misc-unsafe-memory-access=allow, which
|
||||
# needs a newer JDK than the one the clojure CLI wrapper ships with.
|
||||
clojureJdk25 = clojure.override { jdk = jdk25_headless; };
|
||||
|
||||
# Upstream leaves a few dev deps on "RELEASE"; pin them so the lockfile
|
||||
# generation and the (offline) shadow-cljs build resolve the same
|
||||
# deterministic, purely local maven versions.
|
||||
pinDepsEdn = ''
|
||||
sed -i frontend/deps.edn \
|
||||
-e 's|binaryage/devtools {:mvn/version "RELEASE"}|binaryage/devtools {:mvn/version "1.0.7"}|' \
|
||||
-e 's|org.clojure/tools.namespace {:mvn/version "RELEASE"}|org.clojure/tools.namespace {:mvn/version "1.5.1"}|' \
|
||||
-e 's|com.bhauman/rebel-readline {:mvn/version "RELEASE"}|com.bhauman/rebel-readline {:mvn/version "0.1.11"}|'
|
||||
'';
|
||||
|
||||
# Offline Clojure dependencies: eval-time cache from deps-lock.json
|
||||
# (no fixed-output derivation) + fake-git shim + short-SHA expansion.
|
||||
# Resolution runs offline from the cache on every build; no cpcache needed.
|
||||
cljOffline = callPackage ./clj-offline.nix { };
|
||||
in
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "penpot-frontend";
|
||||
inherit version;
|
||||
inherit src;
|
||||
|
||||
# Local carries of upstream plugin API fixes (see the patches for the
|
||||
# upstream issue references). Remove once the `penpot` input contains them.
|
||||
patches = [
|
||||
./patches/plugin-library-name-path.patch
|
||||
./patches/plugin-typography-lineheight.patch
|
||||
];
|
||||
|
||||
nativeBuildInputs = [
|
||||
nodejs_24
|
||||
pnpm_11
|
||||
pnpmConfigHook
|
||||
clojureJdk25
|
||||
cljOffline.fake-git
|
||||
cljOffline.clj-builder
|
||||
rsync
|
||||
patchelf
|
||||
];
|
||||
|
||||
# pnpmConfigHook is invoked once per workspace in preBuild instead
|
||||
# (dontPnpmConfigure disables the implicit single-workspace run).
|
||||
dontPnpmConfigure = true;
|
||||
|
||||
postPatch = ''
|
||||
${pinDepsEdn}
|
||||
# 2.18's shared pnpm store config would redirect the offline installs
|
||||
# away from the fetched per-workspace stores.
|
||||
find . -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
|
||||
'';
|
||||
|
||||
env = {
|
||||
NODE_ENV = "production";
|
||||
# Baked into the cljs output (app.config/compiled-version-tag).
|
||||
VERSION = version;
|
||||
# Upstream appends the wall-clock build time (VERSION_TAG =
|
||||
# "$VERSION-$BUILD_TS"); a plain version keeps the build deterministic
|
||||
# while still busting caches. TEMPORARY: the -fix suffix forces clients
|
||||
# to fetch the patched bundle while the local patches are carried.
|
||||
VERSION_TAG = "${version}-fix2";
|
||||
# _helpers.js embeds these in the bundle when set; pin them for
|
||||
# reproducibility (upstream exports the wall clock).
|
||||
BUILD_DATE = "Thu Jan 01 00:00:00 UTC 1970";
|
||||
BUILD_TS = "0";
|
||||
};
|
||||
|
||||
preBuild = ''
|
||||
# pnpmConfigHook honors two variables: $pnpmRoot (where the workspace
|
||||
# lives; the hook pushd's into it) and $pnpmDeps (the fetched store,
|
||||
# visible to the hook via bash dynamic scoping from the local).
|
||||
installWorkspace() {
|
||||
local pnpmRoot="$1" pnpmDeps="$2"
|
||||
pnpmConfigHook
|
||||
}
|
||||
|
||||
installWorkspace frontend ${frontendPnpmDeps}
|
||||
installWorkspace plugins ${pluginsPnpmDeps}
|
||||
installWorkspace mcp ${mcpPnpmDeps}
|
||||
|
||||
# sass-embedded ships a prebuilt dart VM expecting the host loader.
|
||||
sassPkg=${
|
||||
if stdenvNoCC.hostPlatform.isx86_64 then "sass-embedded-linux-x64" else "sass-embedded-linux-arm64"
|
||||
}
|
||||
patchelf --set-interpreter "$(cat ${stdenv.cc.bintools}/nix-support/dynamic-linker)" \
|
||||
frontend/node_modules/.pnpm/$sassPkg@*/node_modules/$sassPkg/dart-sass/src/dart
|
||||
'';
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# 1. render-wasm artifacts.
|
||||
mkdir -p frontend/src/app/render_wasm/api frontend/resources/public/js/worker
|
||||
install -m644 ${renderWasm}/render-wasm.js ${renderWasm}/render-wasm.wasm frontend/resources/public/js/
|
||||
install -m644 ${renderWasm}/worker/render.js frontend/resources/public/js/worker/render.js
|
||||
install -m644 ${renderWasm}/shared.js frontend/src/app/render_wasm/api/shared.js
|
||||
|
||||
# 2. MCP plugin.
|
||||
(cd mcp && WS_URI=/mcp/ws pnpm run --filter mcp-plugin build)
|
||||
|
||||
# 3. Official plugins.
|
||||
(cd plugins/libs/plugins-runtime && pnpm run build)
|
||||
(cd plugins && pnpm run build:plugins)
|
||||
|
||||
# 4. Main cljs build, resolving offline from the eval-time clj-nix cache.
|
||||
${cljOffline.setup}
|
||||
|
||||
(cd frontend && pnpm run build:app:main)
|
||||
(cd frontend && pnpm run build:app:libs)
|
||||
(cd frontend && pnpm run build:app:assets)
|
||||
|
||||
# 5. Finalize the compiled assets (the dist assembly itself happens in
|
||||
# installPhase).
|
||||
sed -i "s|\./render.js|./render.js?version=$version|g" frontend/resources/public/js/worker/main*.js
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/dist
|
||||
rsync -a frontend/resources/public/ $out/dist/
|
||||
rsync -a plugins/dist/apps/ $out/dist/plugins/
|
||||
mkdir -p $out/dist/plugins/mcp
|
||||
rsync -a mcp/packages/plugin/dist/ $out/dist/plugins/mcp/
|
||||
echo "$version" > $out/dist/version.txt
|
||||
install -m644 ${penpot}/LICENSE $out/dist/LICENSE
|
||||
|
||||
# The NixOS module re-renders flags into the config template at system
|
||||
# build time; upstream also serves it from dist/js/config.js.
|
||||
install -m644 ${penpot}/docker/images/files/config.js $out/config.js
|
||||
install -m644 ${penpot}/docker/images/files/config.js $out/dist/js/config.js
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
# The pnpm stores are exposed so a version bump can rehash them directly
|
||||
# (`nix build .#penpot-frontend.<name>` with hash = lib.fakeHash).
|
||||
passthru = {
|
||||
inherit
|
||||
version
|
||||
frontendPnpmDeps
|
||||
pluginsPnpmDeps
|
||||
mcpPnpmDeps
|
||||
;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "Penpot frontend static bundle";
|
||||
homepage = "https://penpot.app";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Hardened ImageMagick policy shipped by the upstream images (resource caps
|
||||
# and PS/EPS/PDF/XPS coders disabled); nixpkgs' default policy is
|
||||
# unrestricted and the backend/exporter process user-controlled content.
|
||||
{
|
||||
runCommand,
|
||||
penpot,
|
||||
}:
|
||||
runCommand "penpot-imagemagick-policy" { } ''
|
||||
install -Dm644 "${penpot}/docker/images/files/imagemagick-policy.xml" \
|
||||
"$out/etc/ImageMagick-7/policy.xml"
|
||||
''
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic repack of a jar, replicating stripJavaArchivesHook for the
|
||||
penpot backend uberjar.
|
||||
|
||||
Nix's stripJavaArchivesHook runs strip-nondeterminism, which drives the
|
||||
perl Archive::Zip module. Two problems made it unusable here:
|
||||
|
||||
* its per-member removeMember/addMember reorder loop is O(n^2) in the entry
|
||||
count (linear member search + full array rebuild per entry), so the
|
||||
54000-entry, 109 MB penpot jar spent ~32 minutes there;
|
||||
* Archive::Zip derives the DOS timestamps with localtime, so the result
|
||||
depended on the build machine's timezone.
|
||||
|
||||
This script reproduces the same semantics (jar.pm + zip.pm handlers) with the
|
||||
stdlib zipfile module: read each entry once, sort the entry list, write it in a
|
||||
single pass. The result is byte-identical across runs and timezones, and costs
|
||||
seconds instead of minutes.
|
||||
|
||||
Semantics replicated from strip-nondeterminism:
|
||||
* entry order: META-INF/ first, then META-INF/MANIFEST.MF, then lexicographic
|
||||
* canonical time: Archive::Zip SAFE_EPOCH (1980-01-01 12:01:00 local), used
|
||||
here as a fixed UTC tuple so the output never depends on $TZ
|
||||
* .clj entries are stamped 2 DOS seconds (1 unix second) earlier, so that
|
||||
Clojure never mistakes a .clj for a stale .class
|
||||
* META-INF/MANIFEST.MF: drop Bnd-LastModified:/Built-By: lines
|
||||
* .html with a `<!-- Generated by javadoc` marker in the first 1 KB: bump
|
||||
the generation date to the canonical date
|
||||
* nested .jar entries: recurse with the same semantics
|
||||
* extra fields 0x5455 (extended timestamps), 0x000a (NTFS), 0x7875 (ux
|
||||
uid/gid) are normalized; unknown fields are kept verbatim
|
||||
* archives with a META-INF/*.SF signature are left untouched
|
||||
* javac.sh and plexus compiler-arguments files are dropped
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
# Archive::Zip SAFE_EPOCH: 1980-01-01 12:01:00. The DOS timestamp below is the
|
||||
# same instant, but written as a fixed tuple: no localtime call on the way out.
|
||||
DOS_DATE = (1980, 1, 1)
|
||||
DOS_TIME = (12, 1, 0)
|
||||
DOS_EPOCH_SECONDS = 315576060
|
||||
|
||||
MANIFEST = "META-INF/MANIFEST.MF"
|
||||
MANIFEST_DIR = "META-INF/"
|
||||
JAVADOC_WINDOW = 1024
|
||||
JAVADOC_MARKER = b"<!-- Generated by javadoc"
|
||||
|
||||
# From jar.pm's extract helper: a `YYYY-MM-DD` following this class is the
|
||||
# javadoc generation timestamp.
|
||||
JAVADOC_DATE_RE = re.compile(rb"(<!-- Generated by javadoc[^>]*?\son\s)(\d{4}-\d{2}-\d{2})")
|
||||
|
||||
|
||||
def order_key(name: str):
|
||||
if name == MANIFEST_DIR:
|
||||
return (0, name)
|
||||
if name == MANIFEST:
|
||||
return (1, name)
|
||||
return (2, name)
|
||||
|
||||
|
||||
def dropped(name: str) -> bool:
|
||||
return name == "javac.sh" or re.fullmatch(
|
||||
r"org\.codehaus\.plexus\.compiler\.javac\.JavacCompiler\d+arguments", name
|
||||
) is not None
|
||||
|
||||
|
||||
def canonical_tuple(entry_name: str):
|
||||
if entry_name.endswith(".clj"):
|
||||
# 2 DOS seconds earlier, i.e. one unix second (DOS has 2 s granularity).
|
||||
seconds = DOS_TIME[0] * 3600 + DOS_TIME[1] * 60 + DOS_TIME[2] - 2
|
||||
return DOS_DATE + (seconds // 3600, (seconds % 3600) // 60, seconds % 60)
|
||||
return DOS_DATE + DOS_TIME
|
||||
|
||||
|
||||
def normalize_manifest(data: bytes) -> bytes:
|
||||
out = []
|
||||
for line in data.splitlines(keepends=True):
|
||||
if line.startswith(b"Bnd-LastModified:") or line.startswith(b"Built-By:"):
|
||||
continue
|
||||
out.append(line)
|
||||
return b"".join(out)
|
||||
|
||||
|
||||
def normalize_javadoc(data: bytes) -> bytes:
|
||||
head = data[:JAVADOC_WINDOW]
|
||||
if JAVADOC_MARKER not in head:
|
||||
return data
|
||||
|
||||
def repl(match):
|
||||
return match.group(1) + b"1980-01-01"
|
||||
|
||||
return JAVADOC_DATE_RE.sub(repl, data, count=1)
|
||||
|
||||
|
||||
def normalize_extra(extra: bytes, filename: str) -> bytes:
|
||||
"""Normalize known timestamp/identity extra fields, keep the rest as-is."""
|
||||
want = canonical_tuple(filename)
|
||||
out = bytearray()
|
||||
pos = 0
|
||||
while pos + 4 <= len(extra):
|
||||
field_id, field_len = struct.unpack_from("<HH", extra, pos)
|
||||
data = extra[pos + 4 : pos + 4 + field_len]
|
||||
if field_id == 0x5455: # extended timestamp
|
||||
if len(data) >= 5:
|
||||
data = data[:1] + b"\x00\x00\x00\x00" + data[5:]
|
||||
elif field_id == 0x000a: # NTFS timestamp
|
||||
if len(data) >= 4:
|
||||
reserved = data[:4]
|
||||
tags = bytearray()
|
||||
tag_pos = 4
|
||||
while tag_pos + 4 <= len(data):
|
||||
tag, tag_size = struct.unpack_from("<HH", data, tag_pos)
|
||||
tags += data[tag_pos : tag_pos + 4]
|
||||
if tag == 0x0001 and tag_size == 24:
|
||||
# 32 bytes of 100ns ticks since 1601, i.e. 12 bytes of
|
||||
# zeros followed by the unix seconds in the low word.
|
||||
ticks = b"\x00" * 12 + struct.pack("<I", int(DOS_EPOCH_SECONDS))
|
||||
tags += ticks
|
||||
else:
|
||||
tags += data[tag_pos + 4 : tag_pos + 4 + tag_size]
|
||||
tag_pos += 4 + tag_size
|
||||
data = reserved + bytes(tags)
|
||||
elif field_id == 0x7875: # Info-ZIP new unix extra field
|
||||
if len(data) >= 3 and data[0] == 1:
|
||||
uid_len, gid_len = data[1], data[2 + data[1]]
|
||||
data = (
|
||||
b"\x01"
|
||||
+ bytes([uid_len])
|
||||
+ b"\x00" * uid_len
|
||||
+ bytes([gid_len])
|
||||
+ b"\x00" * gid_len
|
||||
)
|
||||
out += struct.pack("<HH", field_id, len(data)) + data
|
||||
pos += 4 + field_len
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def normalize_member(name: str, data: bytes) -> bytes:
|
||||
if name == MANIFEST:
|
||||
return normalize_manifest(data)
|
||||
if name.endswith(".html"):
|
||||
return normalize_javadoc(data)
|
||||
if name.endswith(".jar"):
|
||||
return normalize_jar_bytes(data)
|
||||
return data
|
||||
|
||||
|
||||
def _rewrite(source, target):
|
||||
zin = zipfile.ZipFile(source)
|
||||
if any(n.startswith("META-INF/") and n.endswith(".SF") for n in zin.namelist()):
|
||||
# Signed jar: normalization would break the signature.
|
||||
return False, 0
|
||||
|
||||
with zipfile.ZipFile(target, "w") as zout:
|
||||
written = 0
|
||||
for item in sorted(zin.infolist(), key=lambda i: order_key(i.filename)):
|
||||
if dropped(item.filename):
|
||||
continue
|
||||
data = normalize_member(item.filename, zin.read(item.filename))
|
||||
date_time = canonical_tuple(item.filename)
|
||||
info = zipfile.ZipInfo(item.filename, date_time=date_time)
|
||||
info.compress_type = item.compress_type
|
||||
info.external_attr = item.external_attr
|
||||
info.internal_attr = item.internal_attr
|
||||
info.create_system = 3
|
||||
info.extra = normalize_extra(item.extra, item.filename)
|
||||
zout.writestr(info, data)
|
||||
written += 1
|
||||
return True, written
|
||||
|
||||
|
||||
def normalize_jar_bytes(data: bytes) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zin:
|
||||
if any(n.startswith("META-INF/") and n.endswith(".SF") for n in zin.namelist()):
|
||||
return data
|
||||
with zipfile.ZipFile(buffer, "w") as zout:
|
||||
for item in sorted(zin.infolist(), key=lambda i: order_key(i.filename)):
|
||||
if dropped(item.filename):
|
||||
continue
|
||||
info = zipfile.ZipInfo(item.filename, date_time=canonical_tuple(item.filename))
|
||||
info.compress_type = item.compress_type
|
||||
info.external_attr = item.external_attr
|
||||
info.internal_attr = item.internal_attr
|
||||
info.create_system = 3
|
||||
info.extra = normalize_extra(item.extra, item.filename)
|
||||
zout.writestr(info, normalize_member(item.filename, zin.read(item.filename)))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print(f"usage: {sys.argv[0]} <jar>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
path = sys.argv[1]
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".jar")
|
||||
os.close(tmp_fd)
|
||||
try:
|
||||
handled, entries = _rewrite(path, tmp_path)
|
||||
if not handled:
|
||||
print(f"jar-strip: {path}: signed jar, leaving untouched", file=sys.stderr)
|
||||
return 0
|
||||
if entries == 0:
|
||||
print(f"jar-strip: {path}: no entries written", file=sys.stderr)
|
||||
return 1
|
||||
# Self-validation: reopen and verify every CRC before trusting the output.
|
||||
with zipfile.ZipFile(tmp_path) as check:
|
||||
bad = check.testzip()
|
||||
if bad is not None:
|
||||
print(f"jar-strip: {path}: CRC mismatch at {bad}", file=sys.stderr)
|
||||
return 1
|
||||
os.replace(tmp_path, path)
|
||||
print(f"jar-strip: {path}: normalized {entries} entries")
|
||||
return 0
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,117 @@
|
||||
# Penpot MCP server (upstream `mcp/` pnpm workspace).
|
||||
#
|
||||
# The esbuild bundle (packages/server/dist/index.js) keeps the node
|
||||
# dependencies external, so the runtime needs a real node_modules tree.
|
||||
# We materialize it offline with pnpm's `deploy` from the fetched store.
|
||||
{
|
||||
lib,
|
||||
penpot,
|
||||
version,
|
||||
subSrc,
|
||||
stdenv,
|
||||
stdenvNoCC,
|
||||
runCommand,
|
||||
makeWrapper,
|
||||
nodejs_24,
|
||||
pnpm_11,
|
||||
fetchPnpmDeps,
|
||||
pnpmConfigHook,
|
||||
}:
|
||||
let
|
||||
# Only the mcp/ workspace is needed from the Penpot source tree.
|
||||
mcpSrc = subSrc penpot [ "mcp" ];
|
||||
|
||||
# 2.18 adds a shared `storeDir: ../.pnpm-store` to the workspace's
|
||||
# pnpm-workspace.yaml; our pnpm fetches are per-workspace fixed-output
|
||||
# derivations, so strip the line before the fetch (postPatch strips the
|
||||
# consumer copy).
|
||||
# Named "source": stdenv's unpack of a directory source copies it to a
|
||||
# dir named after `stripHash(src)`, and the fetch below relies on
|
||||
# sourceRoot = "source/mcp" (matching the raw subSrc trees, which are
|
||||
# also named "source" in the store).
|
||||
mcpSrcP = runCommand "source" { } ''
|
||||
mkdir "$out"
|
||||
cp -r "${mcpSrc}"/. "$out"/
|
||||
chmod -R u+w "$out"
|
||||
find "$out" -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
|
||||
'';
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "penpot-mcp";
|
||||
inherit version;
|
||||
|
||||
src = mcpSrcP;
|
||||
# The filtered source tree keeps the upstream `mcp/` directory;
|
||||
# the workspace (pnpm-lock.yaml, pnpm-workspace.yaml) lives there.
|
||||
sourceRoot = "source/mcp";
|
||||
|
||||
# Fixed-output fetch of the whole pnpm store (fetcherVersion 3 is
|
||||
# unsupported for pnpm 11).
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
pname = "penpot-mcp";
|
||||
inherit (finalAttrs) src sourceRoot;
|
||||
pnpm = pnpm_11;
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-99ef/Qk3tx+etEqepQQVqAW3XL7T1Kr6qQJ7Uxf1z0Y=";
|
||||
};
|
||||
|
||||
# 2.18's shared pnpm store config would redirect the offline install away
|
||||
# from the fetched store.
|
||||
postPatch = ''
|
||||
find . -name pnpm-workspace.yaml -exec sed -i '/^storeDir:/d' {} +
|
||||
'';
|
||||
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
nodejs_24
|
||||
pnpm_11
|
||||
pnpmConfigHook
|
||||
];
|
||||
|
||||
# Same build as upstream `mcp/scripts/build` (build-types is not part of
|
||||
# it; ApiDocs loads the committed data/api_types.yml at runtime).
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
pnpm -r --filter "mcp-server" run build
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Produce a standalone production runtime for the server. Unlike the
|
||||
# upstream dist/ bundle we materialize node_modules now (from the
|
||||
# offline store) instead of shipping a `setup` script that would run
|
||||
# `pnpm install` on the user's machine. The esbuild bundle keeps the
|
||||
# node dependencies external, so they must ship alongside it.
|
||||
pnpm --filter mcp-server \
|
||||
--config.inject-workspace-packages=true \
|
||||
deploy --prod --offline deploy
|
||||
|
||||
mkdir -p $out/lib/penpot-mcp $out/bin
|
||||
# dist contains index.js, static/ and data/ (instructions + api types).
|
||||
cp -a deploy/dist/. $out/lib/penpot-mcp/
|
||||
cp deploy/package.json $out/lib/penpot-mcp/package.json
|
||||
cp -a deploy/node_modules $out/lib/penpot-mcp/node_modules
|
||||
|
||||
# The server resolves data/ relative to the working directory, so the
|
||||
# wrapper chdirs into the runtime before exec'ing. Defaults to the
|
||||
# multi-user docker runtime; override arguments with PENPOT_MCP_ARGS.
|
||||
makeWrapper ${lib.getExe nodejs_24} $out/bin/penpot-mcp \
|
||||
--chdir $out/lib/penpot-mcp \
|
||||
--set-default PENPOT_MCP_SERVER_HOST 127.0.0.1 \
|
||||
--add-flags 'index.js' \
|
||||
--add-flags ''${PENPOT_MCP_ARGS:---multi-user}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = { inherit (finalAttrs) version pnpmDeps; };
|
||||
|
||||
meta = {
|
||||
description = "Penpot MCP server (Model Context Protocol integration for Penpot)";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "penpot-mcp";
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs
|
||||
index c7895ef7..fda13133 100644
|
||||
--- a/frontend/src/app/plugins/library.cljs
|
||||
+++ b/frontend/src/app/plugins/library.cljs
|
||||
@@ -10,6 +10,7 @@
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.variant :as cfv]
|
||||
[app.common.geom.point :as gpt]
|
||||
+ [app.common.path-names :as cpn]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.color :as clr]
|
||||
[app.common.types.component :as ctk]
|
||||
@@ -32,11 +33,25 @@
|
||||
[app.plugins.utils :as u]
|
||||
[app.util.object :as obj]
|
||||
[beicon.v2.core :as rx]
|
||||
+ [cuerdas.core :as str]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(declare lib-color-proxy)
|
||||
(declare lib-typography-proxy)
|
||||
|
||||
+(defn- rename-path-name
|
||||
+ "Compose the full path string used by the library rename events from an
|
||||
+ item and a new name. A `value` that contains a path separator is
|
||||
+ interpreted as an absolute path (matching the UI rename field); any
|
||||
+ other value keeps the item's current path. This makes the setters
|
||||
+ idempotent: assigning back a value read from `name`/`path` (e.g.
|
||||
+ \"Group / Name\") no longer appends the current path again and moves
|
||||
+ the element to a nested group on every set."
|
||||
+ [item value]
|
||||
+ (if (str/includes? value "/")
|
||||
+ value
|
||||
+ (cpn/merge-path-item (d/nilv (:path item) "") value)))
|
||||
+
|
||||
(defn lib-color-proxy? [p]
|
||||
(obj/type-of? p "LibraryColorProxy"))
|
||||
|
||||
@@ -60,7 +75,7 @@
|
||||
:set
|
||||
(fn [self value]
|
||||
(cond
|
||||
- (not (string? value))
|
||||
+ (or (not (string? value)) (str/empty? value))
|
||||
(u/not-valid plugin-id :name value)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
@@ -68,7 +83,7 @@
|
||||
|
||||
:else
|
||||
(let [color (u/proxy->library-color self)
|
||||
- value (dm/str (d/nilv (:path color) "") " / " value)]
|
||||
+ value (rename-path-name color value)]
|
||||
(st/emit! (dwl/rename-color file-id id value)))))}
|
||||
|
||||
:path
|
||||
@@ -307,7 +322,7 @@
|
||||
:set
|
||||
(fn [self value]
|
||||
(cond
|
||||
- (not (string? value))
|
||||
+ (or (not (string? value)) (str/empty? value))
|
||||
(u/not-valid plugin-id :name value)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
@@ -315,7 +330,7 @@
|
||||
|
||||
:else
|
||||
(let [typo (u/proxy->library-typography self)
|
||||
- value (dm/str (d/nilv (:path typo) "") " / " value)]
|
||||
+ value (rename-path-name typo value)]
|
||||
(st/emit! (-> (dwl/rename-typography file-id id value)
|
||||
(se/add-event plugin-id))))))}
|
||||
|
||||
@@ -754,7 +769,7 @@
|
||||
:set
|
||||
(fn [self value]
|
||||
(cond
|
||||
- (not (string? value))
|
||||
+ (or (not (string? value)) (str/empty? value))
|
||||
(u/not-valid plugin-id :name value)
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
@@ -762,7 +777,7 @@
|
||||
|
||||
:else
|
||||
(let [component (u/proxy->library-component self)
|
||||
- value (dm/str (d/nilv (:path component) "") " / " value)]
|
||||
+ value (rename-path-name component value)]
|
||||
(st/emit! (dwv/rename-comp-or-variant-and-main id value)))))}
|
||||
|
||||
:path
|
||||
diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
index 32da4f5a..4bb5d4d4 100644
|
||||
--- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
+++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
@@ -98,6 +98,22 @@ describe('Library', () => {
|
||||
expect(color.opacity).toBeCloseTo(0.8, 2);
|
||||
});
|
||||
|
||||
+ // The `name` setter must be idempotent. Assigning a value that already
|
||||
+ // contains the path (the composite shown in the assets panel) must not
|
||||
+ // append the current path again and nest the element deeper on each set.
|
||||
+ test('color name with a path is idempotent', (ctx) => {
|
||||
+ const color = ctx.penpot.library.local.createColor();
|
||||
+ color.name = 'plugingroup/Name';
|
||||
+ expect(color.path).toBe('plugingroup');
|
||||
+ expect(color.name).toBe('Name');
|
||||
+ color.name = 'plugingroup/Name';
|
||||
+ expect(color.path).toBe('plugingroup');
|
||||
+ expect(color.name).toBe('Name');
|
||||
+ color.name = 'Simple';
|
||||
+ expect(color.path).toBe('plugingroup');
|
||||
+ expect(color.name).toBe('Simple');
|
||||
+ });
|
||||
+
|
||||
test('library color converts to fill and stroke', (ctx) => {
|
||||
const color = ctx.penpot.library.local.createColor();
|
||||
color.color = '#123456';
|
||||
@@ -188,6 +204,19 @@ describe('Library', () => {
|
||||
expect(typo.path).toBe('Text');
|
||||
});
|
||||
|
||||
+ test('typography name with a path is idempotent', (ctx) => {
|
||||
+ const typo = ctx.penpot.library.local.createTypography();
|
||||
+ typo.name = 'Text/Heading';
|
||||
+ expect(typo.path).toBe('Text');
|
||||
+ expect(typo.name).toBe('Heading');
|
||||
+ typo.name = 'Text/Heading';
|
||||
+ expect(typo.path).toBe('Text');
|
||||
+ expect(typo.name).toBe('Heading');
|
||||
+ typo.name = 'Body';
|
||||
+ expect(typo.path).toBe('Text');
|
||||
+ expect(typo.name).toBe('Body');
|
||||
+ });
|
||||
+
|
||||
test('typography plugin data round-trips', (ctx) => {
|
||||
const typo = ctx.penpot.library.local.createTypography();
|
||||
typo.setPluginData('k', 'v');
|
||||
@@ -279,6 +313,21 @@ describe('Library', () => {
|
||||
expect(comp.path).toBe('Controls');
|
||||
});
|
||||
|
||||
+ test('component name with a path is idempotent', (ctx) => {
|
||||
+ const rect = ctx.penpot.createRectangle();
|
||||
+ ctx.board.appendChild(rect);
|
||||
+ const comp = ctx.penpot.library.local.createComponent([rect]);
|
||||
+ comp.name = 'Controls/Button';
|
||||
+ expect(comp.path).toBe('Controls');
|
||||
+ expect(comp.name).toBe('Button');
|
||||
+ comp.name = 'Controls/Button';
|
||||
+ expect(comp.path).toBe('Controls');
|
||||
+ expect(comp.name).toBe('Button');
|
||||
+ comp.name = 'Input';
|
||||
+ expect(comp.path).toBe('Controls');
|
||||
+ expect(comp.name).toBe('Input');
|
||||
+ });
|
||||
+
|
||||
test('component plugin data round-trips', (ctx) => {
|
||||
const rect = ctx.penpot.createRectangle();
|
||||
ctx.board.appendChild(rect);
|
||||
@@ -0,0 +1,41 @@
|
||||
diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs
|
||||
index c7895ef7..fda13133 100644
|
||||
--- a/frontend/src/app/plugins/library.cljs
|
||||
+++ b/frontend/src/app/plugins/library.cljs
|
||||
@@ -440,7 +455,7 @@
|
||||
|
||||
:lineHeight
|
||||
{:this true
|
||||
- :get #(-> % u/proxy->library-typography :font-height)
|
||||
+ :get #(-> % u/proxy->library-typography :line-height)
|
||||
:set
|
||||
(fn [self value]
|
||||
(cond
|
||||
@@ -452,7 +467,7 @@
|
||||
|
||||
:else
|
||||
(let [typo (-> (u/proxy->library-typography self)
|
||||
- (assoc :font-height value))]
|
||||
+ (assoc :line-height value))]
|
||||
(st/emit! (dwl/update-typography typo file-id)))))}
|
||||
|
||||
:letterSpacing
|
||||
diff --git a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
index 32da4f5a..4bb5d4d4 100644
|
||||
--- a/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
+++ b/plugins/apps/plugin-api-test-suite/src/tests/library.test.ts
|
||||
@@ -238,9 +267,14 @@ describe('Library', () => {
|
||||
test('typography applies to a text shape', (ctx) => {
|
||||
const typo = ctx.penpot.library.local.createTypography();
|
||||
typo.fontSize = '22';
|
||||
+ // `lineHeight` must be stored under `:line-height` (the key the rest of
|
||||
+ // the app and the text attrs use), otherwise the applied typography
|
||||
+ // silently keeps the shape's current line height.
|
||||
+ typo.lineHeight = '1.5';
|
||||
const t = text(ctx);
|
||||
typo.applyToText(t);
|
||||
expect(t.fontSize).toBe('22');
|
||||
+ expect(t.lineHeight).toBe('1.5');
|
||||
});
|
||||
|
||||
test('typography applies to a text range', (ctx) => {
|
||||
@@ -0,0 +1,294 @@
|
||||
# Penpot's WebAssembly canvas renderer (the upstream `render-wasm/` tree):
|
||||
# Rust compiled for wasm32-unknown-emscripten with emscripten, statically
|
||||
# linking Penpot's prebuilt Skia binary distribution.
|
||||
#
|
||||
# Packaging notes (why this is not a plain buildRustPackage):
|
||||
#
|
||||
# - nixpkgs cannot cross-compile Rust for wasm32-unknown-emscripten: the
|
||||
# target is absent from `pkgsCross` and nixpkgs' `rustc` ships no std for
|
||||
# it. The Rust toolchain is therefore assembled from the official dist
|
||||
# tarballs (version pinned to the one upstream's devenv uses), and
|
||||
# nixpkgs' emscripten provides emcc/em++/emar. Upstream devenv pins
|
||||
# emsdk 4.0.6; nixpkgs' emscripten (6.0.8) accepts the same flag set.
|
||||
# - The skia-bindings build script normally downloads a prebuilt Skia
|
||||
# tarball from GitHub at build time. We fetch that tarball with fetchurl
|
||||
# and hand it to the build script via SKIA_BINARIES_URL=file://, so the
|
||||
# build itself runs sandboxed with no network access.
|
||||
# - Cargo dependencies are vendored with rustPlatform.fetchCargoVendor
|
||||
# (itself a fixed-output derivation). When penpot (or its
|
||||
# render-wasm/Cargo.lock) changes, recompute the hash: set it to
|
||||
# lib.fakeHash, run `nix build .#penpot-render-wasm`, and copy the
|
||||
# "got: sha256-..." value from the hash-mismatch error back into this
|
||||
# file. If the Rust toolchain version (rustVersion below) or the Skia
|
||||
# tarball URL changes, the corresponding fetchurl hashes must be
|
||||
# recomputed as well (same procedure: set to lib.fakeHash, read `got:`).
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
runCommand,
|
||||
fetchurl,
|
||||
patchelf,
|
||||
libgcc,
|
||||
rustPlatform,
|
||||
emscripten,
|
||||
esbuild,
|
||||
zlib,
|
||||
penpot,
|
||||
version,
|
||||
}:
|
||||
let
|
||||
# Rust version pinned by upstream's devenv (1.91.0, released 2025-10-30).
|
||||
rustVersion = "1.91.0";
|
||||
rustDistDate = "2025-10-30";
|
||||
wasmTarget = "wasm32-unknown-emscripten";
|
||||
|
||||
# Only the two systems this flake targets are wired up; the official dist
|
||||
# tarballs are host-specific, hence the per-system hashes.
|
||||
hostTriple =
|
||||
{
|
||||
x86_64-linux = "x86_64-unknown-linux-gnu";
|
||||
aarch64-linux = "aarch64-unknown-linux-gnu";
|
||||
}
|
||||
.${stdenv.hostPlatform.system}
|
||||
or (throw "penpot-render-wasm: unsupported system ${stdenv.hostPlatform.system}");
|
||||
|
||||
rustDistHashes =
|
||||
{
|
||||
x86_64-linux = {
|
||||
rustc = "sha256-pxaejLYXSvL0VxdwM3A2PY3oLOVfbMuhhYkwRbk3CHQ=";
|
||||
cargo = "sha256-cQPAP7ir6FsjMHAFqd/k8ByCaomUXYS5b6LQP9TS0Tg=";
|
||||
hostStd = "sha256-ieZSCxbBK0NSZEApjS2g3LcHR8XMLQuOR9ObXamu70k=";
|
||||
};
|
||||
aarch64-linux = {
|
||||
rustc = "sha256-8+o8lkt/O4hDN/LUEXZAMrvRci1/VVkqVHy7Ka/YfAM=";
|
||||
cargo = "sha256-AD1wCCGcoNIlrR36MB98B5sSNJlDDuB4DIV4Lgh47v8=";
|
||||
hostStd = "sha256-/yPcgfeW1k406GakT9C8rnJuNAFINTabj5OTpUQWfso=";
|
||||
};
|
||||
}
|
||||
.${stdenv.hostPlatform.system};
|
||||
|
||||
rustDist =
|
||||
name: hash:
|
||||
fetchurl {
|
||||
url = "https://static.rust-lang.org/dist/${rustDistDate}/${name}.tar.xz";
|
||||
inherit hash;
|
||||
};
|
||||
|
||||
# Minimal rustc + cargo installation (laid out like a rustup toolchain):
|
||||
# std for the host is needed for build scripts and proc-macros, std for
|
||||
# the emscripten target for the actual build. The dist binaries are
|
||||
# unpatched upstream builds, so the ELF files get their interpreter and
|
||||
# rpath pointed at nixpkgs' glibc/libgcc/zlib explicitly (autoPatchelfHook
|
||||
# proved unreliable for these binaries).
|
||||
rustToolchain =
|
||||
runCommand "penpot-render-wasm-rust-toolchain-${rustVersion}"
|
||||
{
|
||||
nativeBuildInputs = [ patchelf ];
|
||||
# The dist binaries ship pre-stripped; stripping them corrupts them.
|
||||
dontStrip = true;
|
||||
}
|
||||
''
|
||||
mkdir -p $out
|
||||
tar -xJf ${rustDist "rustc-${rustVersion}-${hostTriple}" rustDistHashes.rustc} \
|
||||
--strip-components=2 -C $out "rustc-${rustVersion}-${hostTriple}/rustc"
|
||||
tar -xJf ${rustDist "cargo-${rustVersion}-${hostTriple}" rustDistHashes.cargo} \
|
||||
--strip-components=2 -C $out "cargo-${rustVersion}-${hostTriple}/cargo"
|
||||
tar -xJf ${rustDist "rust-std-${rustVersion}-${hostTriple}" rustDistHashes.hostStd} \
|
||||
--strip-components=2 -C $out "rust-std-${rustVersion}-${hostTriple}/rust-std-${hostTriple}"
|
||||
tar -xJf ${rustDist "rust-std-${rustVersion}-${wasmTarget}" "sha256-0oJ49jPVYxTJAE058YHy1vz7kdqXSTgOsp9Hzo+Bfk4="} \
|
||||
--strip-components=2 -C $out "rust-std-${rustVersion}-${wasmTarget}/rust-std-${wasmTarget}"
|
||||
|
||||
rpath="$out/lib:${stdenv.cc.libc}/lib:${libgcc}/lib:${zlib}/lib"
|
||||
interp="$(cat ${stdenv.cc.bintools}/nix-support/dynamic-linker)"
|
||||
find $out -type f \( \
|
||||
-path "*/bin/cargo" -o -path "*/bin/rustc" -o -path "*/bin/rustdoc" \
|
||||
-o -name "librustc_driver*.so" -o -name "libLLVM.so.*" \
|
||||
-o -name rust-analyzer-proc-macro-srv -o -path "*rustlib/*/bin/*" \
|
||||
\) -print0 | while IFS= read -r -d "" f; do
|
||||
# Skip linker scripts / completions etc. that match the globs.
|
||||
[ "$(head -c 4 "$f" | od -An -tx1 | tr -d " \n")" = "7f454c46" ] || continue
|
||||
if [ -n "$(patchelf --print-interpreter "$f" 2>/dev/null)" ]; then
|
||||
patchelf --set-interpreter "$interp" "$f"
|
||||
fi
|
||||
patchelf --set-rpath "$rpath" "$f"
|
||||
done
|
||||
|
||||
# Fail the derivation if the toolchain cannot actually run sandboxed.
|
||||
$out/bin/cargo --version
|
||||
$out/bin/rustc --version
|
||||
'';
|
||||
|
||||
# Prebuilt Skia for wasm32/emscripten, pinned by the SKIA_BINARIES_URL
|
||||
# default in penpot/render-wasm/_build_env. Handed to the skia-bindings
|
||||
# build script, which accepts file:// URLs for exactly this purpose.
|
||||
skiaBinaries = fetchurl {
|
||||
url = "https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-${wasmTarget}-gl-svg-textlayout-binary-cache-webp.tar.gz";
|
||||
hash = "sha256-kwqI/Cg2c0JrzDKOAnselx+JgSPjhn/y7wS40DpDTx0=";
|
||||
};
|
||||
|
||||
cargoDeps = rustPlatform.fetchCargoVendor {
|
||||
name = "penpot-render-wasm-cargo-deps";
|
||||
src = penpot + "/render-wasm";
|
||||
hash = "sha256-Qxv3feKgQe/0+43rEehoRWibCJoxvOAHFDLY3Sq5jWs=";
|
||||
};
|
||||
|
||||
# Common emcc flags, verbatim from penpot/render-wasm/_build_env, plus
|
||||
# -sDEFAULT_TO_CXX: the prebuilt Skia libraries are C++ objects that need
|
||||
# libc++abi at link time, which newer emcc no longer pulls in when the
|
||||
# linker is invoked as plain emcc (as rustc does).
|
||||
emccCommonFlags = [
|
||||
"-sDEFAULT_TO_CXX=1"
|
||||
"--no-entry"
|
||||
"--js-library"
|
||||
"src/js/wapi.js"
|
||||
"-sMALLOC=dlmalloc"
|
||||
"-sINVOKE_RUN=0"
|
||||
"-sALLOW_TABLE_GROWTH=0"
|
||||
"-sALLOW_MEMORY_GROWTH=1"
|
||||
"-sINITIAL_HEAP=268435456" # upstream: $((256 * 1024 * 1024))
|
||||
"-sMEMORY_GROWTH_GEOMETRIC_STEP=0.8"
|
||||
"-sERROR_ON_UNDEFINED_SYMBOLS=0"
|
||||
"-sMAX_WEBGL_VERSION=2"
|
||||
"-sEXPORT_NAME=createRustSkiaModule"
|
||||
"-sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32"
|
||||
# Upstream 2.17.x builds with -sENVIRONMENT=web; the extra node/worker
|
||||
# environments are only needed by the (post-2.17) wasm-based exporter.
|
||||
"-sENVIRONMENT=web"
|
||||
"-sMODULARIZE=1"
|
||||
"-sDISABLE_EXCEPTION_CATCHING=1"
|
||||
"-sFILESYSTEM=0"
|
||||
"-sEXPORT_ES6=1"
|
||||
];
|
||||
|
||||
# Builds one flavor of the renderer. Upstream drives both flavors through
|
||||
# render-wasm/build + _build_env; the differences are the cargo profile
|
||||
# (--release vs --profile size) and the emcc optimization level.
|
||||
buildRenderWasm =
|
||||
flavor:
|
||||
assert lib.assertOneOf "flavor" flavor [
|
||||
"frontend"
|
||||
"export"
|
||||
];
|
||||
let
|
||||
isFrontend = flavor == "frontend";
|
||||
cargoProfileFlag = if isFrontend then "--release" else "--profile size";
|
||||
profileDir = if isFrontend then "release" else "size";
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "penpot-render-wasm" + lib.optionalString (!isFrontend) "-export";
|
||||
inherit version;
|
||||
|
||||
src = penpot + "/render-wasm";
|
||||
|
||||
nativeBuildInputs = [
|
||||
rustToolchain
|
||||
emscripten
|
||||
esbuild
|
||||
];
|
||||
|
||||
env = {
|
||||
CARGO_BUILD_TARGET = wasmTarget;
|
||||
SKIA_BINARIES_URL = "file://${skiaBinaries}";
|
||||
EMCC_CFLAGS = lib.concatStringsSep " " (
|
||||
(if isFrontend then [ "-O3" ] else [ "-Oz" ]) ++ [ "-sASSERTIONS=0" ] ++ emccCommonFlags
|
||||
);
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
export CARGO_TARGET_DIR="$PWD/cargo-target"
|
||||
export CARGO_HOME="$PWD/cargo-home"
|
||||
export CC_wasm32_unknown_emscripten=emcc
|
||||
export AR_wasm32_unknown_emscripten=emar
|
||||
|
||||
# emcc needs a writable cache; seed it from the store copy so the
|
||||
# sysroot libraries are not rebuilt.
|
||||
export EM_CACHE="$PWD/emcc-cache"
|
||||
cp -r --reflink=auto "${emscripten}/share/emscripten/cache/." "$EM_CACHE/"
|
||||
chmod -R u+w "$EM_CACHE/"
|
||||
|
||||
# Point cargo at the vendored crates; fetchCargoVendor's config
|
||||
# ships with a @vendor@ placeholder to fill in.
|
||||
cp -r --reflink=auto "${cargoDeps}" ./cargo-vendor
|
||||
chmod -R u+w ./cargo-vendor
|
||||
mkdir -p .cargo
|
||||
substitute "${cargoDeps}/.cargo/config.toml" .cargo/config.toml \
|
||||
--subst-var-by vendor "$PWD/cargo-vendor"
|
||||
|
||||
${lib.optionalString (!isFrontend) ''
|
||||
# Penpot 2.17.2 predates the size-optimized profile that newer
|
||||
# upstream uses for the exporter flavor; add it (same definition
|
||||
# as current upstream render-wasm/Cargo.toml).
|
||||
if ! grep -q "^\[profile\.size\]" Cargo.toml; then
|
||||
cat >> Cargo.toml <<'PROFILES'
|
||||
|
||||
[profile.size]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
PROFILES
|
||||
fi
|
||||
''}
|
||||
|
||||
cargo build ${cargoProfileFlag} --offline
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
artifacts="$PWD/cargo-target/${wasmTarget}/${profileDir}"
|
||||
|
||||
mkdir -p "$out"${lib.optionalString isFrontend "/worker"}
|
||||
cp "$artifacts/render_wasm.js" "$out/render-wasm.js"
|
||||
cp "$artifacts/render_wasm.wasm" "$out/render-wasm.wasm"
|
||||
|
||||
# Match upstream copy_artifacts(): rename the wasm reference and add
|
||||
# a version query for cache busting.
|
||||
sed -i "s/render_wasm\.wasm/render-wasm.wasm?version=${version}/g" \
|
||||
"$out/render-wasm.js"
|
||||
|
||||
shared="$(find "$PWD/cargo-target/${wasmTarget}" -name render_wasm_shared.js | head -n 1)"
|
||||
cp "$shared" "$out/shared.js"
|
||||
|
||||
${lib.optionalString isFrontend ''
|
||||
# Worker bundle, exactly as upstream copy_artifacts() builds it
|
||||
# (the exporter imports the ESM module directly instead).
|
||||
esbuild "$artifacts/render_wasm.js" \
|
||||
--log-level=error \
|
||||
--outfile="$out/worker/render.js" \
|
||||
--platform=neutral \
|
||||
--format=iife \
|
||||
--global-name=WasmModule
|
||||
''}
|
||||
|
||||
# Fail loudly instead of shipping an empty/broken artifact.
|
||||
test -s "$out/render-wasm.js"
|
||||
test -s "$out/render-wasm.wasm"
|
||||
test -s "$out/shared.js"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description =
|
||||
if isFrontend then
|
||||
"Penpot WebAssembly canvas renderer (frontend flavor)"
|
||||
else
|
||||
"Penpot WebAssembly canvas renderer (exporter flavor)";
|
||||
homepage = "https://penpot.app";
|
||||
license = lib.licenses.mpl20;
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
};
|
||||
in
|
||||
let
|
||||
frontend = buildRenderWasm "frontend";
|
||||
in
|
||||
frontend
|
||||
// {
|
||||
passthru = frontend.passthru or { } // {
|
||||
export = buildRenderWasm "export";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Common services.penpot configuration shared by the demo VM and the VM test.
|
||||
#
|
||||
# NOTE: the public URI must match the URL the stack is actually reached on.
|
||||
# The demo VM forwards host:8080 -> guest:8080, so browsing
|
||||
# http://localhost:8080 and calling the API on http://localhost would be a
|
||||
# cross-origin mismatch (every request would need a failing preflight).
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
services.penpot = {
|
||||
enable = true;
|
||||
publicUri = "http://localhost:8080";
|
||||
mailpit.enable = true;
|
||||
|
||||
# Registration always creates NON-admin users; admins are granted by
|
||||
# email at login time (checked against PENPOT_ADMINS on every request).
|
||||
# This demo grants admin to the pre-registered demo account below,
|
||||
# replace it with the email you register with, then rebuild and log
|
||||
# out/in for it to take effect.
|
||||
admins = [ "admin@penpot.local" ];
|
||||
|
||||
# Demo/test-only secret key. Production deployments must supply their
|
||||
# own file (e.g. via sops-nix) instead of a store path.
|
||||
secretKeyFile = pkgs.writeText "penpot-demo-secret-key" "penpot-demo-secret-key";
|
||||
|
||||
# Demo/test-only passwords exercising the password-credential paths
|
||||
# (see the LoadCredential asserts in vm-test.nix). With
|
||||
# database.passwordFile set, the local connection uses md5 auth with
|
||||
# the provisioned role password instead of the scoped trust rule.
|
||||
database.passwordFile = pkgs.writeText "penpot-demo-db-password" "penpot-demo-db-password";
|
||||
smtp.username = "penpot-demo";
|
||||
smtp.passwordFile = pkgs.writeText "penpot-demo-smtp-password" "penpot-demo-smtp-password";
|
||||
|
||||
# Evaluation defaults mirroring the upstream docker-compose file: skip
|
||||
# the email-confirmation roundtrip on registration (the demo SMTP sink
|
||||
# still works and is reachable from the host, see below).
|
||||
flags = [
|
||||
"enable-prepl-server"
|
||||
"disable-email-verification"
|
||||
];
|
||||
|
||||
# Keep the backend's JVM footprint predictable in the memory-constrained
|
||||
# test/demo VM.
|
||||
jvmOpts = [
|
||||
"-Xms256m"
|
||||
"-Xmx1g"
|
||||
];
|
||||
};
|
||||
|
||||
# Demo only: expose the mailpit UI beyond loopback so the host can browse
|
||||
# it (http://localhost:8025) and read the emails the instance sends.
|
||||
services.mailpit.instances.penpot.listen = "0.0.0.0:8025";
|
||||
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
8080
|
||||
8025
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
lib,
|
||||
self,
|
||||
...
|
||||
}:
|
||||
{
|
||||
name = "penpot";
|
||||
meta.maintainers = with lib.maintainers; [ ];
|
||||
|
||||
nodes.machine =
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
{ nixpkgs.overlays = [ self.overlays.default ]; }
|
||||
self.nixosModules.default
|
||||
./penpot-config.nix
|
||||
];
|
||||
|
||||
# Room for the backend JVM, the exporter's Chromium pool and
|
||||
# PostgreSQL during boot.
|
||||
virtualisation.memorySize = 3072;
|
||||
virtualisation.diskSize = 4096;
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
start_all()
|
||||
|
||||
machine.wait_for_unit("postgresql.service")
|
||||
machine.wait_for_unit("redis-penpot.service")
|
||||
machine.wait_for_unit("mailpit-penpot.service")
|
||||
machine.wait_for_unit("nginx.service")
|
||||
machine.wait_for_unit("penpot-backend.service")
|
||||
|
||||
def http(url, extra="", fail=True):
|
||||
"""Fetch url with curl and return the response body or headers.
|
||||
|
||||
fail=False keeps going on HTTP error statuses, for endpoints that
|
||||
answer with a useful body on them (e.g. the MCP 406)."""
|
||||
flag = "-f " if fail else ""
|
||||
return machine.succeed("curl -sS " + flag + extra + " " + url)
|
||||
|
||||
def http_code(url, extra=""):
|
||||
return machine.succeed(
|
||||
f"curl -sS -o /dev/null -w '%{{http_code}}' {extra} {url}"
|
||||
).strip()
|
||||
|
||||
mcp_init = (
|
||||
"-X POST -H 'Content-Type: application/json' "
|
||||
"-d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}'"
|
||||
)
|
||||
|
||||
# The backend is a source-run Clojure uberjar: give it a generous window
|
||||
# to reach the readyz endpoint (which also exercises the database).
|
||||
machine.wait_until_succeeds(
|
||||
"curl -fsS http://localhost:8080/readyz -o /tmp/readyz && grep -q OK /tmp/readyz",
|
||||
timeout=900,
|
||||
)
|
||||
|
||||
# Provisioning and secret handling.
|
||||
machine.succeed("systemctl is-active penpot-db-setup.service")
|
||||
dbowner = machine.succeed(
|
||||
"su postgres -c \"psql -tAc \\\"SELECT pg_get_userbyid(datdba)"
|
||||
" FROM pg_database WHERE datname='penpot'\\\"\""
|
||||
)
|
||||
assert "penpot" in dbowner
|
||||
machine.succeed("test -d /var/lib/penpot/assets")
|
||||
|
||||
# Frontend static site and rendered configuration.
|
||||
assert "<!doctype html" in http("http://localhost:8080/").lower()
|
||||
config_js = http("http://localhost:8080/js/config.js")
|
||||
for needle in ("penpotPublicURI", "disable-secure-session-cookies", "enable-smtp"):
|
||||
assert needle in config_js
|
||||
assert "no-store" in http("http://localhost:8080/js/config.js", "-I").lower()
|
||||
assert len(http("http://localhost:8080/js/worker/main.js")) > 0
|
||||
assert "max-age=604800" in http("http://localhost:8080/js/worker/main.js", "-I")
|
||||
|
||||
# Exporter and MCP entry points.
|
||||
machine.wait_for_unit("penpot-exporter.service")
|
||||
machine.wait_until_succeeds(
|
||||
"curl -fsS http://127.0.0.1:6061/readyz -o /tmp/exporter-readyz"
|
||||
" && grep -q OK /tmp/exporter-readyz",
|
||||
timeout=120,
|
||||
)
|
||||
machine.wait_for_unit("penpot-mcp.service")
|
||||
machine.wait_for_open_port(4401)
|
||||
# In multi-user mode a tokenless initialize is answered with a JSON-RPC
|
||||
# error (HTTP 406); any well-formed JSON-RPC response proves the server
|
||||
# is serving the MCP protocol.
|
||||
assert "jsonrpc" in http("http://127.0.0.1:4401/mcp", mcp_init, fail=False)
|
||||
|
||||
# The vhost routes /api/export to the exporter: the status code via the
|
||||
# vhost equals the one from hitting the exporter directly (the exporter
|
||||
# answers unauthenticated GETs with its own validation error), proving
|
||||
# the proxy hop without hardcoding a status.
|
||||
assert http_code("http://localhost:8080/api/export/readyz") == http_code(
|
||||
"http://127.0.0.1:6061/api/export/readyz"
|
||||
)
|
||||
assert "jsonrpc" in http("http://localhost:8080/mcp/stream", mcp_init, fail=False)
|
||||
|
||||
# Administrative CLI bootstrap path (no email roundtrip): the
|
||||
# penpot-manage tool ships on the machine PATH and talks to the backend
|
||||
# prepl server; profiles created through it are active immediately.
|
||||
machine.succeed(
|
||||
"penpot-manage create-profile -n 'CLI Admin'"
|
||||
" -e cli@penpot.local -p cli-password-123"
|
||||
)
|
||||
cli_login = http(
|
||||
"http://localhost:8080/api/rpc/command/login-with-password",
|
||||
"-X POST -H 'Content-Type: application/transit+json' "
|
||||
+ "-d '[\"^ \",\"~:email\",\"cli@penpot.local\","
|
||||
+ "\"~:password\",\"cli-password-123\"]'",
|
||||
fail=False,
|
||||
)
|
||||
assert "cli@penpot.local" in cli_login
|
||||
assert '"~:is-admin",false' in cli_login
|
||||
|
||||
# Real API roundtrip (nginx -> backend -> database): an unknown user
|
||||
# login gets the backend's own validation error back.
|
||||
assert "wrong-credentials" in http(
|
||||
"http://localhost:8080/api/rpc/command/login-with-password",
|
||||
"-X POST -H 'Content-Type: application/transit+json' "
|
||||
+ "-d '[\"^ \",\"~:email\",\"probe@example.org\","
|
||||
+ "\"~:password\",\"wrong-password-123\"]'",
|
||||
fail=False,
|
||||
)
|
||||
|
||||
# Websocket proxying path answers (backend not fully upgraded without a
|
||||
# client, but the vhost must not 404 on it).
|
||||
assert http_code(
|
||||
"http://localhost:8080/ws/notifications",
|
||||
"-H 'Upgrade: websocket' -H 'Connection: Upgrade'",
|
||||
) in ("426", "400", "101")
|
||||
|
||||
# SMTP sink: deliver a mail through mailpit via curl's SMTP client and
|
||||
# read it back through the mailpit API.
|
||||
machine.succeed(
|
||||
"printf 'Subject: penpot-test\\r\\nFrom: test@example.org\\r\\nTo:"
|
||||
" test@example.org\\r\\n\\r\\nhello from the test\\r\\n'"
|
||||
" | curl -sS smtp://127.0.0.1:1025"
|
||||
" --mail-from test@example.org --mail-rcpt test@example.org -T -"
|
||||
)
|
||||
machine.wait_until_succeeds(
|
||||
"curl -fsS 'http://127.0.0.1:8025/api/v1/messages' -o /tmp/mailpit"
|
||||
" && grep -q penpot-test /tmp/mailpit",
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
# Restart semantics: the backend must come back to a healthy state
|
||||
# after a restart.
|
||||
machine.succeed("systemctl restart penpot-backend.service")
|
||||
machine.wait_until_succeeds(
|
||||
"curl -fsS http://localhost:8080/readyz -o /tmp/readyz2 && grep -q OK /tmp/readyz2",
|
||||
timeout=900,
|
||||
)
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# A minimal headless NixOS VM running a full Penpot stack (backend, nginx
|
||||
# vhost, exporter, MCP, PostgreSQL, Valkey and the mailpit SMTP sink).
|
||||
#
|
||||
# Build it with:
|
||||
# nix build .#nixosConfigurations.penpot-vm.config.system.build.vm
|
||||
# and run it headless with:
|
||||
# ./result/bin/run-penpot-vm-vm
|
||||
#
|
||||
# The guest's port 8080 (the vhost's listen port, matching the public URI)
|
||||
# is forwarded to the host's port 8080, so once the VM is up you can verify
|
||||
# the deployment from the host:
|
||||
# curl -fsS http://127.0.0.1:8080/readyz # backend health (via nginx)
|
||||
# curl -fsS http://127.0.0.1:8080/ # frontend
|
||||
# curl -fsS http://127.0.0.1:8080/js/config.js
|
||||
# The mailpit SMTP-sink UI is forwarded too: http://localhost:8025
|
||||
{
|
||||
lib,
|
||||
modulesPath,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
"${modulesPath}/virtualisation/qemu-vm.nix"
|
||||
./penpot-config.nix
|
||||
];
|
||||
|
||||
networking.hostName = "penpot-vm";
|
||||
|
||||
# Headless: serial console only.
|
||||
virtualisation.graphics = false;
|
||||
virtualisation.qemu.consoles = [ "ttyS0" ];
|
||||
boot.kernelParams = [ "console=ttyS0,115200" ];
|
||||
|
||||
# The Clojure backend plus Chromium-driven exporter need some headroom.
|
||||
virtualisation.memorySize = 3072;
|
||||
virtualisation.diskSize = 4096;
|
||||
|
||||
# Host and guest ports must match: the vhost listens on the public URI's
|
||||
# port (8080) and the served config.js points the app at that URL.
|
||||
virtualisation.forwardPorts = [
|
||||
{
|
||||
from = "host";
|
||||
host.port = 8080;
|
||||
guest.port = 8080;
|
||||
}
|
||||
{
|
||||
from = "host";
|
||||
host.port = 8025;
|
||||
guest.port = 8025;
|
||||
}
|
||||
];
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
|
||||
# Convenience for poking around over the serial console.
|
||||
services.getty.autologinUser = "root";
|
||||
users.users.root.initialHashedPassword = "";
|
||||
}
|
||||
Reference in new issue
Block a user