Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

jobs:
test:
timeout-minutes: 15
strategy:
matrix:
include:
- os: ubuntu-latest
node: 20
- os: ubuntu-latest
node: 22
- os: macos-latest
node: 20
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Workflow actions use mutable tags

The workflow executes actions/checkout@v4 and actions/setup-node@v4 rather than immutable commit SHAs, so upstream tag movement can change code executed with repository read access or alter the checkout and toolchain used by subsequent checks.

How this was verified: Both executable action references use mutable @v4 tags and the workflow grants contents: read.

Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/ci.yml
Line: 30-31

Comment:
**Workflow actions use mutable tags**

The workflow executes `actions/checkout@v4` and `actions/setup-node@v4` rather than immutable commit SHAs, so upstream tag movement can change code executed with repository read access or alter the checkout and toolchain used by subsequent checks.

**How this was verified:** Both executable action references use mutable `@v4` tags and the workflow grants `contents: read`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install native MIDI build dependency
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install --yes libasound2-dev
- run: npm ci
- run: npm run check
- name: Pack, clean-install, and smoke-test the CLI
run: npm run test:package
114 changes: 92 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ MIDI channel, and MIDI number are independently configurable.
It does not open MIDI ports.
- `apply` writes an eligible plan, saves a backup first, and reads the entire
controller back after every changed target to verify the result.
- `ui` opens a local, visual, strictly read-only inspector for a connected
controller or a previously exported JSON snapshot.

Only `apply` can write configuration. System commands, reset commands, and
bootloader commands are always blocked. Live writes currently require firmware
Expand All @@ -47,11 +49,73 @@ Requirements:
- a native MIDI toolchain supported by `@julusian/midi`
- on Linux, the ALSA development package required by RtMidi

Install the current GitHub version globally:

```sh
npm install --global --include=dev github:oveddan/mft-api
```

This installs the `mft-config` executable. GitHub source installs need
`--include=dev` because their installation-time build uses TypeScript. Production
installs that omit development dependencies should use a prebuilt tarball.

You can also install a release tarball when one is available:

```sh
npm install --global ./oveddan-mft-config-0.1.0.tgz
```

Confirm the command is ready:

```sh
mft-config --help
```

## Visual read-only viewer

Build and start the local viewer:

```sh
mft-config ui
```

Open the printed `http://127.0.0.1:4783` URL. Discover a connected Twister,
select it, and read its configuration, or choose **Open JSON snapshot** to work
offline. The viewer provides:

- a physical 4×4 knob layout for every detected bank, including eight-bank
firmware;
- persistent active, inactive, and detent colors with authoritative stored
indices;
- all decoded rotary, push, indicator, movement, detent, super-knob, global,
side-button, identity, firmware, warning, and raw-tag fields;
- exact JSON download and offline snapshot import.

The UI has a hard read-only Node/MIDI boundary, not merely disabled controls.
Its server cannot construct an apply connection, exposes no mutation endpoint,
and the transport permits only identity and configuration-pull SysEx. See
[the UI architecture and development guide](docs/ui-architecture.md).

The former `mft-export` executable remains as a compatibility alias for now.
New scripts and documentation should use `mft-config`; the alias may be removed
in a future major release.

The package is not published to the npm registry. This repository does not yet
have an explicit host-tool license, so registry publishing remains blocked until
the owner chooses and adds one. The package metadata deliberately uses
`UNLICENSED` and `private: true` in the meantime. `UNLICENSED` does not grant
permission to redistribute or modify the package; usage terms remain pending.
Adding a license must include an explicit decision to keep or remove the
`private` publish guard.

### Contributor setup

```sh
git clone https://github.com/oveddan/mft-api.git
cd mft-api
npm install
npm run check
npm link
```

Build the CLI after making changes:
Expand All @@ -60,27 +124,27 @@ Build the CLI after making changes:
npm run build
```

The examples below use `node dist/cli.js`. After packaging or linking the npm
binary, the equivalent command name is `mft-export`.
`npm link` builds the source checkout and links `mft-config` for contributor
testing. End-user commands use the installed executable.

## Read the controller

List connected Twisters:

```sh
node dist/cli.js list
mft-config list
```

Export the only connected device:

```sh
node dist/cli.js export --out twister-config.json
mft-config export --out twister-config.json
```

If more than one Twister is connected, use the index shown by `list`:

```sh
node dist/cli.js export --device 1 --out twister-config.json
mft-config export --device 1 --out twister-config.json
```

The JSON includes the firmware and unit identity, detected bank count, all
Expand Down Expand Up @@ -232,9 +296,9 @@ minutes and are bound to the snapshot hash, firmware version, and device ID.
### Make the top row green when active and purple when inactive

```sh
node dist/cli.js export --out twister-config.json
mft-config export --out twister-config.json

node dist/cli.js plan \
mft-config plan \
--snapshot twister-config.json \
--set bank.1.encoder.1.colors.active=green \
--set bank.1.encoder.1.colors.inactive=purple \
Expand All @@ -246,23 +310,23 @@ node dist/cli.js plan \
--set bank.1.encoder.4.colors.inactive=purple \
--out patch-plan.json

node dist/cli.js apply --plan patch-plan.json --yes
mft-config apply --plan patch-plan.json --yes
```

### Make the first two push switches toggle on and off

Code `1` is `ccToggle`:

```sh
node dist/cli.js export --out twister-config.json
mft-config export --out twister-config.json

node dist/cli.js plan \
mft-config plan \
--snapshot twister-config.json \
--set bank.1.encoder.1.switch.action.code=1 \
--set bank.1.encoder.2.switch.action.code=1 \
--out patch-plan.json

node dist/cli.js apply --plan patch-plan.json --yes
mft-config apply --plan patch-plan.json --yes
```

### Change a knob's rotary and push MIDI mappings
Expand All @@ -271,9 +335,9 @@ This makes bank 2, encoder 5 send CC 20 on channel 3 when turned, and a
momentary CC 40 on channel 4 when pressed:

```sh
node dist/cli.js export --out twister-config.json
mft-config export --out twister-config.json

node dist/cli.js plan \
mft-config plan \
--snapshot twister-config.json \
--set bank.2.encoder.5.encoder.type.code=1 \
--set bank.2.encoder.5.encoder.midiChannel=3 \
Expand All @@ -283,39 +347,39 @@ node dist/cli.js plan \
--set bank.2.encoder.5.switch.midiNumber=40 \
--out patch-plan.json

node dist/cli.js apply --plan patch-plan.json --yes
mft-config apply --plan patch-plan.json --yes
```

### Set a velocity-sensitive spread indicator with a red detent

```sh
node dist/cli.js export --out twister-config.json
mft-config export --out twister-config.json

node dist/cli.js plan \
mft-config plan \
--snapshot twister-config.json \
--set bank.3.encoder.9.movement.code=2 \
--set bank.3.encoder.9.indicator.code=3 \
--set bank.3.encoder.9.detent.enabled=true \
--set bank.3.encoder.9.detent.color=red \
--out patch-plan.json

node dist/cli.js apply --plan patch-plan.json --yes
mft-config apply --plan patch-plan.json --yes
```

### Change global brightness and sleep behavior

```sh
node dist/cli.js export --out twister-config.json
mft-config export --out twister-config.json

node dist/cli.js plan \
mft-config plan \
--snapshot twister-config.json \
--set global.brightness.rgb=96 \
--set global.brightness.indicator=80 \
--set global.sleep.timeoutIndex=4 \
--set global.sleep.animation.code=0 \
--out patch-plan.json

node dist/cli.js apply --plan patch-plan.json --yes
mft-config apply --plan patch-plan.json --yes
```

Timeout index `4` means 10 minutes; sleep animation code `0` turns the lights
Expand All @@ -328,12 +392,18 @@ Before sending any configuration frame, `apply`:
1. validates the plan hash and expiry;
2. discovers the controller again and checks its identity and firmware;
3. takes a fresh complete snapshot and rejects stale expected values;
4. saves a timestamped JSON backup under `.mft-state/backups/`;
4. saves a timestamped JSON backup under the per-user state directory;
5. writes targets sequentially without automatic retries;
6. reads the full configuration after every target and compares it with the
expected state; and
7. records pending, verified, failed, or unknown outcomes in an append-only
journal.
journal.

By default, backups, the single-use-plan journal, and the last verified snapshot
are stored under `~/.mft-config/` (or `$XDG_STATE_HOME/mft-config/`). This stable
location prevents changing the working directory from bypassing plan-consumption
checks. Set `MFT_CONFIG_STATE_DIR` to choose an explicit alternative.
The override must be an absolute path.

Completed plans are single-use. See [`docs/write-safety.md`](docs/write-safety.md)
for protocol-level details and remaining limitations.
Expand Down
80 changes: 80 additions & 0 deletions docs/ui-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Read-only UI architecture

The visual configurator is a small local web application. `mft-config ui`
starts an HTTP server on `127.0.0.1:4783` and serves dependency-free HTML, CSS,
and browser JavaScript from `ui/`. The browser never opens MIDI ports. The Node
server reuses the same exporter and protocol decoder as `mft-config export`.

## Hard read-only boundary

The server imports `RtMidiReadOnlyBackend`, not `RtMidiBackend`. The read-only
backend has only `discover` and `connect`; it cannot create the separate
`ConfigurationWriteConnection` required by the applier. Its connection's only
send method runs every frame through `assertReadOnlyRequest` immediately before
the native MIDI output call.

The local HTTP surface is intentionally tiny:

- `GET /api/devices` sends Universal Identity Request and returns matching
Twister ports.
- `POST /api/export` accepts only `{ "deviceIndex": <non-negative integer> }`
and runs the complete read-only export.

There are no plan, apply, write, reset, system, or bootloader routes. Unknown
`/api/*` paths return 404 without opening a MIDI connection. The transport guard
still blocks a mutating or unknown SysEx frame if one reaches it. Tests assert
both layers.

The UI listens on loopback by default. `MFT_CONFIG_UI_HOST` can override the
host, but exposing a hardware-adjacent local service to a network is not
recommended. Responses use a same-origin Content Security Policy and do not
load remote assets.

## Browser model

`ui/app.js` is split into small state, API, validation, and rendering functions.
It validates imported snapshots before rendering. Imports stay entirely in the
browser, so offline inspection does not touch MIDI. Downloads serialize the
exact in-memory snapshot rather than reconstructing configuration fields.

The grid always renders 16 encoders in physical left-to-right, top-to-bottom
order. Bank tabs are created from the snapshot, so both four- and eight-bank
exports use the same rendering path. Each knob persistently shows all three
stored 7-bit color indices: active, inactive, and detent. Active and inactive
use the selected Classic or MF64 RGB palette; the indicator-ring detent uses its
separate red/blue balance. CSS previews are a screen approximation; the numeric
index displayed in the knob detail is authoritative because hardware LED output
depends on brightness and diffusion.

## Local development

Install and run all checks:

```sh
npm ci
npm run check
npm run test:package
```

Start the UI through the packaged command path:

```sh
npm run build
node dist/cli.js ui
```

Or use the contributor shortcut, which builds first:

```sh
npm run ui
```

Set a different loopback port when needed:

```sh
MFT_CONFIG_UI_PORT=4784 npm run ui
```

Then open the printed URL. Exercise device discovery with hardware attached,
or use **Open JSON snapshot** to test every view offline. Server tests inject a
fake read-only backend and never require MIDI hardware.
Loading
Loading