Implement penpot_export_library (SSE URI plus artifact download) #204

Closed
opened 2026-09-14 23:12:29 +00:00 by david · 1 comment
Owner

Summary

Implement penpot_export_library: export a Penpot file as a .penpot artifact, follow the streamed Transit URI to download the ZIP, and save it to disk — verifying it is a real .penpot archive and reporting what it contains.

Background

Depends on: #202

Export is the other half of the library-as-artifact strategy: the component library is authored in the Penpot UI once, exported here, and committed to the repo. It is also the tool that makes a design file portable — a project's screens and library can be captured and re-imported elsewhere.

Confirmed contract on Penpot 2.17:

  • export-binfile takes a JSON body with fileId, includeLibraries and embedAssets as booleans. Do not send a type parameter — that is a later-version change and 2.17 does not accept it.
  • The response streams event: progress frames and terminates with event: end whose data: is a Transit URI: {"~#uri": "https://…/assets/by-id/…"}.
  • Fetching that URI returns a ZIP whose contents are inspectable: manifest.json, files/<id>.json, per-page/per-shape JSON, and colors/ / typographies/ directories. A committed artifact is therefore diffable in review, which is a genuine advantage over an opaque binary.

Note the two-step nature: the SSE call returns a pointer, not the bytes. The download is a separate authenticated-or-not GET against the returned URI — verify whether the returned URL needs the token (it may be a pre-signed asset URL) and record the finding.

Documentation Required

A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting.

docs/reference/penpot-api/

docs/reference/transit-format/

docs/reference/nodejs/

docs/reference/pi-coding-agent/ and docs/reference/typebox/

Implementation Details

Create extensions/penpot/src/tools/exportLibrary.ts and register the tool.

Parameters:

  • file_id (string, required) — the file to export.
  • out_path (string, optional) — where to write the ZIP; default to a deterministic path derived from the file name/id under the repo's scratch/artifact location (document the default). Refuse to overwrite an existing file unless overwrite: true is passed.
  • include_libraries (boolean, optional, default true).
  • embed_assets (boolean, optional, default true).
  • list_contents (boolean, optional, default true) — whether to report the archive's entry list.

Behaviour:

  1. POST export-binfile with the JSON body { fileId, includeLibraries, embedAssets }no type parameter.
  2. Consume the SSE stream (progress frames surfaced), decode the end frame via the Transit decoder, and extract the URI. Fail loudly with the raw payload if the end frame does not decode to a URI.
  3. Download the URI. Determine whether the request needs the auth header (verify against the instance, record the finding); handle a redirect by following it.
  4. Verify the downloaded bytes are a ZIP (magic bytes) and are non-empty before writing.
  5. Write to out_path, then report: absolute path, byte size, and (when list_contents) the archive's entry names/counts — manifest.json, files/*.json, colors/*, typographies/*.
  6. Read-many safety: exporting a file the extension did not create is a read, not a write, so the designated-target guard does not apply — but say clearly in the tool description that this downloads a copy of the file to disk.

Acceptance Criteria

  • The request body is exactly { fileId, includeLibraries, embedAssets } with booleans, and contains no type field (asserted in the unit test).
  • The end frame's Transit URI is decoded and used as the download URL.
  • An end payload that is not a URI fails loudly with the raw payload attached.
  • The download is written only after verifying the ZIP magic bytes and a non-empty body; a non-ZIP response (e.g. an error page or a JSON error) is refused with the raw excerpt.
  • The tool reports the absolute output path, byte size, and the archive entry list when list_contents is true.
  • An existing out_path is not overwritten unless overwrite: true.
  • Whether the download URI requires the auth header is determined empirically and recorded in extensions/penpot/findings.md.
  • Unit tests cover body construction, URI extraction, the non-ZIP refusal path and the overwrite guard, with a stubbed client/fetch.

Test Plan

node --test extensions/penpot/src/tools/exportLibrary.test.ts

Live validation (requires PENPOT_URL/PENPOT_TOKEN, scratch file):

  1. Export the scratch file with defaults; confirm the tool writes a ZIP and reports its entry list.
  2. Inspect the archive locally (unzip -l) and confirm manifest.json, files/*.json, colors/*, typographies/* are present.
  3. Export with include_libraries: false and compare the archive contents to the default export — record what actually differs.
  4. Re-run with the same out_path and confirm the overwrite guard refuses; then with overwrite: true and confirm it replaces the file.
  5. Record in extensions/penpot/findings.md whether the download URI required the auth header, whether it redirected, and the exact end payload shape.
## Summary Implement `penpot_export_library`: export a Penpot file as a `.penpot` artifact, follow the streamed Transit URI to download the ZIP, and save it to disk — verifying it is a real `.penpot` archive and reporting what it contains. ## Background **Depends on:** #202 Export is the other half of the library-as-artifact strategy: the component library is authored in the Penpot UI once, exported here, and committed to the repo. It is also the tool that makes a design file portable — a project's screens and library can be captured and re-imported elsewhere. Confirmed contract on Penpot 2.17: - `export-binfile` takes a **JSON** body with `fileId`, `includeLibraries` and `embedAssets` as **booleans**. Do **not** send a `type` parameter — that is a later-version change and 2.17 does not accept it. - The response streams `event: progress` frames and terminates with `event: end` whose `data:` is a **Transit URI**: `{"~#uri": "https://…/assets/by-id/…"}`. - Fetching that URI returns a **ZIP** whose contents are inspectable: `manifest.json`, `files/<id>.json`, per-page/per-shape JSON, and `colors/` / `typographies/` directories. A committed artifact is therefore diffable in review, which is a genuine advantage over an opaque binary. Note the two-step nature: the SSE call returns a *pointer*, not the bytes. The download is a separate authenticated-or-not GET against the returned URI — verify whether the returned URL needs the token (it may be a pre-signed asset URL) and record the finding. ## Documentation Required A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting. **`docs/reference/penpot-api/`** - `<PENPOT_URL>/api/main/doc/openapi.json` — `export-binfile`'s parameters and response type, confirming the boolean pair and the absence of `type` on this version. - https://help.penpot.app/user-guide/export-import/export-import-files/ — the "Export shared libraries" and "Include shared library assets in file libraries" options and what each produces, which is what `includeLibraries`/`embedAssets` map onto. - https://help.penpot.app/user-guide/import-export/ — the `.penpot` archive's purpose and the import side of the round trip. - https://help.penpot.app/user-guide/design-systems/libraries/ — what "shared library" means for the `includeLibraries` flag. **`docs/reference/transit-format/`** - https://github.com/cognitect/transit-format — the `~#uri` tag and how a URI is represented (implemented in the previous step). **`docs/reference/nodejs/`** - https://nodejs.org/api/globals.html#fetch — the second request to download the artifact. - https://nodejs.org/api/fs.html — writing the ZIP to disk. - https://nodejs.org/api/buffer.html — checking the ZIP magic bytes (`PK\x03\x04`) before accepting the download. - https://nodejs.org/api/webstreams.html — consuming the SSE body. **`docs/reference/pi-coding-agent/`** and **`docs/reference/typebox/`** - https://pi.dev/docs/latest/extensions — tool registration and structured `details`. - https://github.com/sinclairzx81/typebox — boolean and string parameter schemas. ## Implementation Details Create `extensions/penpot/src/tools/exportLibrary.ts` and register the tool. Parameters: - `file_id` (string, **required**) — the file to export. - `out_path` (string, optional) — where to write the ZIP; default to a deterministic path derived from the file name/id under the repo's scratch/artifact location (document the default). Refuse to overwrite an existing file unless `overwrite: true` is passed. - `include_libraries` (boolean, optional, default `true`). - `embed_assets` (boolean, optional, default `true`). - `list_contents` (boolean, optional, default `true`) — whether to report the archive's entry list. Behaviour: 1. POST `export-binfile` with the JSON body `{ fileId, includeLibraries, embedAssets }` — **no `type` parameter**. 2. Consume the SSE stream (progress frames surfaced), decode the `end` frame via the Transit decoder, and extract the URI. Fail loudly with the raw payload if the end frame does not decode to a URI. 3. Download the URI. Determine whether the request needs the auth header (verify against the instance, record the finding); handle a redirect by following it. 4. Verify the downloaded bytes are a ZIP (magic bytes) and are non-empty before writing. 5. Write to `out_path`, then report: absolute path, byte size, and (when `list_contents`) the archive's entry names/counts — `manifest.json`, `files/*.json`, `colors/*`, `typographies/*`. 6. Read-many safety: exporting a file the extension did not create is a **read**, not a write, so the designated-target guard does not apply — but say clearly in the tool description that this downloads a copy of the file to disk. ## Acceptance Criteria - [ ] The request body is exactly `{ fileId, includeLibraries, embedAssets }` with booleans, and contains **no** `type` field (asserted in the unit test). - [ ] The `end` frame's Transit URI is decoded and used as the download URL. - [ ] An `end` payload that is not a URI fails loudly with the raw payload attached. - [ ] The download is written only after verifying the ZIP magic bytes and a non-empty body; a non-ZIP response (e.g. an error page or a JSON error) is refused with the raw excerpt. - [ ] The tool reports the absolute output path, byte size, and the archive entry list when `list_contents` is true. - [ ] An existing `out_path` is not overwritten unless `overwrite: true`. - [ ] Whether the download URI requires the auth header is determined empirically and recorded in `extensions/penpot/findings.md`. - [ ] Unit tests cover body construction, URI extraction, the non-ZIP refusal path and the overwrite guard, with a stubbed client/fetch. ## Test Plan ```bash node --test extensions/penpot/src/tools/exportLibrary.test.ts ``` Live validation (requires `PENPOT_URL`/`PENPOT_TOKEN`, scratch file): 1. Export the scratch file with defaults; confirm the tool writes a ZIP and reports its entry list. 2. Inspect the archive locally (`unzip -l`) and confirm `manifest.json`, `files/*.json`, `colors/*`, `typographies/*` are present. 3. Export with `include_libraries: false` and compare the archive contents to the default export — record what actually differs. 4. Re-run with the same `out_path` and confirm the overwrite guard refuses; then with `overwrite: true` and confirm it replaces the file. 5. Record in `extensions/penpot/findings.md` whether the download URI required the auth header, whether it redirected, and the exact `end` payload shape.
david closed this issue 2026-09-15 04:35:55 +00:00
Author
Owner

pi-loop opened and merged a pull request for this issue: #237

pi-loop opened and merged a pull request for this issue: https://git.excelera.net/david/pi-extensions-and-skills/pulls/237
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
david/pi-extensions-and-skills#204
No description provided.