mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
465480630b
The time budget for an exec script was read after every statement, the last one included. A single statement without args, or a script whose closing COMMIT crossed the budget, had already run and committed, yet the call threw a time budget error. A plugin that retries on error then wrote the same rows a second time. The script now reads the clock before each statement after the first, so it still stops before the next statement once the time is up and rolls back a transaction it opened, but a script whose statements have all run is reported as done. query() likewise reads the clock only once another row has come back, so a result read to its end is returned. The ctx.db doc in the SDK said a single statement was not timed, which contradicted the query budget. It now names what is really untimed: an exec with bound args and migrate. README and wiki describe the script stopping before its next statement.
511 lines
30 KiB
Markdown
511 lines
30 KiB
Markdown
# trek-plugin-sdk
|
||
|
||
The SDK for building [TREK](https://github.com/liketrek/TREK) plugins.
|
||
|
||
The path is four commands: **`create` → `dev` → `status` → `publish`**. Everything
|
||
else the CLI can do is a step one of those already does for you.
|
||
|
||
## Scaffold a plugin
|
||
|
||
```bash
|
||
npx trek-plugin-sdk # no command? a guided menu of everything below
|
||
npx trek-plugin-sdk create # interactive wizard (id, type, icon, permissions, egress)
|
||
npx trek-plugin-sdk create my-plugin --type widget # or non-interactive
|
||
cd my-plugin
|
||
```
|
||
|
||
The wizard asks for the id, type, icon (validated against lucide), permissions,
|
||
egress hosts and required addons, and offers to initialize a git repo and install
|
||
dependencies for you. In a non-interactive shell (CI, pipes) every command stays
|
||
flag-driven with plain output — no prompts, and machine output (`entry` JSON,
|
||
`pack --json`, PR URLs) stays on stdout.
|
||
|
||
What you get **runs and packs immediately** — but it is not publishable yet: the
|
||
README is a template and there is no screenshot. That is deliberate. Writing those
|
||
is your job, and `status` tells you exactly what is left.
|
||
|
||
## Develop with a live reload loop
|
||
|
||
`dev` runs your plugin locally — no full TREK needed. It injects a `ctx` that
|
||
enforces exactly the permissions your manifest grants (an ungranted call throws
|
||
`PERMISSION_DENIED`, so you catch missing grants), backs `db:own` with a real
|
||
SQLite file, serves your routes and your page/widget UI, and reloads on save.
|
||
|
||
```bash
|
||
npx trek-plugin-sdk dev # http://localhost:4317 — dashboard, routes, UI
|
||
```
|
||
|
||
Open **`/preview`** to see a page/widget rendered in a real sandboxed frame with a
|
||
theme/accent/appearance toggle (`trek.invoke()` is proxied to your routes). Hit a route
|
||
as an unauthenticated request with `?_anon=1`. Drop a `dev-fixtures.json` (trips, users,
|
||
config) next to your manifest to feed `ctx.trips` / `ctx.users`.
|
||
|
||
## Know where you are
|
||
|
||
```bash
|
||
npx trek-plugin-sdk status # never fails — it's a map, not a gate
|
||
```
|
||
|
||
`status` runs every check the TREK-Plugins registry enforces that can be answered
|
||
without a network — which is nearly all of them — and prints the whole journey as a
|
||
checklist grouped by stage (Manifest, Code, Docs, Release, Repo): what passes, what
|
||
does not, and the **one** command to run next. Run it whenever you are not sure what
|
||
to do; it deliberately never exits non-zero.
|
||
|
||
`validate` is the same checks with an exit code. It is the form for scripts and CI;
|
||
`status` is the form for humans. A plugin that passes `validate` will pass every
|
||
registry gate that does not require the tag and the release to exist — those four
|
||
run in `preflight`, which `publish` does for you.
|
||
|
||
## Build a native UI (page / widget)
|
||
|
||
The UI is a sandboxed, opaque-origin iframe that can't load TREK's stylesheet — so the
|
||
SDK ships it. Put **one line** in your `client/index.html` `<head>`:
|
||
|
||
```html
|
||
<!-- trek:ui -->
|
||
```
|
||
|
||
`dev` and `pack` expand it into the inlined **design kit**: token-driven styles that
|
||
follow the host's theme and accent (glass, cards, `.trek-btn`, `.trek-input`,
|
||
`.trek-chip`, `.trek-row`, hover), plus a `window.trek` bridge:
|
||
|
||
```js
|
||
trek.onContext((ctx) => { /* ctx.theme, ctx.tokens, ctx.appearance, ctx.user, ctx.tripId */ })
|
||
const data = await trek.invoke('/status') // calls your own route, host-proxied
|
||
trek.notify('success', 'Saved')
|
||
```
|
||
|
||
### Keep UI state for this browser tab
|
||
|
||
The opaque frame cannot use browser storage directly. The bridge provides
|
||
host-managed, JSON-only session state instead. It survives an iframe remount and
|
||
page reload in the same browser tab, but is not durable data. Do not use it to
|
||
store secrets.
|
||
|
||
```js
|
||
// Default: this user + this plugin + this browser tab.
|
||
await trek.session.set('dismissed-onboarding', true)
|
||
const dismissed = await trek.session.get('dismissed-onboarding')
|
||
|
||
// Explicitly partition state by the trip in view (fails without a trip context).
|
||
await trek.session.set('filters', ['flight', 'hotel'], { scope: 'trip' })
|
||
```
|
||
|
||
`get()` returns `undefined` for a missing key. `set`, `get`, `remove`, and
|
||
`clear` accept `{ scope: 'plugin' | 'trip' }`; `plugin` is the default. Use your
|
||
plugin's own logical key (for example `reservation:88:expanded`) for any finer
|
||
partitioning. Each plugin or individual trip scope is limited to 32 keys,
|
||
64 characters per key, and 1 KiB per JSON value.
|
||
|
||
The kit applies the theme, mirrors the appearance flags (reduced-motion,
|
||
no-transparency) and auto-reports your height. It also upgrades any native
|
||
`<select>` into a host-styled, keyboard-accessible dropdown that matches TREK —
|
||
the OS-drawn popup never could. Write a plain `<select>` and it just works; add
|
||
`data-trek-native` to opt a field out. See the
|
||
[Plugin Development wiki](https://github.com/liketrek/TREK/wiki/Plugin-Development)
|
||
for the full component + token reference.
|
||
|
||
## Write a plugin
|
||
|
||
```js
|
||
const { definePlugin } = require('trek-plugin-sdk')
|
||
|
||
module.exports = definePlugin({
|
||
async onLoad(ctx) {
|
||
await ctx.db.migrate('001', 'CREATE TABLE cache (k TEXT PRIMARY KEY, v TEXT)')
|
||
},
|
||
routes: [
|
||
{ method: 'GET', path: '/status', auth: true, async handler(req, ctx) {
|
||
return { status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":true}' }
|
||
}},
|
||
],
|
||
})
|
||
```
|
||
|
||
Your plugin runs in an **isolated child process**. `ctx` is the only way to reach
|
||
TREK, and it grants exactly the permissions your `trek-plugin.json` declares — an
|
||
ungranted call throws `PERMISSION_DENIED`.
|
||
|
||
### What a ctx call returns
|
||
|
||
Every ctx method falls into one of three kinds, listed per method in the exported
|
||
`PLUGIN_METHOD_RESULT`:
|
||
|
||
- **An entity** (a list where the entry ends in `[]`). The trip data: `trip`,
|
||
`place`, `day`, `reservation`, `accommodation`, `packingItem`, `packingBag`,
|
||
`tripFile`, `fileLink`, `budgetItem`, `assignment`, `dayNote`, `todo` and `user`;
|
||
the collab board: `collabNote`, `collabPoll` and `collabMessage`; the acting
|
||
user's own data: `tag`, `category`, `journey`, `journalEntry`, `bucketItem` and
|
||
`collection`. Each row carries exactly the fields in `PLUGIN_ENTITY_FIELDS[entity]`
|
||
and no others: the table's published columns in snake_case, then what TREK adds on
|
||
top (for a trip `day_count`, `place_count`, `is_owner`, `owner_username` and
|
||
`shared_count`; for a place `category`, `tags` and `ratings`; for a reservation
|
||
`endpoints`, `travelers` and `day_positions`; for a day its `assignments` and
|
||
`notes_items`). A field is only present when the method's read produces it, so
|
||
`trips.getById` has no `day_count` while `trips.listMine` does. Credentials are
|
||
never part of a row (a trip's calendar `feed_token`, any account data beyond a
|
||
user's id, username, display name and avatar). A column TREK adds later is not
|
||
delivered until TREK publishes it in this list, so a row never changes shape behind
|
||
your back.
|
||
|
||
The same holds for the rows inside a row. `PLUGIN_ENTITY_NESTED` names, per entity,
|
||
the fields that hold rows of another entity (a day's `notes_items` are `dayNote`
|
||
rows, a reservation's `endpoints` are `reservationEndpoint` rows), and those carry
|
||
that entity's fields and no others, however deep they sit. Every other added field
|
||
(counts, `{ user_id, username }` lists, id lists, a place's `category`) is a value
|
||
TREK builds field by field. Three results are envelopes around rows rather than a
|
||
row: `collections.listMine` (`collectionListing`), `collections.get`
|
||
(`collectionDetail`) and `vacay.mine` (`vacayPlanData`).
|
||
|
||
The `Trip`, `Place`, ... interfaces name the commonly used fields and are checked
|
||
against these lists when the SDK is built; `Place.day_id` is deprecated and never
|
||
delivered, since a place's days are the `assignments` of `ctx.trips.getDays()`.
|
||
- **`readModel`**: a small result TREK builds field by field from named columns, so
|
||
no stored row passes through it whole: a bag's members, the Atlas `visited` codes,
|
||
what a Vacay toggle did, a collection's saved place or copy summary, and a journal
|
||
photo. These are typed `unknown`; treat a field you did not check for as optional.
|
||
- **`host`**: a value the host builds itself (`{ deleted }`, `{ sent }`, a model's
|
||
answer, an access token) or data you own (`ctx.db`, `ctx.meta`, another plugin's
|
||
answer), returned as it is.
|
||
|
||
## Settings
|
||
|
||
Declare settings in `trek-plugin.json`; TREK renders the form, you write no settings UI.
|
||
|
||
```json
|
||
"settings": [
|
||
{ "key": "api_url", "label": "API URL", "default": "https://api.example", "required": true },
|
||
{ "key": "retries", "label": "Retries", "input_type": "number", "default": 3 },
|
||
{ "key": "api_key", "label": "API key", "scope": "user", "secret": true, "required": true },
|
||
{ "key": "region", "label": "Region", "input_type": "select",
|
||
"options": [{ "value": "eu", "label": "EU" }, { "value": "us", "label": "US" }], "default": "eu" }
|
||
]
|
||
```
|
||
|
||
| Attribute | Meaning |
|
||
|---|---|
|
||
| `key` | Identifier (`^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$`). |
|
||
| `scope` | `instance` (default) — set once by the admin, arrives in `ctx.config`. `user` — per user, read with `await ctx.settings.get(key)`. |
|
||
| `input_type` | `text` (default), `password`, `number`, `checkbox`, `select` (with `options`). |
|
||
| `default` | The field's value wherever nobody set one. The form pre-fills it **and the runtime resolves it** — `ctx.config.api_url` / `ctx.settings.get('region')` return it until someone saves something else — so a plugin with sensible defaults works before anyone opens the form. Satisfies `required`. Not allowed on a `secret` (the manifest is public); must be a boolean on a `checkbox` and one of the `options` when declared. `trek-plugin dev` seeds its fixtures from these too. |
|
||
| `required` | Enforced: the form refuses Save while the field is blank, and the host answers `400 { error: 'Missing required setting "<key>"' }` if a save reaches it anyway. A `checkbox` is exempt (that would be consent, not a setting). A required `scope:'user'` field also gates whether a notification channel dispatches to that user. |
|
||
| `secret` | Encrypted at rest, decrypted only into the server-side `ctx.config` / `ctx.settings`, masked to the browser, never sent to the iframe. |
|
||
| `label`, `placeholder`, `hint` | Form text. |
|
||
| `options` | `[{ "value", "label" }]` for a `select` (bare strings are accepted and coerced). |
|
||
| `oauth` | Descriptive metadata only — for a host-brokered flow see the OAuth broker below. |
|
||
|
||
Any other attribute is **silently dropped at install**. `trek-plugin validate` warns on
|
||
one (`manifest.settings-known-keys`) so a typo like `"defalt"` doesn't quietly do nothing.
|
||
|
||
### Actions
|
||
|
||
Up to 8 buttons the host renders on a settings form, declared in `trek-plugin.json`
|
||
and implemented in `definePlugin({ actions: { <key>: async (ctx) => ({ ok, message }) } })`:
|
||
|
||
```json
|
||
"actions": [
|
||
{ "key": "testConnection", "label": "Test connection", "hint": "Pings the API." },
|
||
{ "key": "purgeCache", "label": "Purge cache", "scope": "instance", "danger": true }
|
||
]
|
||
```
|
||
|
||
| Attribute | Meaning |
|
||
|---|---|
|
||
| `scope` | `user` (default) — renders on the user Settings tab and runs as the clicking user. `instance` — renders in the admin instance-settings dialog and runs as the clicking admin. The default differs from settings fields on purpose: existing manifests keep their user-tab buttons. A host older than TREK 4.2.0 (which introduced action scope) ignores this field entirely and renders the button on every user's Settings tab, running it as each user — so if you declare `scope: "instance"`, set the manifest's `trek` range floor to `>=4.2.0` or the button will run in the wrong place on an older host. |
|
||
| `danger` | Rendered destructive; the host asks for confirmation first. |
|
||
| `hint` | Shown beside the button (≤ 200 chars). |
|
||
|
||
Either way the handler gets the clicking person as acting user: `ctx.config` is the
|
||
instance config, `ctx.settings.get()` returns their own user value, and trip reads are
|
||
membership-checked against them. Return `{ ok, message }` or throw to report a failed
|
||
action (the host shows the message, emoji-stripped, ≤ 200 chars). An instance action is
|
||
disabled in the dialog until the plugin is active — there is no child process to run it.
|
||
In the mock host, pass `declaredActions: ['testConnection', { key: 'purgeCache', scope: 'instance' }]`
|
||
and drive either with `drv.action(key)`.
|
||
|
||
`ctx.config` is frozen at activation — the host re-spawns the plugin when the admin
|
||
saves. `ctx.settings.get()` is live per call and returns `undefined` in a userless
|
||
context (`onLoad`, jobs, scheduler) — fall back to `ctx.config` there.
|
||
|
||
## Test without a running TREK
|
||
|
||
```js
|
||
import { createMockHost } from 'trek-plugin-sdk/testing'
|
||
|
||
const { ctx, broadcasts } = createMockHost({
|
||
grants: ['db:read:trips', 'ws:broadcast:trip'],
|
||
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
|
||
})
|
||
// the mock enforces the SAME permission model, so you can prove your plugin
|
||
// degrades gracefully when a permission is missing.
|
||
```
|
||
|
||
## Runtime limits
|
||
|
||
The host enforces these limits on every ctx call and hook invocation. They are generous
|
||
for a legitimate plugin and only bite a runaway or abusive one — build against them so
|
||
`dev`/`mock-host` behavior matches production.
|
||
|
||
| Area | Limit |
|
||
|---|---|
|
||
| `ctx.ai` | 200 calls/day per plugin (UTC midnight rollover); past it throws `"daily AI budget exhausted (resets at UTC midnight)"` |
|
||
| `ctx.notify` | 100 calls/day per plugin (UTC midnight rollover); past it throws `"daily notification budget exhausted (resets at UTC midnight)"` |
|
||
| RPC (every `ctx.*` call) | burst 60, sustained 20/s, 16 in-flight per plugin; a throttled call is refused with `HOST_ERROR: rate limit exceeded — slow down ctx.* calls` |
|
||
| `ctx.db` (your own sqlite) | 256 MB per plugin; a `query` or `tx` returns at most 100,000 rows (a whole `tx` batch shares one count). A `query`, a whole `tx` batch and an `exec` script without bound args each have 2 s of wall-clock time, checked between the rows a statement reads and between the statements of a batch or script; past either limit the call throws (`query exceeded its 2000 ms time budget`), a `tx` rolls back, and an `exec` script stops before its next statement once the time is up (the statements that ran stay applied, as they always did, and a transaction the script opened itself is rolled back; a script whose last statement has run counts as done, however long it took). Split a longer script over several calls, or batch it with `tx`. `migrate` has no time budget, since a migration stopped part of the way would fail again on every start. `ATTACH`, `DETACH`, `VACUUM`, `PRAGMA`, `WITH RECURSIVE` and `load_extension` are refused. The budget cannot stop a single statement, because SQLite runs it on TREK's main thread in one step: a write runs to its end (an `INSERT ... SELECT` over a cross join of large tables), and a read runs until its first row (an aggregate over one). Keep your statements indexed and bounded |
|
||
| `ctx.meta` | 64 KB per value, 256 chars per key, 100 keys per (plugin, entity) |
|
||
| Plugin process | 300 MB RSS ceiling; auto-disabled after 5 crashes in 5 minutes |
|
||
| Event redelivery buffer | 200 events held per plugin, dropped unreplayed after 15 minutes |
|
||
|
||
Hook contribution caps (declarative content the host renders — a slow/failing call is
|
||
skipped, never fatal):
|
||
|
||
| Hook | Cap |
|
||
|---|---|
|
||
| `pdfSectionProvider` | ≤5 sections per provider |
|
||
| `atlasLayerProvider` | ≤3 layers per provider |
|
||
| `mapMarkerProvider` | ≤200 markers per provider |
|
||
| `warningProvider` | ≤20 warnings per provider, each message ≤300 chars |
|
||
| `placeDetailProvider` | ≤12 items per provider |
|
||
| `searchProvider` | `search`: ≤20 places per provider, 2 s to answer. `suggest` (optional): ≤3 places in the dropdown across all providers, 800 ms to answer |
|
||
| `poiCategoryProvider` | ≤4 declared categories per plugin; ≤60 places per answer (inside `bounds` only), ≤6 detail rows per place, 8 s to answer |
|
||
| `photoProvider` | ≤60 photos per page |
|
||
| `calendarSource` | ≤500 events per source per request |
|
||
| `tableContributor` | ≤20 columns / ≤10 actions per entity |
|
||
| `tripCardProvider` | ≤4 badges per trip, ≤240 total per provider |
|
||
|
||
## OAuth broker
|
||
|
||
`ctx.oauth.getAccessToken()` is host-brokered outbound OAuth: the plugin never becomes a
|
||
direct OAuth client. The host runs the whole flow (authorize → callback → token exchange
|
||
→ refresh) with PKCE and a 10-minute state TTL, and holds the refresh token + client
|
||
secret — a plugin only ever sees a short-lived access token for the ACTING USER, or
|
||
`null` when they haven't connected (or in a userless context).
|
||
|
||
Provider config is the plugin's admin-owned INSTANCE settings — declare `scope:'instance'`
|
||
manifest fields named `oauth_authorize_url`, `oauth_token_url`, `oauth_scopes` (optional),
|
||
`oauth_client_id`, `oauth_client_secret`, and the admin fills them in from Settings →
|
||
Plugins → Connect. Both endpoint URLs must be `https` and may not point at loopback,
|
||
`.local`/`.internal` names, or private/metadata addresses — the host refuses to start a
|
||
flow against them. The host exposes
|
||
`/api/plugin-oauth/:id/status|connect|callback|disconnect` to drive the connect UI;
|
||
plugin code itself only ever calls `getAccessToken()`.
|
||
|
||
## Capture the screenshot
|
||
|
||
The registry requires a screenshot that resolves to a real image, and the store card
|
||
shows it. `shot` boots the dev server, renders your plugin in the same themed frame
|
||
TREK uses, and writes a 1600×900 `docs/screenshot.png`:
|
||
|
||
```bash
|
||
npm i -D playwright && npx playwright install chromium # once
|
||
npx trek-plugin-sdk shot # --dark for the dark theme
|
||
```
|
||
|
||
Playwright is deliberately **not** a dependency of this SDK — it ships a browser, and
|
||
most authors never need one. An `integration` plugin has no UI to render, so `shot`
|
||
cannot help; screenshot the TREK surface your plugin changes instead.
|
||
|
||
## Publish — one command
|
||
|
||
Commit and push your plugin to its public GitHub repo, then:
|
||
|
||
```bash
|
||
npx trek-plugin-sdk publish --repo you/repo --tag v1.0.0
|
||
```
|
||
|
||
`publish` is the whole release, in five steps and in this order:
|
||
|
||
1. **check** — every registry gate that can be checked locally
|
||
2. **pack** — build `plugin.zip`
|
||
3. **release** — git tag, push, and cut the GitHub release with the artifact
|
||
4. **preflight** — the gates that need the tag and the release to exist
|
||
5. **submit** — open the PR against the TREK-Plugins registry
|
||
|
||
Step 1 comes first for a reason: a GitHub release is effectively immutable, because
|
||
the registry pins its sha256. If a check fails, **nothing is packed, tagged, pushed
|
||
or released** — so you can fix it and re-run against the same version. (`--no-checks`
|
||
skips step 1; it is an escape hatch for a re-run, never right on a first publish.)
|
||
|
||
Needs `git` and an authenticated `gh`. It prints the PR URL at the end.
|
||
|
||
**Updating** a listed plugin: bump `version` in the manifest, commit, and run
|
||
`publish` again with the new tag — it detects the existing entry and prepends the
|
||
new version, newest-first. Your TREK-Plugins fork is fast-forwarded automatically
|
||
before the PR branch is cut, so a fork left behind doesn't break the submit (a
|
||
*diverged* fork gets a warning and the `gh repo sync --force` command to fix it).
|
||
|
||
### When a publish fails
|
||
|
||
A failure **after** the release is cut (a preflight or PR problem) rolls back
|
||
exactly what that run created — the GitHub release, the pushed tag, the local
|
||
tag — so the same tag is free to re-run against once you've fixed the problem.
|
||
Nothing that existed before the run is ever touched. Pass `--keep-release` to
|
||
keep the release instead; the error then prints the manual cleanup commands.
|
||
|
||
Two related conveniences:
|
||
|
||
- A leftover tag from an earlier failed run (it has no release and doesn't point
|
||
at `HEAD`) is recognised as debris and **moved to your current commit** instead
|
||
of silently releasing the stale one.
|
||
- `trek-plugin unrelease <vX.Y.Z> --repo you/repo` deletes a stranded release +
|
||
remote tag + local tag in one go. It checks the published registry index first
|
||
and **refuses to touch a version that is actually published** — those artifacts
|
||
are immutable (the registry pins their sha256), so the only way forward there is
|
||
a new version.
|
||
|
||
Prefer to drive the steps yourself? They still exist individually — `pack`,
|
||
`release` (pack → GitHub release → entry), `preflight`, `submit` (opens the PR),
|
||
and `entry` (just prints the JSON).
|
||
|
||
### Signing (recommended — `publish` offers it)
|
||
|
||
In a terminal, `publish` **asks** whether to sign, and creates the key for you if you
|
||
have none — you don't have to know `--sign` or `keygen` exist. Scripts and CI are never
|
||
prompted: pass `--sign`.
|
||
|
||
A signature proves the artifact came from **you**, not just that its bytes match what
|
||
the registry saw. Signing is dependency-free Ed25519 over the artifact bytes.
|
||
|
||
It is a one-way door you may walk through **late**:
|
||
|
||
- Unsigned throughout is fine — the sha256 pin is the only guarantee, and TREK accepts it.
|
||
- **Unsigned → signed later breaks nobody.** Nothing is pinned until a signed version
|
||
installs, so adding a key at v1.4.0 is a real option, not a lost cause. The registry
|
||
does require every version signed once your key appears in the entry — your first
|
||
signed update **retro-signs the older versions for you** (each pinned artifact is
|
||
downloaded, verified against its sha256, and signed with the same key).
|
||
- **Signed → unsigned is refused forever**, on every instance that already has the plugin.
|
||
`publish` refuses that at step 1, before anything is tagged or released.
|
||
|
||
So **back the key up** (`~/.trek-plugin/signing.key`). Losing it means you cannot ship an
|
||
update to your own plugin without a registry maintainer override.
|
||
|
||
```bash
|
||
npx trek-plugin-sdk publish --repo you/repo --tag v1.1.0 --sign # or just answer the prompt
|
||
```
|
||
|
||
## Exports
|
||
|
||
- `definePlugin(def)` + all the plugin types (`PluginContext`, `PluginRoute`, `PluginJob`, `PhotoProvider`, `CalendarSource`).
|
||
- `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest.
|
||
- `PLUGIN_ENTITY_FIELDS`, `PLUGIN_ENTITY_NESTED`, `PLUGIN_METHOD_RESULT`: what each ctx method returns, which fields each entity row carries and which of them hold rows of another entity (see [What a ctx call returns](#what-a-ctx-call-returns)).
|
||
- `validateManifest(json)` — the manifest rules the server loader uses.
|
||
- `settingDefaults(manifest, scope)` — the `default`s of one settings scope, keyed by field: what the host resolves for an unset field, and what `dev` seeds `ctx.config` / `ctx.settings` with. Spread it under your own fixtures when calling `createMockHost` to mirror the host. `SETTING_FIELD_KEYS` is the attribute list the host stores.
|
||
- `createMockHost(opts)` (from `trek-plugin-sdk/testing`).
|
||
- `TREK_UI_CSS`, `TREK_THEME_JS`, `TREK_UI_MARKER`, `injectTrekUi(html)` — the design kit, for authors who inline it themselves (a bundler, a custom build). Most plugins just use the `<!-- trek:ui -->` marker instead.
|
||
|
||
## Commands
|
||
|
||
Run any of these with `npx trek-plugin-sdk <command>` (or the short `trek-plugin`
|
||
bin if you install the package). `trek-plugin help <command>` — or
|
||
`trek-plugin <command> --help` — prints a full page for any of them.
|
||
|
||
**The path:**
|
||
|
||
- `create [name] [--type t] [--template blank|notification-channel] [--interactive]` — scaffold a plugin; a wizard if you omit the name.
|
||
- `dev [dir] [--port 4317]` — run locally with a real request loop, SQLite `db:own`, and hot reload.
|
||
- `status [dir]` — where am I? what's left? Every offline registry gate as a checklist, plus the next command. Never fails.
|
||
- `publish [dir] --repo o/n --tag vX [--sign [key]] [--no-checks] [--no-preflight]` — **the lot**: check → pack → release → preflight → open the PR. In a terminal it offers to sign (and makes you a key); scripts pass `--sign`.
|
||
|
||
**Also:**
|
||
|
||
- `validate [dir]` — the gate: the same checks as `status`, but it exits non-zero.
|
||
- `pack [dir] [--out plugin.zip] [--json]` — build the artifact, print `sha256` + `size`. Refuses a plugin that could not *load*; does not enforce the publish gates, because packing is how you sideload a plugin to try it.
|
||
- `shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve]` — capture `docs/screenshot.png`. Needs Playwright.
|
||
- `keygen [--key file]` — create an Ed25519 signing key.
|
||
- `sign [zip] [--key file]` — print a signature + public key for an artifact.
|
||
- `entry --repo o/n --tag vX [--merge f] [--sign [key]] [--out f]` — emit the registry entry JSON.
|
||
- `preflight [dir] --repo o/n --tag vX [--entry f] [--all]` — the registry checks that need the network: the tag resolves to the pinned commit, the released artifact downloads and hashes, the id is not bound to another owner, and an update does not drop or rotate a published signing key.
|
||
- `submit [dir] --repo o/n --tag vX [--registry o/n] [--draft]` — open the registry PR for you.
|
||
- `release [dir] --repo o/n --tag vX [--sign [key]] [--merge f]` — pack → GitHub release → entry, without opening the PR.
|
||
- `unrelease <vX.Y.Z> [dir] --repo o/n [--yes]` — delete a stranded release + remote tag + local tag. Refuses a version that is published in the registry (immutable); `--yes` consents when the registry index can't be checked.
|
||
|
||
## Update notice
|
||
|
||
Both CLIs (`trek-plugin` and `create-trek-plugin`) tell you when a newer SDK has been
|
||
published. This matters more than the usual "you're on an old version" nag: the
|
||
registry entry format, the manifest rules and the permission catalog all move with the
|
||
TREK host, so a stale SDK can `pack` and `submit` an entry that today's registry CI
|
||
rejects.
|
||
|
||
It is powered by [`update-notifier`](https://github.com/sindresorhus/update-notifier),
|
||
the standard for npm CLIs:
|
||
|
||
- At most **once every 24 hours**, a detached background process asks
|
||
`registry.npmjs.org` for this package's `latest` version and caches the answer under
|
||
`$XDG_CONFIG_HOME/configstore/` (or `~/.config/…`).
|
||
- Your command **never waits for it** — the notice is printed from that cache, so a
|
||
fresh install learns about an update on a later run.
|
||
- The notice goes to **stderr**, so `pack --json` and `entry` keep piping clean JSON.
|
||
|
||
The request is an unauthenticated GET for a public package — the same one `npm install`
|
||
makes (npm's servers see your IP, as for any download). TREK has no telemetry, and this
|
||
isn't any. To turn it off:
|
||
|
||
```bash
|
||
export NO_UPDATE_NOTIFIER=1
|
||
```
|
||
|
||
It is already silent in CI (any `CI` env var), under `NODE_ENV=test`, and whenever
|
||
stdout isn't a terminal (i.e. when piped or redirected).
|
||
|
||
The SDK tooling in this repo is MIT. Your plugin is your own code under your own license.
|
||
|
||
### Roadtrip category searches
|
||
|
||
The `searchProvider.search` request can include `category` and `bounds` (south, west, north, east). Search within that rectangle for the category; `query` remains a readable category query and `near` its centre, so existing providers continue to work. These optional fields are absent on older hosts and ordinary name searches. The host validates and filters coordinates, namespaces IDs, and applies the exact route corridor after combining sources. The existing permission and two-second hook timeout still apply.
|
||
|
||
### Suggestions while the user types
|
||
|
||
`searchProvider.suggest` is optional. Implement it only when your index can take a request per keystroke, typically one you keep in your own database; your rows then appear in the place search's dropdown while the person types, after TREK's own suggestions and labelled with your plugin's name. It gets the same `SearchRequest` as `search`, from the second typed character on, with `limit` 3 and 800 ms to answer, and the dropdown keeps at most three plugin rows across all providers. A picked row is taken as it is, without a details lookup, so fill in address, website and phone right away. The host learns whether your hook has `suggest` when the plugin loads (a class method counts), and never calls it on a plugin without one.
|
||
|
||
### POI categories on the trip map
|
||
|
||
A plugin can add up to four chips of its own to the trip map's **Explore places** pill
|
||
(trailheads, EV chargers, step-free places, drinking water, campsites...) and answer the
|
||
searches for them. Declare them in the manifest and ask for `hook:poi-category-provider`:
|
||
|
||
```json
|
||
"permissions": ["hook:poi-category-provider"],
|
||
"capabilities": {
|
||
"poiCategories": [
|
||
{ "id": "trailheads", "label": "Trailheads", "labels": { "de": "Wanderparkplätze" },
|
||
"icon": "Signpost", "color": "#2f855a" }
|
||
]
|
||
}
|
||
```
|
||
|
||
| Field | Rule |
|
||
|---|---|
|
||
| `id` | `^[a-z][a-z0-9-]{0,23}$`, unique within the plugin. It is what `getPois` receives as `request.category`. |
|
||
| `label` | Plain text, at most 40 characters, shown when `labels` has nothing for the user's language. Emoji and control characters are stripped. |
|
||
| `labels` | Optional: TREK language code to label (`de`, `fr`, `zh-TW`...), same rules as `label`. A code this TREK does not ship is ignored. |
|
||
| `icon` | One of `POI_CATEGORY_ICONS` (exported): Footprints, Mountain, MountainSnow, Signpost, Trees, TentTree, Tent, Accessibility, Droplet, Droplets, PlugZap, Zap, Bath, Bike, Waves, Landmark, MapPin, Star, Heart, Info. Lucide has no toilet glyph; use Bath. |
|
||
| `color` | `#rrggbb` only. It colours the chip and the markers. |
|
||
|
||
Then implement the hook:
|
||
|
||
```ts
|
||
hooks: {
|
||
poiCategoryProvider: {
|
||
async getPois({ category, bounds, lang, limit }, ctx) {
|
||
const rows = await myIndex.within(category, bounds, limit);
|
||
return rows.map((r) => ({
|
||
id: r.id, name: r.name, lat: r.lat, lng: r.lng,
|
||
details: [{ label: 'Length', value: `${r.km} km` }],
|
||
}));
|
||
},
|
||
},
|
||
},
|
||
```
|
||
|
||
The host asks only the plugin that declared the chip, only for a declared id, and only
|
||
while the grant is held. `bounds` is the viewport narrowed to at most 0.5 degrees a side.
|
||
The host drops places outside `bounds`, keeps 60, caps strings and details (label 40,
|
||
value 120 characters, 6 rows), allows only http/https websites, and namespaces ids as
|
||
`plugin:<yourId>:<id>`. A timeout or a thrown error shows as an error on that chip only.
|
||
Answers are not cached, because the hook runs as the user who picked the chip. Also
|
||
`GET /api/plugin-pois` and, for a connected assistant, the `list_plugin_poi_categories`
|
||
and `search_plugin_pois` MCP tools.
|