diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3772a47 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 + 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 diff --git a/README.md b/README.md index 9bf835e..8f80573 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,48 @@ 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 +``` + +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: @@ -60,27 +97,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 @@ -232,9 +269,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 \ @@ -246,7 +283,7 @@ 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 @@ -254,15 +291,15 @@ node dist/cli.js apply --plan patch-plan.json --yes 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 @@ -271,9 +308,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 \ @@ -283,15 +320,15 @@ 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 \ @@ -299,15 +336,15 @@ node dist/cli.js plan \ --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 \ @@ -315,7 +352,7 @@ node dist/cli.js plan \ --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 @@ -328,12 +365,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. diff --git a/package-lock.json b/package-lock.json index e2ea9f5..a9f18e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { - "name": "@djtechtools/mft-export", + "name": "@oveddan/mft-config", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@djtechtools/mft-export", + "name": "@oveddan/mft-config", "version": "0.1.0", + "license": "UNLICENSED", "dependencies": { "@julusian/midi": "^3.0.1" }, "bin": { + "mft-config": "dist/cli.js", "mft-export": "dist/cli.js" }, "devDependencies": { diff --git a/package.json b/package.json index b89843e..9979d86 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,36 @@ { - "name": "@djtechtools/mft-export", + "name": "@oveddan/mft-config", "version": "0.1.0", "private": true, "type": "module", - "description": "Guarded MIDI Fighter Twister configuration exporter and planner", + "description": "Safely read, plan, and write MIDI Fighter Twister configuration", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "git+https://github.com/oveddan/mft-api.git" + }, + "bugs": { + "url": "https://github.com/oveddan/mft-api/issues" + }, + "homepage": "https://github.com/oveddan/mft-api#readme", + "files": [ + "dist/**/*.js", + "docs/write-safety.md", + "schema/*.json", + "README.md" + ], "bin": { + "mft-config": "./dist/cli.js", "mft-export": "./dist/cli.js" }, "scripts": { + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "build": "tsc -p tsconfig.json", "test": "tsx --test test/*.test.ts", - "check": "npm run build && npm test" + "check": "npm run build && npm test", + "prepare": "npm run build", + "prepack": "npm run clean && npm run build", + "test:package": "node scripts/packed-artifact-smoke.mjs" }, "engines": { "node": ">=20" diff --git a/scripts/packed-artifact-smoke.mjs b/scripts/packed-artifact-smoke.mjs new file mode 100644 index 0000000..22dcecb --- /dev/null +++ b/scripts/packed-artifact-smoke.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const temporaryDirectory = mkdtempSync(join(tmpdir(), "mft-config-package-")); +let tarballPath; +const npmCli = process.env.npm_execpath; +assert.ok(npmCli, "npm_execpath is required; run this check through npm run test:package"); + +function run(binary, args, options = {}) { + const result = spawnSync(binary, args, { + cwd: options.cwd ?? temporaryDirectory, + encoding: "utf8", + env: process.env, + }); + if (options.status !== undefined) { + assert.equal(result.status, options.status, `${binary} ${args.join(" ")}\n${result.stdout}\n${result.stderr}`); + } + if (options.stdout) assert.match(result.stdout, options.stdout); + if (options.stderr) assert.match(result.stderr, options.stderr); + return result; +} + +try { + const packOutput = execFileSync(process.execPath, [npmCli, "pack", "--json"], { + cwd: repositoryRoot, + encoding: "utf8", + }); + assert.ok(packOutput.trim(), "npm pack did not return its JSON manifest"); + const [packed] = JSON.parse(packOutput); + assert.ok(packed?.filename, "npm pack did not report a tarball filename"); + tarballPath = join(repositoryRoot, packed.filename); + + const paths = packed.files.map((file) => file.path).sort(); + const required = [ + "README.md", + "dist/cli.js", + "dist/applier.js", + "docs/write-safety.md", + "package.json", + "schema/config-export.schema.json", + "schema/patch-plan.schema.json", + ]; + for (const path of required) assert.ok(paths.includes(path), `packed artifact is missing ${path}`); + for (const path of paths) { + assert.match(path, /^(README\.md|package\.json|(?:LICENSE|LICENCE|COPYING)(?:\..+)?|dist\/.+\.js|docs\/write-safety\.md|schema\/[^/]+\.json)$/); + } + assert.ok(!paths.some((path) => /(^|\/)(src|test|scripts|node_modules|\.mft-state)(\/|$)/.test(path))); + + const installRoot = join(temporaryDirectory, "install"); + run(process.execPath, [npmCli, "install", "--prefix", installRoot, "--no-audit", "--no-fund", tarballPath], { status: 0 }); + const command = join(installRoot, "node_modules", ".bin", "mft-config"); + const legacyCommand = join(installRoot, "node_modules", ".bin", "mft-export"); + + run(command, ["--help"], { status: 0, stdout: /^Usage:\n mft-config list/m }); + run(legacyCommand, ["--help"], { status: 0, stdout: /^Usage:\n mft-config list/m, stderr: /mft-export is deprecated/ }); + run(command, ["list", "--timeout", "99"], { status: 1, stderr: /--timeout must be an integer/ }); + run(command, ["export", "--device", "-1"], { status: 1, stderr: /--device must be a non-negative integer/ }); + run(command, ["plan"], { status: 1, stderr: /plan requires --snapshot/ }); + run(command, ["apply"], { status: 1, stderr: /apply requires --plan/ }); + + const snapshotPath = join(temporaryDirectory, "snapshot.json"); + const planPath = join(temporaryDirectory, "plan.json"); + const snapshot = { + schemaVersion: "djtt.mft.config-export.v1", + capturedAt: "2026-08-03T00:00:00.000Z", + device: { + manufacturer: "DJ TechTools", + manufacturerId: [0, 1, 121], + familyId: 5, + modelId: 1, + firmware: { date: "2026-07-02", identityBytes: [32, 38, 7, 2] }, + unitId: { source: "sysex-0x05", hex: "0102030405060708" }, + midiPorts: { input: "input-a", output: "output-a" }, + }, + capabilities: { bankCount: 4, encodersPerBank: 16 }, + globals: { + midiChannel: 4, + sideButtonsBanked: true, + sideButtons: Array.from({ length: 6 }, (_, index) => ({ number: index + 1, action: { code: 0, name: "ccHold" } })), + superKnob: { start: 63, end: 127 }, + brightness: { rgb: 127, indicator: 127 }, + colorMap: { code: 1, name: "mf64" }, + animationChannels: { encoder: 6, switch: 3 }, + sleep: { timeoutIndex: 7, timeoutMinutes: 60, animation: { code: 1, name: "rainbowWave" } }, + bankAnimationsEnabled: true, + rawTags: { "0": 4, "1": 1, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0, "7": 0, "8": 63, "9": 127, "31": 127, "32": 127, "33": 1, "34": 6, "35": 3, "36": 7, "37": 1, "38": 1 }, + }, + banks: [{ + number: 1, + encoders: [{ + number: 1, + detent: { enabled: false, color: 63 }, + movement: { code: 0, name: "direct" }, + switch: { action: { code: 0, name: "ccHold" }, midiChannel: 2, midiNumber: 0 }, + encoder: { midiChannel: 1, shiftedMidiChannel: 5, midiNumber: 0, type: { code: 1, name: "cc" } }, + colors: { active: 25, inactive: 5 }, + indicator: { code: 2, name: "blendedBar" }, + superKnobEnabled: false, + rawTags: { "10": 0, "11": 0, "12": 0, "13": 2, "14": 0, "15": 0, "16": 1, "17": 0, "18": 1, "19": 25, "20": 5, "21": 63, "22": 2, "23": 0, "24": 5 }, + }], + }], + warnings: [], + }; + writeFileSync(snapshotPath, `${JSON.stringify(snapshot)}\n`); + run(command, ["plan", "--snapshot", snapshotPath, "--set", "bank.1.encoder.1.colors.active=red", "--out", planPath], { status: 0 }); + assert.equal(JSON.parse(readFileSync(planPath, "utf8")).changes[0].path, "bank.1.encoder.1.colors.active"); + run(command, ["apply", "--plan", planPath], { status: 1, stderr: /explicit confirmation with --yes/ }); + + process.stdout.write(`Packed and clean-installed ${packed.filename}; mft-config and mft-export smoke tests passed.\n`); +} finally { + if (tarballPath) rmSync(tarballPath, { force: true }); + rmSync(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/src/cli.ts b/src/cli.ts index 9d2701d..10ac96d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import type { ConfigExport } from "./model.js"; import { createPatchPlan, type PatchInput, type PatchPlan } from "./planner.js"; import { applyPatchPlan } from "./applier.js"; import { assertPlanNotConsumed } from "./journal.js"; +import { stateDirectory } from "./state.js"; interface Arguments { command: "list" | "export" | "plan" | "apply" | "help"; @@ -22,10 +23,10 @@ interface Arguments { function usage(): string { return `Usage: - mft-export list [--timeout ] - mft-export export [--device ] [--out ] [--timeout ] - mft-export plan --snapshot --set [--set ] [--out ] - mft-export apply --plan --yes [--device ] + mft-config list [--timeout ] + mft-config export [--device ] [--out ] [--timeout ] + mft-config plan --snapshot --set [--set ] [--out ] + mft-config apply --plan --yes [--device ] This tool only sends Universal Identity, global pull (0x02), encoder bulk-pull (0x04/0x01), and device-ID pull (0x05) messages. The plan command is offline. @@ -101,6 +102,9 @@ async function writeAtomically(path: string, data: string): Promise { } async function main(): Promise { + if (process.argv[1]?.endsWith("mft-export")) { + process.stderr.write("mft-export is deprecated; use mft-config instead.\n"); + } const args = parseArguments(process.argv.slice(2)); if (args.command === "help") { process.stdout.write(`${usage()}\n`); @@ -146,8 +150,8 @@ async function main(): Promise { if (args.command === "apply") { const plan = JSON.parse(await readFile(resolve(args.plan!), "utf8")) as PatchPlan; - const stateDirectory = resolve(".mft-state"); - const journalPath = resolve(stateDirectory, "journal.ndjson"); + const stateRoot = stateDirectory(); + const journalPath = resolve(stateRoot, "journal.ndjson"); await assertPlanNotConsumed(journalPath, plan.planId); const connection = backend.connectForApply(device); try { @@ -156,12 +160,12 @@ async function main(): Promise { timeoutMs: args.timeoutMs, saveBackup: async (snapshot) => { const stamp = new Date().toISOString().replaceAll(":", "-"); - const path = resolve(stateDirectory, "backups", `${stamp}.json`); + const path = resolve(stateRoot, "backups", `${stamp}.json`); await writeAtomically(path, `${JSON.stringify(snapshot, null, 2)}\n`); return path; }, }); - const postPath = resolve(stateDirectory, "last-verified.json"); + const postPath = resolve(stateRoot, "last-verified.json"); await writeAtomically(postPath, `${JSON.stringify(result.postSnapshot, null, 2)}\n`); process.stdout.write(`Applied and verified ${plan.changes.length} change(s).\nBackup: ${result.backupPath}\nVerified snapshot: ${postPath}\n`); } finally { @@ -186,6 +190,6 @@ async function main(): Promise { } main().catch((error: Error) => { - process.stderr.write(`mft-export: ${error.message}\n`); + process.stderr.write(`mft-config: ${error.message}\n`); process.exitCode = 1; }); diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..baa92f1 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,29 @@ +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; + +interface StateDirectoryOptions { + env?: NodeJS.ProcessEnv; + home?: string; + platform?: NodeJS.Platform; +} + +/** + * Keep backups and the single-use-plan journal stable regardless of the + * directory from which a globally installed command is invoked. + */ +export function stateDirectory(options: StateDirectoryOptions = {}): string { + const env = options.env ?? process.env; + const home = options.home ?? homedir(); + const platform = options.platform ?? process.platform; + const override = env.MFT_CONFIG_STATE_DIR; + if (override) { + if (!isAbsolute(override)) throw new Error("MFT_CONFIG_STATE_DIR must be an absolute path"); + return override; + } + if (env.XDG_STATE_HOME) { + if (!isAbsolute(env.XDG_STATE_HOME)) throw new Error("XDG_STATE_HOME must be an absolute path"); + return join(env.XDG_STATE_HOME, "mft-config"); + } + if (platform === "win32" && env.LOCALAPPDATA) return join(env.LOCALAPPDATA, "mft-config"); + return join(home, ".mft-config"); +} diff --git a/test/state.test.ts b/test/state.test.ts new file mode 100644 index 0000000..c0c7e4f --- /dev/null +++ b/test/state.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { stateDirectory } from "../src/state.js"; + +test("uses a stable per-user directory for journals and backups", () => { + assert.equal(stateDirectory({ env: {}, home: "/home/example", platform: "linux" }), "/home/example/.mft-config"); + assert.equal(stateDirectory({ env: { XDG_STATE_HOME: "/state" }, home: "/home/example", platform: "linux" }), "/state/mft-config"); + assert.equal(stateDirectory({ env: { LOCALAPPDATA: "C:\\State" }, home: "C:\\Users\\example", platform: "win32" }), "C:\\State/mft-config"); + assert.equal(stateDirectory({ env: { MFT_CONFIG_STATE_DIR: "/custom" }, home: "/home/example" }), "/custom"); + assert.throws(() => stateDirectory({ env: { MFT_CONFIG_STATE_DIR: "relative" }, home: "/home/example" }), /must be an absolute path/); +}); diff --git a/tsconfig.json b/tsconfig.json index f2b32c7..eadade5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,8 +7,8 @@ "noUncheckedIndexedAccess": true, "outDir": "dist", "rootDir": "src", - "declaration": true, - "sourceMap": true, + "declaration": false, + "sourceMap": false, "skipLibCheck": true }, "include": ["src/**/*.ts"]