Created
July 28, 2026 04:47
-
-
Save lexrus/d2d11fd1311b8ffce36f4e277fd761de to your computer and use it in GitHub Desktop.
Patch for the pen.dev CLI (@pen.dev/cli) headless mode: fixes the "Base URI must be absolute" bug so headless export of .pen files with image resources works without launching the desktop app. By Lex (https://lex.sh). Generated by GLM-5.2.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Patch the Pencil CLI (pen.dev, @pen.dev/cli) so its headless mode can render | |
| `.pen` files that reference image resources — without launching the Pencil | |
| desktop app. | |
| WHY THIS FILE EXISTS | |
| -------------------- | |
| The Pencil CLI can export designs two ways: by driving the running desktop app | |
| (`pencil interactive --app desktop`), or fully headless (`pencil interactive | |
| --in file.pen`). The headless path is far nicer for automation — it never | |
| steals focus or eats the app's memory — but as of @pen.dev/cli 0.3.0 it is | |
| broken for any real document: | |
| `pencil interactive --in file.pen` (headless) fails with | |
| "Error loading scene graph: Error: Base URI must be absolute!" and loads an | |
| empty document. Even when that error is bypassed, exports come out with | |
| EMPTY image fills (app screenshots / device frames render as blank). | |
| ROOT CAUSE | |
| ---------- | |
| When the headless CLI loads a document, it constructs the base URI object from | |
| the file path but leaves the `scheme` field unset. The document's relative | |
| image URLs (`../../iPhone_frame.png`, etc.) are then resolved against a | |
| scheme-less base, which the engine rejects as non-absolute. Separately, the | |
| CLI's asset-mapping loop compares `doc.uri.scheme === 'file:'` (a literal | |
| INCLUDING the colon), so a scheme-less document is skipped and its images never | |
| load — hence the blank renders. | |
| THE FIX | |
| ------- | |
| At the entry of the bundle's `loadContent` method, if the base URI is a | |
| path-only object (has `.path`, no `.scheme`), set `scheme = 'file:'` — note the | |
| trailing colon, which is what the asset-mapping comparison expects. This makes | |
| the base URI a proper absolute `file:` URI, so both document loading and image | |
| resource mapping succeed, and headless exports match the desktop app's output. | |
| The patch is applied to the installed CLI bundle in place: | |
| /opt/homebrew/lib/node_modules/@pen.dev/cli/dist/index.mjs | |
| (a minified/obfuscated single-file bundle). It is idempotent — safe to re-run, | |
| skips itself if already applied, and refuses to patch if the bundle's layout | |
| has changed (so an upgrade that moves the anchor won't get a corrupt edit). | |
| USAGE | |
| ----- | |
| python3 patch_pencil_headless.py # apply (re-run after any CLI upgrade) | |
| python3 patch_pencil_headless.py --check # check status only, no changes | |
| Author: Lex (https://lex.sh) | |
| Generated by GLM-5.2. | |
| """ | |
| import os | |
| import re | |
| import sys | |
| # Resolve the CLI bundle via the same path the `pencil`/`pen` symlinks use. | |
| CANDIDATES = [ | |
| "/opt/homebrew/lib/node_modules/@pen.dev/cli/dist/index.mjs", | |
| os.path.expanduser( | |
| "~/.npm-global/lib/node_modules/@pen.dev/cli/dist/index.mjs" | |
| ), | |
| ] | |
| # The fix injected at the entry of loadContent(e, t, n, r), where n is the | |
| # document's base URI. If n is a path-only URI object (has .path, no .scheme), | |
| # promote it to a file: URI so relative image URLs resolve. | |
| # | |
| # The value must be 'file:' (WITH the colon). The CLI's asset-mapping loop | |
| # compares `doc.uri.scheme === 'file:'` (a literal including the colon), so | |
| # setting scheme to bare 'file' loads the document but skips image-resource | |
| # mapping — exports come out with empty image fills. 'file:' matches the | |
| # comparison and the images render correctly. | |
| FIX = ( | |
| "try{if(n&&typeof n==='object'&&'path'in n&&!n.scheme" | |
| "&&typeof n.path==='string')n.scheme='file:';}catch(_){}" | |
| ) | |
| # Anchor: the obfuscated loadContent method signature. The single-letter param | |
| # names are stable across the bundle; if this anchor ever stops matching, the | |
| # CLI layout changed and the patch must be re-derived. | |
| ANCHOR = "async['loadContent'](e,t,n,r){" | |
| # A stable substring of the FIX, used to detect an already-applied patch. | |
| MARKER = "n.scheme='file:'" | |
| def find_bundle(): | |
| for c in CANDIDATES: | |
| if os.path.exists(c): | |
| return c | |
| return None | |
| def status(path): | |
| src = open(path, "r", errors="replace").read() | |
| if MARKER in src: | |
| return "applied" | |
| if ANCHOR in src: | |
| return "unpatched" | |
| return "unknown (CLI layout changed — re-derive the patch)" | |
| def main(): | |
| check_only = "--check" in sys.argv | |
| path = find_bundle() | |
| if not path: | |
| print("error: could not find @pen.dev/cli/dist/index.mjs " | |
| "(is the pencil CLI installed?)") | |
| sys.exit(2) | |
| st = status(path) | |
| if check_only: | |
| print(f"{path}\n status: {st}") | |
| sys.exit(0 if st == "applied" else 1) | |
| if st == "applied": | |
| print(f"already patched: {path}") | |
| sys.exit(0) | |
| if st != "unpatched": | |
| print(f"error: {st}") | |
| print(" The CLI bundle structure changed. The patch anchor is gone, " | |
| "so re-derivation is needed (see comments in this script).") | |
| sys.exit(1) | |
| src = open(path, "r", errors="replace").read() | |
| idx = src.find(ANCHOR) | |
| inject_at = src.find("{", idx) + 1 | |
| patched = src[:inject_at] + FIX + src[inject_at:] | |
| # Back up once (keep the last unpatched original). | |
| bak = path + ".orig" | |
| if not os.path.exists(bak): | |
| import shutil | |
| shutil.copy2(path, bak) | |
| open(path, "w").write(patched) | |
| print(f"patched: {path}") | |
| print(f"backup: {bak}") | |
| print("headless `pencil interactive --in <file>` can now open .pen files " | |
| "with image resources.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment