Skip to content

Instantly share code, notes, and snippets.

@lexrus
Created July 28, 2026 04:47
Show Gist options
  • Select an option

  • Save lexrus/d2d11fd1311b8ffce36f4e277fd761de to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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