Skip to content

About

Shortcut -> Windshift migration plugin (Extism WASM)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

26 Commits

Folders and files

Repository files navigation

windshift-plugin-shortcut

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.

What ships now

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

Building

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 functions

The 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).

Plugin ABI (what this repo encodes)

  • Exports (standard Go: //go:wasmexport): handle_request is the only required one. get_metadata/get_routes are optional — routes and the extension live in manifest.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/user namespace: single-pointer ABI — offset of a JSON request in, offset of a JSON response out (0 on failure). plugin/wasm.go hand-rolls the minimal extism:host/env shim (go-pdk's memory package is internal and unimportable) plus kv_get/kv_set imports.

Install

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/shortcut

Or upload through Windshift's admin panel (Admin → Module Settings → Plugins), which persists to /data/plugins on the data volume.

Configuring

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.

Workspace modes

  • 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 in workspace_id; stories whose project is missing from the /projects listing fall back to workspace_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 to workspace_id. Epics are org-level and always land in workspace_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.

Running the sync

  • 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/shortcut as 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.

Full-account dump & restore

  • Dump (POST /dump/start, then POST /dump/tick until phase: 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 == pieces serves the open buffer, so partial files download before the end line exists). The first line is the file meta, the last is end; 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 a dump_tick schedule 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's meta line (mismatched filename → 400); a stale seq → 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 (no end line — 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.

Registry rules

  • A plugin directory is any subdir of a plugin dir containing manifest.json; raw .zip files in the dir are skipped.
  • Identity = manifest.json's name. Routes not declared in the manifest or returned by get_metadata 404 even though the catch-all mounts.
  • Limits per invocation: 5 s timeout, 64 MiB WASM memory.

Local dev loop

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/cookie

Plugin 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 restart

Details 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 need reload.
  • reload re-parses the manifest but can keep serving the old compiled wasm — after wasm changes the reliable deploy is container compose -f compose.yaml down && up -d (a ~7s bounce; the bind mount is re-read at boot). Symptom: /status reports the new version but tick behavior is stale.
  • Test harness pins the version: test/main.go asserts the manifest version, so bump it there together with plugin/manifest.json or verify fails.
  • 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 in assets/*.js; verify asserts every file in plugin/assets/ ships in the zip.
  • Core v0.8.9 limitation: it serves plugin tab documents with a sandbox CSP, 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

Releasing

  1. Bump plugin/manifest.json's version.
  2. Tag the same version and push: git tag v0.3.0 && git push --tags.
  3. CI builds, runtime-tests, and attaches shortcut.zip + checksums.txt to the release. The tag must match the manifest version.

About

Shortcut -> Windshift migration plugin (Extism WASM)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages