midi-shell-commands 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ name: Release
2
+
3
+ on:
4
+ merge_group:
5
+ branches:
6
+ - main
7
+ push:
8
+ branches:
9
+ - main
10
+ workflow_dispatch:
11
+
12
+ # Needed for semantic-release to create GitHub releases and publish to npm via OIDC
13
+ permissions:
14
+ contents: write
15
+ issues: write
16
+ pull-requests: write
17
+ id-token: write
18
+
19
+ concurrency:
20
+ group: Release
21
+ cancel-in-progress: false
22
+
23
+ jobs:
24
+ release:
25
+ name: Semantic Release
26
+ environment: Release
27
+ # Ensure releases run only when code reaches main via GitHub Merge Queue, or when manually dispatched
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - name: Checkout
31
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
32
+ with:
33
+ fetch-depth: 0
34
+ - name: Setup Node.js
35
+ uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
36
+ with:
37
+ node-version-file: '.nvmrc'
38
+ cache: npm
39
+ registry-url: 'https://registry.npmjs.org'
40
+ - name: Update npm
41
+ run: npm install -g npm@latest
42
+ - name: Install dependencies
43
+ run: npm ci
44
+ - name: Run ESLint
45
+ run: npm run lint
46
+ - name: Run unit tests
47
+ run: npm run test:ci
48
+ - name: Semantic Release
49
+ if: ${{ github.event_name == 'push' }}
50
+ uses: cycjimmy/semantic-release-action@9cc899c47e6841430bbaedb43de1560a568dfd16 # v5.0.0
51
+ env:
52
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53
+ NPM_CONFIG_PROVENANCE: true
54
+ - name: Publish to NPM
55
+ run: npm publish --provenance
@@ -0,0 +1,37 @@
1
+ name: Verify PR
2
+
3
+ on:
4
+ pull_request:
5
+
6
+ jobs:
7
+ lint:
8
+ name: Lint
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout
12
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
13
+ - name: Setup Node.js
14
+ uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
15
+ with:
16
+ node-version-file: '.nvmrc'
17
+ cache: npm
18
+ - name: Install dependencies
19
+ run: npm ci
20
+ - name: Run ESLint
21
+ run: npm run lint
22
+
23
+ test:
24
+ name: Test
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - name: Checkout
28
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
29
+ - name: Setup Node.js
30
+ uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
31
+ with:
32
+ node-version-file: '.nvmrc'
33
+ cache: npm
34
+ - name: Install dependencies
35
+ run: npm ci
36
+ - name: Run unit tests
37
+ run: npm run test:ci
package/.nvmrc CHANGED
@@ -1 +1 @@
1
- 22
1
+ 24.11.0
@@ -0,0 +1,25 @@
1
+ {
2
+ "branches": [
3
+ "main"
4
+ ],
5
+ "plugins": [
6
+ "@semantic-release/commit-analyzer",
7
+ "@semantic-release/release-notes-generator",
8
+ [
9
+ "@semantic-release/npm",
10
+ {
11
+ "npmPublish": false
12
+ }
13
+ ],
14
+ [
15
+ "@semantic-release/git",
16
+ {
17
+ "assets": [
18
+ "package.json",
19
+ "package-lock.json"
20
+ ]
21
+ }
22
+ ],
23
+ "@semantic-release/github"
24
+ ]
25
+ }
@@ -0,0 +1,12 @@
1
+ import js from '@eslint/js';
2
+ import { defineConfig } from 'eslint/config';
3
+ import globals from 'globals';
4
+
5
+ export default defineConfig([
6
+ {
7
+ files: ['**/*.{js,mjs,cjs}'],
8
+ plugins: { js },
9
+ extends: ['js/recommended'],
10
+ languageOptions: { globals: globals.node },
11
+ },
12
+ ]);
@@ -0,0 +1,9 @@
1
+ import easyMIDI from 'easymidi';
2
+ import { listenToInput } from './listen-to-input.js';
3
+
4
+ export function checkInputs() {
5
+ const inputNames = easyMIDI.getInputs();
6
+ for (const inputName of inputNames) {
7
+ listenToInput(inputName);
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ import { log } from './log.js';
2
+ import { state } from './state.js';
3
+
4
+ export function cleanUpInputs() {
5
+ for (const input of Object.values(state.watchedInputs)) {
6
+ input.close();
7
+ }
8
+ log('Cleaned up midi input watchers');
9
+ }
@@ -0,0 +1,10 @@
1
+ import { log } from './log.js';
2
+ import { state } from './state.js';
3
+
4
+ export function cleanUpWatcher() {
5
+ if (state.watcher) {
6
+ log('Stopped watching scripts directory');
7
+ state.watcher.close();
8
+ state.watcher = undefined;
9
+ }
10
+ }
@@ -0,0 +1,7 @@
1
+ export function escapePath(p) {
2
+ // Simple shell escaping for spaces
3
+ if (p.includes(' ')) {
4
+ return `'${p.replace(/'/g, '\'\\\'\'')}'`;
5
+ }
6
+ return p;
7
+ }
@@ -0,0 +1,9 @@
1
+ import { checkInputs } from './check-inputs.js';
2
+ import { cleanUpInputs } from './clean-up-inputs.js';
3
+
4
+ export function initializeMidi() {
5
+ const checkDelay = 60 * 1000 + (Math.random() * 3000) | 0;
6
+ checkInputs();
7
+ setInterval(checkInputs, checkDelay);
8
+ process.on('exit', cleanUpInputs);
9
+ }
@@ -0,0 +1,37 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { cleanUpWatcher } from './clean-up-watcher.js';
5
+ import { log } from './log.js';
6
+ import { refreshScripts } from './refresh-scripts.js';
7
+ import { state } from './state.js';
8
+
9
+ export function initializeScriptsDirectory() {
10
+ // Default to ~/Documents/MidiShellCommands if no directory is provided
11
+ let targetDirArg = process.argv[process.argv.length - 1];
12
+ if (process.argv.length === 2 || targetDirArg === '--daemon') {
13
+ targetDirArg = path.join(os.homedir(), 'Documents', 'MidiShellCommands');
14
+ }
15
+
16
+ // Ensure the directory exists
17
+ state.watchDir = path.resolve(targetDirArg);
18
+ try {
19
+ fs.mkdirSync(state.watchDir, { recursive: true });
20
+ }
21
+ catch (e) {
22
+ console.error('Failed to create or access scripts directory:', state.watchDir);
23
+ console.error(e);
24
+ process.exit(1);
25
+ }
26
+
27
+ refreshScripts();
28
+ try {
29
+ state.watcher = fs.watch(state.watchDir, { persistent: true }, refreshScripts);
30
+ }
31
+ catch {
32
+ // Non-fatal on some platforms
33
+ }
34
+ process.on('exit', cleanUpWatcher);
35
+
36
+ log(`Watching ${targetDirArg}`);
37
+ }
@@ -0,0 +1,23 @@
1
+ import childProcess from 'node:child_process';
2
+ import { recentlyInvoked } from './recently-invoked.js';
3
+ import { log } from './log.js';
4
+ import { mapMessageToFileNames } from './map-message-to-file-names.js';
5
+ import { reportErrors } from './report-errors.js';
6
+ import { state } from './state.js';
7
+
8
+ export function invokeScripts(msg) {
9
+ const possibleFileNames = mapMessageToFileNames(msg);
10
+ log(possibleFileNames[0]);
11
+ for (const possibleFileName of possibleFileNames) {
12
+ for (let i = 0; i < state.scriptsWithoutExtension.length; i++) {
13
+ if (state.scriptsWithoutExtension[i] === possibleFileName) {
14
+ if (recentlyInvoked(state.scripts[i])) {
15
+ log('Skipping ' + state.scripts[i] + ' because it was invoked recently');
16
+ } else {
17
+ log('Executing ' + state.scripts[i]);
18
+ childProcess.exec(`./${state.scripts[i]}`, { cwd: state.watchDir }, reportErrors);
19
+ }
20
+ }
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,11 @@
1
+ import easyMIDI from 'easymidi';
2
+ import { invokeScripts } from './invoke-scripts.js';
3
+ import { state } from './state.js';
4
+
5
+ export function listenToInput(inputName) {
6
+ if (!state.watchedInputs[inputName]) {
7
+ const input = state.watchedInputs[inputName] = new easyMIDI.Input(inputName);
8
+ input.on('noteon', invokeScripts);
9
+ input.on('noteoff', invokeScripts);
10
+ }
11
+ }
package/lib/log.js ADDED
@@ -0,0 +1,3 @@
1
+ export function log(msg) {
2
+ console.log(`[midi-shell-commands] ${msg}`);
3
+ }
@@ -0,0 +1,7 @@
1
+ export function mapMessageToFileNames(msg) {
2
+ return [
3
+ `${msg._type}.${msg.note}.${msg.channel}.${msg.velocity}`,
4
+ `${msg._type}.${msg.note}.${msg.channel}`,
5
+ `${msg._type}.${msg.note}`,
6
+ ];
7
+ }
@@ -1,20 +1,14 @@
1
1
  #!/usr/local/bin/node
2
+ import childProcess from 'node:child_process';
2
3
  /**
3
4
  * Postinstall script to set up a macOS LaunchAgent which runs midi-shell-commands
4
5
  * pointing at ~/Documents/MidiShellCommands.
5
6
  */
6
- const fs = require('fs');
7
- const os = require('os');
8
- const path = require('path');
9
- const childProcess = require('child_process');
10
-
11
- function log(msg) {
12
- try {
13
- // Best-effort: npm may suppress some outputs; still try.
14
- console.log(`[midi-shell-commands] ${msg}`);
15
- }
16
- catch {}
17
- }
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { escapePath } from './escape-path.js';
11
+ import { log } from './log.js';
18
12
 
19
13
  (function main() {
20
14
  const platform = process.platform;
@@ -85,8 +79,10 @@ function log(msg) {
85
79
  <key>StandardErrorPath</key>
86
80
  <string>${stderrPath}</string>
87
81
  <key>EnvironmentVariables</key>
88
- <key>PATH</key>
89
- <string>/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin</string>
82
+ <dict>
83
+ <key>PATH</key>
84
+ <string>/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin</string>
85
+ </dict>
90
86
  </dict>
91
87
  </plist>`;
92
88
 
@@ -111,7 +107,9 @@ function log(msg) {
111
107
  try {
112
108
  childProcess.execSync(`launchctl unload ${escapePath(plistPath)}`, { stdio: 'ignore' });
113
109
  }
114
- catch {}
110
+ catch {
111
+ // Unload failed, proceed forward.
112
+ }
115
113
 
116
114
  // Preferred modern approach: bootstrap into the current user session
117
115
  const uid = process.getuid && process.getuid();
@@ -121,7 +119,9 @@ function log(msg) {
121
119
  log('LaunchAgent bootstrapped.');
122
120
  return;
123
121
  }
124
- catch {}
122
+ catch {
123
+ console.log('Current user session bootstrap failed, falling back to legacy load.');
124
+ }
125
125
  }
126
126
 
127
127
  // Fallback to legacy load
@@ -133,10 +133,3 @@ function log(msg) {
133
133
  }
134
134
  })();
135
135
 
136
- function escapePath(p) {
137
- // Simple shell escaping for spaces
138
- if (p.includes(' ')) {
139
- return `'${p.replace(/'/g, '\'\\\'\'')}'`;
140
- }
141
- return p;
142
- }
@@ -0,0 +1,16 @@
1
+ const invocationMap = new Map();
2
+
3
+ const recently = 1000; // once per second.
4
+
5
+ export function recentlyInvoked(script, now = Date.now()) {
6
+ if (!invocationMap.has(script)) {
7
+ invocationMap.set(script, now);
8
+ return false;
9
+ }
10
+ const lastInvocation = invocationMap.get(script);
11
+ if (lastInvocation + recently > now) {
12
+ return true;
13
+ }
14
+ invocationMap.set(script, now);
15
+ return false;
16
+ }
@@ -0,0 +1,13 @@
1
+ import fs from 'node:fs';
2
+ import { state } from './state.js';
3
+
4
+ export function refreshScripts() {
5
+ try {
6
+ state.scripts = fs.readdirSync(state.watchDir).filter(f => f[0] !== '.');
7
+ state.scriptsWithoutExtension = state.scripts.map(script => script.split('.').slice(0, -1).join('.'));
8
+ }
9
+ catch (e) {
10
+ console.error('Failed to read scripts directory:', state.watchDir);
11
+ console.error(e);
12
+ }
13
+ }
@@ -0,0 +1,5 @@
1
+ export function reportErrors(err) {
2
+ if (err) {
3
+ console.error(err);
4
+ }
5
+ }
package/lib/state.js ADDED
@@ -0,0 +1,7 @@
1
+ export const state = {
2
+ scripts: [],
3
+ scriptsWithoutExtension: [],
4
+ watchedInputs: {},
5
+ watchDir: undefined,
6
+ watcher: undefined,
7
+ };
@@ -1,104 +1,8 @@
1
1
  #!/usr/bin/env node
2
- const childProcess = require('child_process');
3
- const easyMIDI = require('easymidi');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const os = require('os');
2
+ import { initializeMidi } from './lib/initialize-midi.js';
3
+ import { initializeScriptsDirectory } from './lib/initialize-scripts-directory.js';
4
+ import { log } from './lib/log.js';
7
5
 
8
- console.log('Midi Shell Commands starting up!');
9
-
10
- // Default to ~/Documents/MidiShellCommands if no directory is provided
11
- let targetDirArg = process.argv[process.argv.length - 1];
12
- if (process.argv.length === 2 || targetDirArg === '--daemon') {
13
- targetDirArg = path.join(os.homedir(), 'Documents', 'MidiShellCommands');
14
- }
15
- console.log(`Watching ${targetDirArg}`);
16
-
17
- const watchDir = path.resolve(targetDirArg);
18
-
19
- // Ensure the directory exists
20
- try {
21
- fs.mkdirSync(watchDir, { recursive: true });
22
- }
23
- catch (e) {
24
- console.error('Failed to create or access scripts directory:', watchDir);
25
- console.error(e);
26
- process.exit(1);
27
- }
28
-
29
- let scripts = [];
30
- let scriptsWithoutExtension = [];
31
-
32
- process.on('exit', cleanUpInputs);
33
- refreshScripts();
34
- try {
35
- fs.watch(watchDir, { persistent: true }, refreshScripts);
36
- }
37
- catch (e) {
38
- // Non-fatal on some platforms
39
- }
40
-
41
- const checkDelay = 60 * 1000 + (Math.random() * 3000) | 0;
42
- const watchedInputs = {};
43
- checkInputs();
44
- setInterval(checkInputs, checkDelay);
45
-
46
- function refreshScripts() {
47
- try {
48
- scripts = fs.readdirSync(watchDir).filter(f => f[0] !== '.');
49
- scriptsWithoutExtension = scripts.map(script => script.split('.').slice(0, -1).join('.'));
50
- }
51
- catch (e) {
52
- console.error('Failed to read scripts directory:', watchDir);
53
- console.error(e);
54
- }
55
- }
56
-
57
- function checkInputs() {
58
- const inputNames = easyMIDI.getInputs();
59
- for (const inputName of inputNames) {
60
- listenToInput(inputName);
61
- }
62
- }
63
-
64
- function listenToInput(inputName) {
65
- if (watchedInputs[inputName]) {
66
- return;
67
- }
68
- const input = watchedInputs[inputName] = new easyMIDI.Input(inputName);
69
- input.on('noteon', invokeScripts);
70
- input.on('noteoff', invokeScripts);
71
- }
72
-
73
- function invokeScripts(msg) {
74
- const possibleFileNames = mapMessageToFileNames(msg);
75
- console.log(possibleFileNames[0]);
76
- for (const possibleFileName of possibleFileNames) {
77
- for (let i = 0; i < scriptsWithoutExtension.length; i++) {
78
- if (scriptsWithoutExtension[i] === possibleFileName) {
79
- console.log('Executing ' + scripts[i]);
80
- childProcess.exec(`./${scripts[i]}`, { cwd: watchDir }, reportErrors);
81
- }
82
- }
83
- }
84
- }
85
-
86
- function mapMessageToFileNames(msg) {
87
- return [
88
- `${msg._type}.${msg.note}.${msg.channel}.${msg.velocity}`,
89
- `${msg._type}.${msg.note}.${msg.channel}`,
90
- `${msg._type}.${msg.note}`,
91
- ];
92
- }
93
-
94
- function reportErrors(err) {
95
- if (err) {
96
- console.error(err);
97
- }
98
- }
99
-
100
- function cleanUpInputs() {
101
- for (const input of Object.values(watchedInputs)) {
102
- input.close();
103
- }
104
- }
6
+ log('Starting up!');
7
+ initializeScriptsDirectory();
8
+ initializeMidi();
package/package.json CHANGED
@@ -1,20 +1,44 @@
1
1
  {
2
2
  "name": "midi-shell-commands",
3
- "version": "1.1.1",
4
- "main": "midi-shell-commands.js",
5
- "bin": {
6
- "midi-shell-commands": "midi-shell-commands.js"
7
- },
3
+ "version": "1.2.0",
4
+ "author": "Dawson Toth",
5
+ "repository": "https://github.com/dawsontoth/midi-shell-commands.git",
8
6
  "scripts": {
9
7
  "start": "node midi-shell-commands.js ./scripts",
10
- "postinstall": "node lib/postinstall.js",
11
- "test": "echo \"Error: no test specified\" && exit 1"
8
+ "postinstall": "node lib/post-install.js",
9
+ "test": "vitest",
10
+ "test:ci": "vitest run",
11
+ "test:coverage": "vitest run --coverage",
12
+ "lint": "eslint .",
13
+ "lint:fix": "eslint . --fix"
12
14
  },
15
+ "description": "Execute shell scripts on MIDI note events; supports macOS daemon mode via LaunchAgent.",
16
+ "type": "module",
13
17
  "keywords": [],
14
- "author": "",
18
+ "main": "midi-shell-commands.js",
19
+ "bin": {
20
+ "midi-shell-commands": "midi-shell-commands.js"
21
+ },
15
22
  "license": "ISC",
16
- "description": "Execute shell scripts on MIDI note events; supports macOS daemon mode via LaunchAgent.",
23
+ "publishConfig": {
24
+ "registry": "https://registry.npmjs.org/",
25
+ "tag": "latest",
26
+ "provenance": true
27
+ },
17
28
  "dependencies": {
18
29
  "easymidi": "^3.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "@eslint/js": "^9.39.1",
33
+ "@semantic-release/commit-analyzer": "^13.0.1",
34
+ "@semantic-release/git": "^10.0.1",
35
+ "@semantic-release/github": "^12.0.2",
36
+ "@semantic-release/npm": "^13.1.1",
37
+ "@semantic-release/release-notes-generator": "^14.1.0",
38
+ "@vitest/coverage-v8": "^4.0.8",
39
+ "eslint": "^9.39.1",
40
+ "globals": "^16.5.0",
41
+ "semantic-release": "^25.0.2",
42
+ "vitest": "^4.0.8"
19
43
  }
20
44
  }
package/renovate.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": [
4
+ "config:recommended"
5
+ ],
6
+ "lockFileMaintenance": {
7
+ "enabled": true,
8
+ "automerge": true
9
+ },
10
+ "packageRules": [
11
+ {
12
+ "matchDepTypes": [
13
+ "action"
14
+ ],
15
+ "pinDigests": true
16
+ },
17
+ {
18
+ "matchUpdateTypes": [
19
+ "minor",
20
+ "patch"
21
+ ],
22
+ "matchCurrentVersion": "!/^0/",
23
+ "automerge": true
24
+ }
25
+ ]
26
+ }