DYE2 (Describe Your Espresso) is a plugin for Decaid — the tablet software for Decent Espresso machines. It manages coffee beans, roast batches, grinders and equipment, and adds a shot dashboard, auto-favourites and recipe editing on top of the stock DYE workflow.
This repo is the source of truth for DYE2. Decaid pulls a pinned release of it as a build asset; nothing here is edited inside Decaid's tree.
| I want to… | Go to |
|---|---|
| Use DYE2 | User manual · Releases |
| Work on the code (human or AI) | Repository map — routes any task to the one note that covers it |
| Build a Decaid plugin | For plugin developers below |
| Read the plugin contract | Decaid's doc/Plugins.md |
Most people never install this by hand. Decaid ships a pinned DYE2 release inside the app and copies it out on first start.
Install a specific version only if you want to run ahead of or behind what Decaid pins:
- Download
dye2.reaplugin-vX.Y.Z.zipfrom Releases. - Decaid → Settings → Plugins → install from ZIP.
Or drop the unzipped dye2.reaplugin/ folder into Decaid's plugins/ directory and enable it.
This repo is a worked example of a Decaid plugin that lives in its own repository and gets bundled into the app from a pinned GitHub release. If you're writing a plugin with a build step (TypeScript, bundler, npm tests), copy this shape.
Read Decaid's doc/Plugins.md first for the general contract — manifest fields, the host API, the HTTP request handler, lifecycle and sandbox rules. This README expands on it from the perspective of a plugin that lives in its own repo: layout, build, release, and the Decaid-side wiring.
A Decaid plugin is not a web app. It is two programs in one repo that never share a scope.
| Plugin runtime | Browser runtime | |
|---|---|---|
| Where | On-device, inside flutter_js |
The tablet's WebView |
| Code | dye2-plugin/src/plugin.ts, pages/, and the module-level parts of utils/ |
The template-literal strings those files emit, plus everything in dev/ |
| Has | host.log, host.emit, host.storage, host.decentProxy, fetch |
Full DOM, fetch, CustomEvent |
| No | DOM, window, document, timers beyond setTimeout/clearTimeout |
host.* |
| Job | Answer HTTP-handler calls — a server-side HTML renderer | Render and interact once the HTML lands |
Note the split runs through files, not just between them: src/utils/dev-api.ts is plugin-runtime TypeScript whose main export is a long string of browser JavaScript.
The plugin runtime emits browser code as strings; it never executes it. Import a browser API into plugin-runtime code and it fails on-device while working fine in your editor.
dye2/
├── dye2-plugin/ # The plugin source (TypeScript, built)
│ ├── src/
│ │ ├── plugin.ts # Entry point — implements PluginInstance
│ │ ├── host.d.ts # flutter_js host API types
│ │ ├── pages/ # Page renderers, roughly one per declared `api` id
│ │ ├── components/ # Web Component experiment — not wired up
│ │ ├── api/client.ts # Earlier REST client — not wired up
│ │ ├── styles/ # Tailwind input + generated CSS
│ │ ├── utils/ # html`` template, escaping, chart, date picker
│ │ └── vendor/ # Vendored browser libs (echarts)
│ ├── test/ # Node test runner (.test.mjs)
│ ├── manifest.src.json # Plugin metadata — SOURCE of manifest.json
│ ├── vite.config.ts # IIFE build → ../dye2.reaplugin/plugin.js
│ └── dev-server.mjs # Local page server, proxies /api/v1/* to a machine
│
├── dye2.reaplugin/ # BUILD OUTPUT — committed, never hand-edited
│ ├── manifest.json
│ └── plugin.js
│
├── dev/ # Legacy plain JS/HTML for Decaid's native DYE pages
├── docs/
│ └── KV_CONTRACT.md # Schemas for the KV keys DYE2 owns
├── rea_restapi.yml # Decaid REST OpenAPI spec
└── websocket_v1.yml # Decaid WebSocket AsyncAPI spec
A shipped plugin is exactly two files — manifest.json and plugin.js in a folder named after the plugin id. Everything else in this repo exists to produce those two files.
dev/ predates the TypeScript rewrite; its pages have since been ported into the plugin. No build step; edit and reload. Nothing in Decaid's own tree references it, so treat it as reference material rather than a live surface.
cd dye2-plugin
npm install
npm run build # vite → ../dye2.reaplugin/{plugin.js,manifest.json}; runs tailwind itself
npm run serve # dev server on :4444 + watch build; proxies /api/v1/* to a real machine
npm run dev # the watch build alone
npm test # 24 suites under test/, run in series (see package.json)test names bc-map.test.mjs and shot-paging.test.mjs explicitly rather than globbing, so a new *.test.mjs does not run until you add it to the script.
Run dev and serve in two terminals — the dev server reloads when plugin.js changes on disk.
PORT=4000 BRIDGE_URL=http://192.168.1.5:8080 npm run servePoint BRIDGE_URL at a tablet or a desktop Decaid instance and the pages talk to real beans, shots and machine state without deploying anything.
manifest.src.json is the source; vite.config.ts copies it into the build output as manifest.json. Never create a second file literally named manifest.json outside the output folder — Decaid finds a plugin root by looking for directories that contain one and refuses to install when an archive has more than one candidate.
DYE2's shape:
{
"id": "dye2.reaplugin",
"author": "Streamline",
"name": "Streamline/DYE2",
"description": "Beans, grinders, and shot workflow for Decent Espresso.",
"version": "0.1.19",
"apiVersion": 1,
"permissions": ["log", "api", "emit", "pluginStorage", "events.shots", "events.workflow"],
"settings": {},
"api": [
{ "id": "dashboard", "type": "http", "data": {} },
{ "id": "bean-picker", "type": "http", "data": {} },
{ "id": "changed", "type": "websocket", "data": {} }
]
}DYE2 declares 20 api entries, 19 http and one websocket; three are shown. author and description are required — Decaid reads them into non-nullable fields, so omitting either fails the install with Invalid manifest.json.
idbecomes a directory name. Single safe path component — no/,\,.,.., no Windows-reserved names.permissionsare enforced.host.log,host.emitandhost.storagethrowPluginPermissionErrorwhen undeclared;fetchandhost.decentProxyreturn a rejected promise instead. Declare the minimum: every added permission blocks automatic updates until a user approves it (see Permission escalation). Theevents.*permissions decide which events reachonEvent: DYE2 takesevents.shotsforshotStored/shotUpdated(it recomputes recent auto-favourites) andevents.workflowforworkflowUpdated, which carries the whole workflow on every change and is the only push a plugin gets for it —GET /workflowhas no revision and no socket.apientries each declare one endpoint.type: "http"is served at/api/v1/plugins/<id>/<api-id>;type: "websocket"at/ws/v1/plugins/<id>/<api-id>, which streams whatever the plugin passes tohost.emit('<api-id>', payload)— DYE2'schangedendpoint is that, and a skin watching it re-reads instead of polling. Decaid calls__httpRequestHandleron your instance, aliasinghandleHttpRequestonto it if that is what you defined — this repo defines__httpRequestHandlerdirectly. DYE2'sdashboardid meansGET /api/v1/plugins/dye2.reaplugin/dashboardreturns a full HTML page.settingsare rendered by skins straight from this schema. Give every setting alabeland adescription— without alabelthe user sees the raw storage key.
- Browser code as string exports. A runtime with no DOM ships browser JavaScript by returning it as text. Shared browser code lives in
src/utils/—dev-api.ts,shot-paging.ts,chart.ts,shared-components.ts,icons.ts— each exporting a template literal that the shell inlines as a<script>tag. - Page assembly. Every live page calls
devPageShell(title, content, styles, scripts, opts)fromsrc/utils/dev-shell.ts, passing the shared strings plus its own orchestration script. For example,pages/grinders.tsends withdevPageShell("Grinders", content, styles, [devApiScript, pageScript]). - HTML safety. The
htmltagged template insrc/utils/html.tsdoes not escape. Wrap anything user-supplied inescapeHtml()from the same file. - API base. Browser code calls
/api/v1/*directly. The dev server proxies it; Decaid routes it in production. No base-URL config anywhere. - Not every module is a page.
src/utils/recent-favs.tsandsrc/utils/workflow-record.tsrun in the plugin runtime as real TypeScript, not as exported strings:plugin.tsimports them, they touch no DOM, they takefetchas an argument, and sonode:testimports them directly (test/recent-favs.test.mjs,test/workflow-record.test.mjs). That is where work belongs that has to happen without a page open — answering an event, or writing the store after a shot. Code shaped this way is type-checked and testable; the page strings are neither.
Two directories are historical and wired to nothing: src/components/ (a Web Component experiment, and the only place in the tree using customElements.define or CustomEvent) and src/api/client.ts. pageShell() in pages/layout.ts is the older three-argument shell they were built against; it has no remaining callers. Read them for context, but copy devPageShell and the utils/ strings.
Beans and grinders are first-class Decaid resources:
GET /api/v1/beans
GET /api/v1/grinders
Auto-favourites, recipes, (for now) filter baskets, and equipment have no resource of their own, so DYE2 persists them in Decaid's generic per-plugin KV store:
GET /api/v1/store/dye2.reaplugin/autoFavourites
GET /api/v1/store/dye2.reaplugin/recipes
GET /api/v1/store/dye2.reaplugin/baskets
GET /api/v1/store/dye2.reaplugin/equipment
No auth. Returns a JSON array, or null if the key was never written — treat null as []. Full schemas, field-by-field, plus a worked query example: docs/KV_CONTRACT.md.
One more key is a single object rather than an array:
GET /api/v1/store/dye2.reaplugin/selection
the recipe or favourite currently being dialled in (null when none). A skin arms it with POST /api/v1/plugins/dye2.reaplugin/selection {kind, id} — {} clears — right after it applies an item. DYE2 then watches workflowUpdated and writes the dashboard's dose, yield and grind edits into that item itself, so a skin never has to touch the arrays. It is written through host.storage, which lands in this same KV store, which is why it is readable at the URL above.
The KV route is not scoped to the owning plugin. Any skin can already read and write these keys. DYE2 is the sole writer by convention, not by enforcement — don't
POST/DELETEkeys you don't own. There is no revision or ETag either, and every mutation rewrites a whole array, so a second writer races the entire collection rather than one row.
A consumer does not have to poll: DYE2 host.emits on its changed websocket endpoint after it writes, and Streamline's plugin-surface live channel re-reads on that push.
Baskets are transitional: decaid#727 adds a real /api/v1/equipment resource to migrate onto.
Full API surface: rea_restapi.yml and websocket_v1.yml.
The git tag is the version. Never hand-bump ahead of a tag — the workflow sets the version from the tag itself.
git tag v0.2.0
git push origin v0.2.0.github/workflows/release.yml then:
- Rewrites
manifest.src.json.versionandpackage.jsonto the tag minus itsv. - Builds (
npm ci && npm run build). - Validates the output — both files non-empty,
idmatches,apiVersionandversionpresent,plugin.jscontainscreatePlugin. - Zips with
dye2.reaplugin/as the top-level entry — the bundling fetch script unpacks intoassets/plugins/and then readsassets/plugins/dye2.reaplugin/manifest.json, so the folder must be there. (Decaid's own installer is more relaxed: it accepts a flat zip or a single wrapper directory, and takes the install directory name frommanifest.id.) - Publishes
dye2.reaplugin-vX.Y.Z.zipto Releases.
Pushes to main carry no tag, so they skip 1–5 and instead publish the built package to a dist branch — see the rules below.
Rules the workflow can't enforce for you:
- Commit the rebuilt
dye2.reaplugin/. A push tomainwhose committed build differs from a clean build raises a CI warning. Notemainitself is not installable from a branch — its root has nomanifest.json— so branch installs go throughdistor a release. - The
distbranch root is the contents ofdye2.reaplugin/, not the folder. Decaid descends exactly one level under GitHub's archive wrapper to find the package root, so adye2.reaplugin/directory inside the branch would be one level too deep and fail with "Plugin package has no manifest.json". A branch root holding two manifest-bearing directories also fails, deliberately. - Release and
distshare one install slot. Both carry the samemanifest.id, so switching channel overwrites the recorded source. Install one or the other, not both. - Keep the branch head at or above the latest tag. Decaid refuses any install that would downgrade an existing one unless downgrades are explicitly allowed. After tagging, bring
main's manifest version up to the released version and commit the rebuild — this is the one bump you make by hand, and it follows the tag rather than leading it. - That rule governs
disttoo, and it bites in the window between a release and that bump. A tag build takes the tag's version whilemain's committed manifest still holds the previous one, sodistpublishes the older version; installing it over the release is then refused as a downgrade (HTTP 409, "Plugin dye2.reaplugin X.Y.Z is installed; remove it before installing …"). Bumpmainbefore relying ondist, or install the release. distis a rolling target.force_orphanrewrites the branch on every push tomain, so the recordedsource.commitmoves and "install from dist" can never be pinned. Pin with a release when that matters.
Bundling means Decaid ships your plugin inside the app and copies it out at startup. It is for plugins Decent wants on every install — otherwise just publish releases and let users install from GitHub.
Two models exist. This repo is the second.
| In-tree | External repo (this one) | |
|---|---|---|
| Lives in | assets/plugins/<id>.reaplugin/, committed to Decaid |
Its own repo; release ZIP fetched at build time |
| Build step | None — hand-written plugin.js |
Anything you like |
| Decaid's copy | Tracked in git | Gitignored — a build artifact |
| Examples | time-to-ready, visualizer, settings, decent-profile |
dye2, shot-upload |
- A public GitHub repo that publishes
<id>.reaplugin-vX.Y.Z.zipon every tag. - The zip contains one top-level folder named exactly
<id>.reaplugin, holdingmanifest.jsonandplugin.js. manifest.json.versionequals the tag without itsv.plugin.jscontainscreatePlugin.
Copy scripts/fetch_dye2_plugin.sh and change the names. It pins a version and a SHA-256, then hard-fails on any contract violation:
pinned_version="v0.1.4"
pinned_sha256="fd8e43af…"
pinned_api_version="1"It downloads the release asset, verifies the checksum, unpacks into assets/plugins/, and asserts the manifest id, the manifest version against the tag, apiVersion, the required permissions, and the createPlugin entry point. A drifted release breaks the build instead of shipping quietly.
Then the wiring, all in Decaid:
| File | Change |
|---|---|
scripts/fetch_<name>_plugin.sh |
New — copy the dye2 one |
.gitignore |
Ignore assets/plugins/<id>.reaplugin/ — build output |
pubspec.yaml (assets:) |
Add - assets/plugins/<id>.reaplugin/ |
lib/src/plugins/plugin_loader_service.dart |
Add the path to _getBundledPluginPaths() |
lib/src/plugins/plugin_source_service.dart |
Add '<id>.reaplugin': 'owner/repo' to bundledPluginRepos |
test/plugins/bundled_plugin_permissions_test.dart |
Add your permission floor to requiredPermissions |
.github/workflows/{pr-checks,develop-builds,release}.yml |
Call your fetch script beside the existing ones — ~28 call sites across the three. Miss release.yml and your plugin is absent from every shipped build |
bundledPluginRepos is what lets an installed copy keep receiving your releases after install — without it your plugin freezes at whatever version Decaid pinned.
The permissions test asserts containsAll, so it's a floor, not an exact match: extra permissions pass, a dropped one fails.
The bundled copy is the app-owned floor. In a release build, Decaid compares versions on every startup:
- bundled newer than installed → bundled wins, replaces it
- bundled equal or older → installed copy is left alone
So a user who installed a newer release from GitHub keeps it, and a hand-edit survives as long as the bundled version hasn't moved past it.
In debug and profile builds there is no comparison — the bundled copy overwrites the installed one unconditionally at every start. If you develop against Decaid running from source, expect your installed build to be replaced on each launch; install through the Plugins screen after startup, or test against a release build.
An automatic update installs only when the candidate manifest requests the same permissions or fewer. Asking for anything new parks it as a pendingUpdate — surfaced in the Plugins screen and in GET /api/v1/plugins — until approved:
curl -X POST http://tablet:8080/api/v1/plugins/dye2.reaplugin/update/approvePlan permissions ahead. Adding one later means every user has to click through.
When you cut a release, open a normal PR against Decaid updating pinned_version and pinned_sha256 in your fetch script:
shasum -a 256 dye2.reaplugin-v0.2.0.zipIf the release added permissions, update requiredPermissions in the test in the same PR.
- Rename
dye2-plugin/→<your-plugin>/and setid,name,author,descriptioninmanifest.src.json. - Point
vite.config.tsoutput at../<your-id>.reaplugin/. - Declare your
apipage ids in the manifest and add one file per id undersrc/pages/. - Trim
permissionsto what you actually call. - Keep
.github/workflows/release.yml— change only the folder and artifact names. - Tag
v0.1.0and confirm the release zip has<your-id>.reaplugin/at its top level. - Ship it standalone first. Propose bundling only once it's stable.