create-hr-plugin
v0.8.1
Published
Scaffold a HomeRunner widget plugin (vm.Script SSR + IIFE client) on @homerunner-next/widget-core
Maintainers
Readme
create-hr-plugin
Scaffold a HomeRunner widget plugin in seconds. Produces a working project (vm.Script SSR + IIFE client) wired against @homerunner-next/widget-core — the same SDK that backs first-party HomeRunner widgets.
📖 Full developer guide: https://homerunner-plugin-docs.vercel.app — manifest contracts, layouts, SSR, publishing, and an honest index of what the platform does not support yet. This README orients you; the guide is the reference.
Usage
npx create-hr-plugin my-widget # one widget (default)
npx create-hr-plugin my-suite --template suite # several widgets + a layoutThen:
cd my-widget
npm run dev # http://localhost:3001 — dev sandbox, MSW-mocked data, no live feed
npm run build # schema inject → hr-widget-build → dist/ + a stamped manifest.json⚠️
--templateis the one irreversible decisionIt picks your manifest shape, and the shape is frozen at your first publish. A single-widget plugin can never become a suite, or the reverse. Central refuses the publish and the rollback with
MANIFEST_SHAPE_CHANGED; the only remedy is a brand-new plugin slug, a new marketplace listing, and every customer re-creating their widgets.The trap is when it fires. Neither the upload audit nor the submit API nor the human review checks the shape — a flip passes all three and dies at publish, days later.
Converting is free before you publish and impossible after. When in doubt,
--template suite: a suite of one is legal and publishes normally.
Requirements
| | |
| --- | --- |
| Node | 20.19+ or 22.12+ (Vite 7's engines; the scaffold pins vite@^7.3.1). npm run dev:ssr also needs process.loadEnvFile, which landed in 20.12 — on older Node it fails silently and loads no env at all |
| @homerunner-next/widget-core | On the registry: ≥ 0.12.1 for --template single, ≥ 0.12.3 for --template suite. Those are the pins this version's templates carry (^0.12.1 and ^0.12.3) |
npx create-hr-plugin runs npm install in the generated project. If the registry's newest
widget-core is older than the pin, that install fails with
npm error code ETARGET No matching version found for @homerunner-next/widget-core@^0.12.3
— and the scaffolder treats that as non-fatal: it prints npm install failed — you can run it
manually. and still exits 0, leaving you a project with no node_modules. If that happens,
re-scaffold with --no-install and install a widget-core tarball by hand. That fallback, and
the current npm-vs-repo version table, are in
Install and versions.
Behind those numbers: hr-widget-build — the binary npm run build invokes — ships as
widget-core's bin from 0.11.0; a suite's npm run dev:ssr -- --widget <slug> needs
0.12.2; and the SSR preview only falls back to the manifest's declared assets.css when
the bundle exports no getStaticAssets from 0.12.3, which is the shape the suite
scaffold writes — before that, its SSR preview rendered unstyled.
Options
npx create-hr-plugin <plugin-slug> [options]| Option | Default | Notes |
| --- | --- | --- |
| --template <single\|suite> | single | Project shape — see below. Frozen at your first publish. --template=<t> works too; anything else (including --template with nothing usable after it) exits 1 |
| --author <name> | git config user.name | Baked into package.json and manifest.json. If git is absent or user.name is unset, the author becomes the literal string Plugin Author |
| --no-install | (off) | Skip npm install — useful in CI / sandboxes, or when the widget-core pin is not on the registry yet |
| --help, -h | | Print usage and exit 0. Passing no arguments at all does the same |
The slug is always the first argument. Put flags after it — a first argument starting
with - is refused (Error: expected a plugin slug first — got "--template".) rather than
turned into a directory name. The CLI is otherwise entirely non-interactive: it prompts for
nothing, and an existing target directory is a hard error.
The slug becomes the plugin id and the first half of every keyword the platform stores against
a customer's widget row. It has to match /^[a-z0-9][a-z0-9-]*$/ — the same rule the publish
audit applies to manifest.id — and the scaffolder additionally refuses dist, widgets and
media, the three directory names a published version prefix owns (the audit reserves those
for widget slugs). Both checks run before a single file is written, rather than at
submission with the project already built and committed.
Which template
| | --template single | --template suite |
| --- | --- | --- |
| Manifest shape | v1: root widgetType / ssr / assets / configSchema | root widgets[] + presets[], one sub-manifest per widget |
| Keyword | plugin:<slug> | plugin:<slug>:<widget> |
| Widgets | exactly one | up to 24, one version and one review for all of them |
| Layout widgets | not possible | yes — named slots, prefill, page assignment |
| CSR-only widgets | not possible (v1 always server-renders) | yes — "ssr": false + expectedHeight |
| Source layout | flat src/ | src/widgets/{slug}/ |
| Ships | Tailwind v4 + a shadcn-style src/components/ui/ kit | no UI kit, no CSS framework (a layout's stylesheet is unisolated on a server-rendered page) |
| Dev sandbox | npm run dev — :3001, MSW-mocked | npm run dev — :3001, all 3 widgets, the layout composing the other two |
| Local SSR preview | npm run dev:ssr — :3003 | npm run dev:ssr -- --widget <slug> — :3003, one widget at a time |
| Build | npm run build → hr-widget-build (iife + SSR pass) | npm run build → hr-widget-build (one iife pass per widget, plus SSR for every widget that isn't "ssr": false) |
The guide's recommendation is start as a
suite,
even for one widget: a suite of one publishes normally and can grow a second widget, a layout
or a preset in any later version. Choose single only when all of these hold — the plugin is
one widget, it always will be, it server-renders, and you want the Tailwind +
src/components/ui/ kit more than you want room to grow.
What you get — --template single
my-widget/
├── manifest.json # Plugin manifest — build:manifest injects configSchema,
│ # the build stamps runtime.widgetCore. Commit it.
├── package.json · tsconfig.json · .env.example
├── vite.config.ts # viteHomerunnerWidget({ slug, command }) — externals, dev CSS, /w/ proxy
├── index.html # Dev page; also the `npm run preview` page (built bundle, no mocks)
├── serve.js # LOCAL PREVIEW ONLY — static server for the built output
├── vercel.json · api/ # LOCAL PREVIEW ONLY — self-hosted demo shim; deletable
├── mocks/fixtures.ts # Dev mock data overrides
├── public/ # mockServiceWorker.js — dev only, excluded from builds
├── scripts/
│ ├── inject-schema.mjs # Pre-build: zod → JSON Schema into manifest.json
│ └── dev-ssr.mts # npm run dev:ssr
└── src/
├── index.tsx # Client IIFE — registerPlugin() + mount()
├── widget.tsx # Your React component + getInitialData()
├── ssr.ts # dehydrateState + getStaticAssets (loaded server-side via vm.Script)
├── ssr-entry.ts # SSR UMD entry barrel
├── config.ts # widgetSchema.extend() + zodToManifestSchema()
├── data.ts # Sample public-api fetcher
├── dev/ # Dev sandbox: mock worker + control bar — never published
├── widget.css
└── components/ui/ # shadcn-style UI (Dialog with elevateDom, Button, Tooltip,
# Carousel, WidgetSection, WebFont, ClientOnly, ResponsiveDialogContent)The UI components live in your plugin source — they're shadcn-style (you own them, edit / restyle / replace freely). Runtime hooks (useTopLevelPortal, usePortalContainer, useShadowHost) come from @homerunner-next/widget-core/runtime and shared utils (WidgetProvider, useWidget, useTranslation, useLocaleDetection, getProxiedImageUrl, getFeedQuery, getWidgetQuery) come from @homerunner-next/widget-core/utils — both are re-exported through src/components/ui/index.ts so you only have one import path.
Walkthrough: Your first widget.
What you get — --template suite
my-suite/
├── manifest.json # widgets[] summaries + presets[] — the suite root
├── package.json · tsconfig.json · .env.example
├── vite.config.ts # viteHomerunnerWidget() — no entry names anywhere
├── index.html · src/dev/ # Dev sandbox: all 3 widgets, mocked (never published)
├── serve.js · vercel.json # LOCAL PREVIEW ONLY — static hosting for the built output
├── scripts/
│ ├── inject-schema.mjs # Pre-build: zod → widgets/{slug}.manifest.json
│ └── dev-ssr.mts # npm run dev:ssr -- --widget {slug}
├── widgets/ # GENERATED by build:manifest — commit it
├── media/ # Marketplace icon + READMEs (authored, committed)
├── mocks/fixtures.ts # Dev mock data; its `widget` factory keys by widget id
└── src/
├── shared/property.ts # Code >1 widget uses, imported relatively
└── widgets/
├── page-frame/ # category "layout": slots, prefill, pages, presets
│ └── ssr-entry.ts # mandatory — a layout must server-render
├── intro-card/ # server-rendered content widget
│ └── ssr-entry.ts
└── quick-facts/ # CSR-only: "ssr": false, expectedHeight,
# and deliberately NO ssr-entry.tsThree widgets, one per surface: page-frame teaches the layout
contract (the mandatory
slots zod field whose keys mirror the manifest slot names, renderedSlots via
LayoutWidgetProps, { category: "layout" } in both registerPluginWidget and mount, and
light-DOM-safe CSS); intro-card is the ordinary server-rendered content widget;
quick-facts is the CSR-only one, and the missing ssr-entry.ts is the point. A root
presets[] entry composes all three plus the platform's own widgets in one click.
The generated README.md documents what the dev sandbox can and cannot cover — chiefly that the mock worker is a page singleton (one scenario, one settings object), and that npm run dev:ssr previews one named widget in isolation, so a layout's server-side slot composition still needs a real feed.
Walkthroughs: Build a suite and Add a layout widget.
Two things the scaffold assumes you know
Settings arrive raw. props.options is the customer's stored settings object exactly as
saved — on the server and on the client, never zod-parsed for you. Call
parseWidgetConfig(configZod, props.options ?? {}) before reading a single field; it applies
your defaults, strips the dashboard's null / "" save artefacts and lifts legacy width
values into the spacing model. Every scaffolded component already does.
→ Settings and options
Your bundle does not ship mount. @homerunner-next/widget-core/runtime is externalised
against the host page's runtime IIFE, so the widget lifecycle (shadow DOM, SSR hydration,
theming) always runs at the platform's current version rather than a copy frozen into your
build. The build records which SDK it used in manifest.runtime.widgetCore; publishing
requires ≥ 0.10.0. Never hand-write that field, and expect manifest.json dirty in git
after every build.
→ Runtime, mount and the DOM contract
Shipping it
npm run build— must succeed from a clean checkout of what you committed, which is exactly what the reviewer runs.- Zip the source (not
dist/, notnode_modules/):git archive --format=zip -o ../my-plugin-1.0.0-source.zip HEAD. - Upload it in the HomeRunner dashboard under Plugins ▸ My plugins ▸ Upload plugin. The audit runs in your browser before any bytes move; a failure lists exactly what is wrong.
- Add review notes, submit, and wait. On approval the reviewer builds your source and the built output is published to an immutable CDN version prefix. Customers then install it per feed from the marketplace.
You host nothing. Third-party plugins cannot be self-hosted — the zip pipeline is the
only path open to you, and serve.js, vercel.json and api/ in a generated project are
local preview scaffolding you may delete. manifest.json ships path-only URLs (e.g.
dist/<slug>.iife.js) which HomeRunner resolves against the published manifest's own URL,
so nothing in your build ever carries an environment-specific origin.
The full preflight checklist — including a script that reproduces the publish-time gates the reviewer never forwards to you — is in Preflight and submit, and version rules are in Publishing, versions and rollback.
Where to read next
| | |
| --- | --- |
| Install and versions | Node floors, the React pins, and what npm actually serves today |
| The two manifest shapes | The --template decision, in full |
| Layout widgets | Slots, renderedSlots, presets, page assignment |
| Config schema and UI schema | What the customer's settings form can render |
| Dev sandbox and mocking · Previewing SSR locally | npm run dev and npm run dev:ssr, and their limits |
| SDK reference | Every export of every @homerunner-next/widget-core subpath |
| Limits and error index · Troubleshooting | Every message this CLI, the build and the publish gates can emit |
SDK reference
See @homerunner-next/widget-core for the full SDK surface — subpath exports for runtime, utils, schema, plugin-schema, spacing, contracts, vite, urls, globals, manifest, plus the dev-only mock and testing subpaths — and the hr-widget-build binary this scaffold's npm run build calls.
License
MIT
