Shortcut → Windshift migration plugin. One-way continuous sync: stories and comments are imported into Windshift workspaces incrementally (resumable 24 h windows over an advancing watermark) — one shared workspace, or one per Shortcut project. Container entities (epics, projects, workflow states) are synced first as the mapping basis, and Shortcut-side deletes are propagated as tombstone comments via a signed webhook plus a per-window deletion sweep. Operators configure and monitor the sync from an admin-tab panel.
| Surface | How |
|---|---|
| Scheduled sync | sync_tick every 5 m (manifest schedule): catalog pass → windowed story import → comment pass → deletion sweep; resumable cursors in per-plugin KV |
| Delete webhook | POST /webhook/shortcut — HMAC-SHA256 (Payload-Signature) verified; story delete/archive → tombstone comment + KV marker |
| Full-account dump & restore | POST /dump/start, auto-ticked by a dump_tick schedule (10 s) plus the manual POST /dump/tick → NDJSON pieces via GET /dump/piece?n=; a CLI or the panel replays chunks via POST /restore/chunk?seq=n |
| Operator routes | GET/POST /config (secrets write-only), POST /sync/tick, POST /sync/reset, POST /sync/purge (delete everything the sync ever created for a clean re-import), GET /status |
| Admin-tab UI | admin.tab point, iframe at /api/plugins/shortcut/assets/index.html — status panel + config form + tick/reset buttons + dump/restore section; the panel polls itself while a dump or restore is in flight |
| Persistent state | kv_get/kv_set host functions — per-plugin KV, survives restarts |
| Release | task build → dist/shortcut.zip (manifest.json + plugin.wasm + assets/), attached to a GitHub Release by CI |
Standard Go ≥ 1.24 — no TinyGo, no dependencies. The reactor build mode is
required (-buildmode=c-shared): Windshift's Extism runtime skips _start
and initializes via _initialize.
Tools are pinned via mise (.mise.toml): run mise install once, then
go/task resolve to the pinned versions in every shell.
task verify # builds dist/shortcut.zip, then runs the wasm against wazero + mock host functionsThe test harness (test/) instantiates the built wasm with the exact stack
Windshift embeds (github.com/extism/go-sdk v1.7.1 on wazero) and exercises
every route, including the KV host-function round-trip. It catches the two
failure modes that are invisible at compile time: a missing handle_request
export and a command-module build (runtime never initialized).
- Exports (standard Go:
//go:wasmexport):handle_requestis the only required one.get_metadata/get_routesare optional — routes and the extension live inmanifest.json. - Request in:
{method, path, headers, body, query, params} - Response out:
{statusCode, headers, body}(body is a string) - Windshift host functions live in the
extism:host/usernamespace: single-pointer ABI — offset of a JSON request in, offset of a JSON response out (0on failure).plugin/wasm.gohand-rolls the minimalextism:host/envshim (go-pdk's memory package is internal and unimportable) pluskv_get/kv_setimports.
Drop the release zip's contents into a plugin directory:
curl -L https://github.com/NSXBet/windshift-plugin-shortcut/releases/latest/download/shortcut.zip -o shortcut.zip
mkdir -p /data/plugins/shortcut
unzip shortcut.zip -d /data/plugins/shortcutOr upload through Windshift's admin panel (Admin → Module Settings →
Plugins), which persists to /data/plugins on the data volume.
Open Integrations → Shortcut Migration in Windshift's admin, or drive the
same routes directly (GET/POST /api/plugins/shortcut/config). Fields:
| Field | Meaning |
|---|---|
workspace_mode |
single (all items into one workspace), team (one workspace per Shortcut team, board columns = the team's workflow states), or per_project (one workspace per Shortcut project, resolved on demand) |
token |
Shortcut API token (write-only; save with the field blank to keep the stored one) |
enabled |
Master switch — a disabled plugin skips ticks |
dry_run |
Read-only mode: everything is fetched and counted, nothing is written to Windshift |
workspace_id |
Target Windshift workspace decimal id (as in /api/v2/workspaces) — the only workspace in single mode; the epic home + unknown-project fallback in per_project mode |
project_ids |
Shortcut projects to sync (empty = all) |
label_mode |
merge (add to existing labels) or replace |
actor_user_id |
Windshift user id that authors imported comments — required unless dry-run |
webhook_secret |
Shortcut outgoing-webhook signing secret (write-only, same blank-keeps rule) |
backfill_days |
How far before now the first window opens (1–365, default 30) |
Secrets are never echoed back — GET /config reports only
token_set/webhook_secret_set booleans.
single— every imported story/epic lands in the configured workspace, exactly as v0.2.0.per_project— each Shortcut project gets its own Windshift workspace (resolved via the core's find-or-create, cached per tick). Stories inherit their project's workspace; epics are org-level and always land inworkspace_id; stories whose project is missing from the/projectslisting fall back toworkspace_id. A project the core rejects is recorded as an item-level error and retried on the next pass — the rest of the window keeps syncing.team— each Shortcut team gets its own workspace (same find-or-create, seeded with the team's workflow states in board order). Story placement follows the story's workflow state → its workflow's team → that team's workspace; unknown states fall back toworkspace_id. Epics are org-level and always land inworkspace_id.
Stories also carry their assignee and sprint: the first owner_ids member is imported as a
Windshift user on demand (GET /members/{uuid} + member_upsert), and iteration_id imports
its sprint into the story's workspace (iteration_upsert) — both resolve through the core's
mapping tables, and a missing member/iteration silently drops the field rather than failing
the story.
- The manifest schedule ticks every 5 minutes; each tick processes as much as its ~4 s budget allows and parks its cursors, so a slow initial backfill resumes tick after tick.
- Tick now (
POST /sync/tick) runs one tick inline and returns the resulting counts; Reset state (POST /sync/reset) clears cursors only — already-created items stay, and the next tick rebuilds from the watermark. - Deletes: register
https://<windshift>/api/plugins/shortcut/webhook/shortcutas a Shortcut outgoing webhook. Delete/archive events tombstone the mapped item immediately; the end-of-window sweep re-verifies every mapped story of a window with a canonical GET and tombstones anything Shortcut has hard-deleted without a webhook. Tombstoned items are commented and skipped thereafter.
- Dump (
POST /dump/start, thenPOST /dump/tickuntilphase: done): streams every entity — including data the incremental sync never touches (members, milestones, archived stories, epic comments, raw originals) — as one line-oriented NDJSON file assembled from 256 KiB KV pieces (GET /dump/piece?n=0..pieces-1, concatenate in order; while a dump runs,n == piecesserves the open buffer, so partial files download before theendline exists). The first line is the filemeta, the last isend; the stories scan runs newest-first (empty gap years before the org's first story cost span-doubled windows last), and iteration and member bodies are emitted before their window's stories — iterations during window freeze, assignees fetched per story (never as a full roster) — so restore resolves story→sprint and story→user links at upsert time. Ticks run on adump_tickschedule every 10 s plus the manual route, and are budget- and rate-limited: when a tick pauses, its cursor is already saved and the next tick resumes — a full org takes minutes of ticks, not one. - Restore (
POST /restore/chunk?seq=n): post line-aligned chunks; re-post the same chunk until the response marks it consumed, then advance. The first chunk must open with the file'smetaline (mismatched filename → 400); a staleseq→ 409. Replay runs through the sync engine offline: items that already map and comments that already synced are skipped, so a crash-and-repost costs fetch time only. A partial file (noendline — downloaded while the dump was still running) restores everything it contains; the panel warns on download so the tail cut isn't a surprise. - Discard (
POST /dump/discard,POST /restore/discard) deletes the state and pieces — use after a completed restore, before restoring again. The admin panel wires all of this: Start dump / Dump tick / Download file / Restore from file.
- A plugin directory is any subdir of a plugin dir containing
manifest.json; raw.zipfiles in the dir are skipped. - Identity =
manifest.json'sname. Routes not declared in the manifest or returned byget_metadata404 even though the catch-all mounts. - Limits per invocation: 5 s timeout, 64 MiB WASM memory.
Full-loop plugin development against the NSXBet fork's public core image
(ghcr.io/nsxbet/windshift:v0.8.9-p1 — upstream v0.8.9 + one backported
fix: the CSP change that lets plugin admin-tab JS execute; upstream v0.8.9
serves plugin assets with default-src 'none'; sandbox, which breaks any
published tag) on localhost —
no cluster, no image pushes, no release cut per iteration.
Prereqs: Apple container runtime + mise install (pins go/task).
task install # place the built plugin into the container-mounted plugin dir (.dev/plugins)
task dev # boot on :8080 — the boot scan registers every plugin found on disk
task setup # first-run: bootstrap admin (dev / windshift-dev-1), cookie in .dev/cookiePlugin registration happens at container boot (disk scan of /plugins), so
install must precede dev. After that, the hot loop never restarts the
container:
task build install reload # ~2s: rebuild zip → swap files → POST /reload — same bits, no restartDetails worth knowing:
- Asset edits need no reload: core reads plugin assets from disk on every
request — edit
plugin/assets/*, then just refresh the browser tab. Only wasm/manifest changes needreload. reloadre-parses the manifest but can keep serving the old compiled wasm — after wasm changes the reliable deploy iscontainer compose -f compose.yaml down && up -d(a ~7s bounce; the bind mount is re-read at boot). Symptom:/statusreports the new version but tick behavior is stale.- Test harness pins the version:
test/main.goasserts the manifest version, so bump it there together withplugin/manifest.jsonorverifyfails. - Admin-tab scripts must be external files: core serves plugin HTML under
the app CSP (
script-src 'self'+ per-response nonce) — inline<script>is silently blocked. Keep logic inassets/*.js;verifyasserts every file inplugin/assets/ships in the zip. - Core v0.8.9 limitation: it serves plugin tab documents with a
sandboxCSP, so tab JavaScript does not execute there (platform posture, since removed in core main). Routes, KV ABI, reload, and tab registration all work against the pinned image. - The container runs as root (
compose.yaml) because the image's default uid 65534 cannot create the SQLite file in the root-owned data volume. Dev only; the prod chart sets its own security context.
Pristine reset — removes the container, the SQLite volume, and all build output:
task clean- Bump
plugin/manifest.json'sversion. - Tag the same version and push:
git tag v0.3.0 && git push --tags. - CI builds, runtime-tests, and attaches
shortcut.zip+checksums.txtto the release. The tag must match the manifest version.