diff --git a/README.md b/README.md index db6d05f..d0a79b8 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,22 @@ codesys-mcp-sp21-plus --ssh-version myplc.lan --ssh-user pi Requires SSH key auth + passwordless sudo for `/usr/bin/strings` on the PLC. If your key isn't installed yet, the error message includes a one-line PowerShell recipe; full setup instructions live at [ssh-key-windows.md](https://gitlab.usv.no/karstein.kvistad/mr-ai-context/-/blob/main/ssh-key-windows.md). +## phobiCS-tui + +This package ships a small ink TUI for browsing CODESYS-exported ST. After installing, run: + + phobiCS-tui # auto-discovers mcp-mirror/ from cwd + phobiCS-tui # explicit project directory + phobiCS-tui approve # diff prompt; exit 0 = accept, 1 = reject, 2 = error + +Browser-mode keys: `j`/`k` (or `↓`/`↑`) move the cursor, `l`/`Enter`/`→` expand a device, `h`/`←` collapse, `q` quits. + +Approve-mode keys: `y` accept, `n`/`q`/`Esc` reject. + +The browser writes the current selection to `%LOCALAPPDATA%/codesys-mcp/tui-state.json` (Windows) or `$XDG_STATE_HOME/codesys-mcp/tui-state.json` (Linux/Mac, defaulting to `~/.local/state/...`). The MCP tool `get_user_selection` reads it so an agent can ground its actions in what the user is looking at. + +Approve mode is opt-in for the MCP server's modifying tools — start the server with `--approve-edits` to wire it in. v0.1 gates only `set_pou_code`; the rest of the modifying tools land in a follow-up. Off by default. + ## MCP Tools 41 tools across the categories below. Tools marked **NEW** were added in this fork; tools marked **FIXED** existed upstream but were broken before this fork. diff --git a/package-lock.json b/package-lock.json index a4b1c63..c5de1d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "codesys-mcp-sp21-plus", - "version": "0.4.9", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codesys-mcp-sp21-plus", - "version": "0.4.9", + "version": "0.5.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "commander": "^11.1.0", + "ink": "^5.2.1", + "react": "^18.3.1", "ssh2": "^1.17.0", "uuid": "^9.0.0", "zod": "^3.24.3" @@ -19,9 +21,13 @@ "codesys-mcp-sp21-plus": "dist/bin.js" }, "devDependencies": { + "@types/diff": "^5.2.3", "@types/node": "^20.14.1", + "@types/react": "^18.3.28", "@types/ssh2": "^1.15.5", "@types/uuid": "^9.0.0", + "diff": "^5.2.2", + "ink-testing-library": "^4.0.0", "tsx": "^4.21.0", "typescript": "^5.5.3", "vitest": "^1.6.0" @@ -30,6 +36,31 @@ "node": ">=18" } }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -958,6 +989,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/diff": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz", + "integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -975,6 +1013,24 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, "node_modules/@types/ssh2": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", @@ -1155,6 +1211,33 @@ } } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -1187,6 +1270,18 @@ "node": "*" } }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -1296,6 +1391,18 @@ "node": ">=4" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -1309,6 +1416,89 @@ "node": "*" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -1347,6 +1537,15 @@ "node": ">= 0.6" } }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1410,6 +1609,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1449,6 +1655,16 @@ "node": ">= 0.8" } }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -1479,6 +1695,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1488,6 +1710,18 @@ "node": ">= 0.8" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1518,6 +1752,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.0.tgz", + "integrity": "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1563,6 +1807,15 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1773,6 +2026,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -1937,12 +2202,108 @@ "url": "https://opencollective.com/express" } }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -1961,6 +2322,33 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -2031,6 +2419,24 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -2299,6 +2705,15 @@ "node": ">= 0.8" } }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2466,6 +2881,18 @@ "node": ">= 0.10" } }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -2473,6 +2900,22 @@ "dev": true, "license": "MIT" }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2492,6 +2935,52 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -2559,6 +3048,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -2723,6 +3221,49 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2750,6 +3291,18 @@ "nan": "^2.23.0" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2773,6 +3326,38 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -3304,6 +3889,18 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -3558,12 +4155,77 @@ "node": ">=8" } }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", @@ -3577,6 +4239,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 9bc71a4..17c0df6 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Codesys-MCP-SP21+ -- fork of luke-harriman/Codesys-MCP carrying CODESYS V3.5 SP22 Patch 1 fixes (and forward-compat with later SPs): script-engine API drift, online/runtime tool auto-login, dual-SHA release classifier, set_pou_code omitted-decl wipe fix, add_library managed-overload, etc. MCP server for CODESYS with persistent UI instance and file-based IPC.", "main": "dist/server.js", "bin": { - "codesys-mcp-sp21-plus": "dist/bin.js" + "codesys-mcp-sp21-plus": "dist/bin.js", + "phobiCS-tui": "dist/tui/index.js" }, "files": [ "dist/", @@ -12,10 +13,11 @@ "LICENSE" ], "scripts": { - "build": "tsc && node -e \"require('fs').cpSync('src/scripts','dist/scripts',{recursive:true})\"", + "build": "tsc && tsc -p tsconfig.tui.json && node -e \"require('fs').cpSync('src/scripts','dist/scripts',{recursive:true})\" && node -e \"const fs=require('fs');fs.writeFileSync('dist/tui/package.json','{\\\"type\\\":\\\"module\\\"}');const p='dist/tui/index.js';fs.writeFileSync(p,'#!/usr/bin/env node\\n'+fs.readFileSync(p,'utf8'));\"", + "build:tui": "tsc -p tsconfig.tui.json", "test": "vitest --run", "test:watch": "vitest", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc -p tsconfig.tui.json --noEmit", "prepublishOnly": "npm run build && npm test" }, "keywords": [ @@ -39,14 +41,20 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "commander": "^11.1.0", + "ink": "^5.2.1", + "react": "^18.3.1", "ssh2": "^1.17.0", "uuid": "^9.0.0", "zod": "^3.24.3" }, "devDependencies": { + "@types/diff": "^5.2.3", "@types/node": "^20.14.1", + "@types/react": "^18.3.28", "@types/ssh2": "^1.15.5", "@types/uuid": "^9.0.0", + "diff": "^5.2.2", + "ink-testing-library": "^4.0.0", "tsx": "^4.21.0", "typescript": "^5.5.3", "vitest": "^1.6.0" diff --git a/src/approve-gate.ts b/src/approve-gate.ts new file mode 100644 index 0000000..8806501 --- /dev/null +++ b/src/approve-gate.ts @@ -0,0 +1,117 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { spawn } from 'child_process'; + +export const IMPL_SENTINEL = '(* === IMPLEMENTATION === *)'; + +export interface SetPouCodeArgs { + declarationCode?: string; + implementationCode?: string; +} + +export type GateResult = + | { status: 'accepted' } + | { status: 'rejected'; message: string } + | { status: 'no-existing' } + | { status: 'error'; message: string }; + +function splitOnSentinel(text: string): { decl: string; impl: string } { + const idx = text.indexOf(IMPL_SENTINEL); + if (idx < 0) { + return { decl: text, impl: '' }; + } + const decl = text.slice(0, idx).replace(/\s+$/, ''); + const after = text.slice(idx + IMPL_SENTINEL.length); + const impl = after.replace(/^\r?\n/, ''); + return { decl, impl }; +} + +export function composeMergedContent(existing: string, args: SetPouCodeArgs): string { + const { decl, impl } = splitOnSentinel(existing); + const newDecl = args.declarationCode ?? decl; + const newImpl = args.implementationCode ?? impl; + return [newDecl, IMPL_SENTINEL, newImpl].join('\n'); +} + +async function findMirrorFile( + projectFilePath: string, + pouPath: string +): Promise { + const projectDir = path.dirname(path.resolve(projectFilePath)); + const mirrorRoot = path.join(projectDir, 'mcp-mirror'); + try { + await fs.access(mirrorRoot); + } catch { + return null; + } + const leaf = pouPath.split(/[./]/).pop()!; + const matches: string[] = []; + await collectMatches(mirrorRoot, `${leaf}.st`, matches); + return matches.length === 1 ? matches[0] : null; +} + +async function collectMatches(dir: string, leafName: string, out: string[]): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + await collectMatches(full, leafName, out); + } else if (e.isFile() && e.name === leafName) { + out.push(full); + } + } +} + +async function spawnApproveTui(existingPath: string, stagedPath: string): Promise { + const tuiBin = path.join(__dirname, 'tui', 'index.js'); + return new Promise((resolve) => { + const child = spawn(process.execPath, [tuiBin, 'approve', existingPath, stagedPath], { + stdio: ['inherit', 'inherit', 'inherit'], + }); + child.on('exit', (code) => { + if (code === 0) resolve({ status: 'accepted' }); + else if (code === 1) + resolve({ status: 'rejected', message: 'User rejected the change in phobiCS-tui.' }); + else + resolve({ + status: 'error', + message: `phobiCS-tui exited with code ${code}.`, + }); + }); + }); +} + +export interface RunGateOpts { + projectFilePath: string; + pouPath: string; + args: SetPouCodeArgs; + spawnFn?: typeof spawnApproveTui; +} + +export async function runApproveGate(opts: RunGateOpts): Promise { + const existingPath = await findMirrorFile(opts.projectFilePath, opts.pouPath); + if (!existingPath) { + return { status: 'no-existing' }; + } + let existing: string; + try { + existing = await fs.readFile(existingPath, 'utf8'); + } catch (err) { + return { status: 'error', message: `read failed: ${(err as Error).message}` }; + } + const proposed = composeMergedContent(existing, opts.args); + + const stagedPath = `${existingPath}.staged`; + await fs.writeFile(stagedPath, proposed, 'utf8'); + try { + const spawnFn = opts.spawnFn ?? spawnApproveTui; + return await spawnFn(existingPath, stagedPath); + } finally { + await fs.rm(stagedPath, { force: true }); + } +} diff --git a/src/bin.ts b/src/bin.ts index 8a48feb..5381934 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -47,6 +47,7 @@ program .option('--fallback-headless', 'Fall back to headless if persistent fails', true) .option('--keep-alive', 'Keep CODESYS running after server stops', false) .option('--auto-mirror', 'Re-run mirror_export after every modifying tool so an external editor watching /mcp-mirror/ sees changes live', false) + .option('--approve-edits', 'Gate modifying MCP tools behind a phobiCS-tui y/n diff prompt', false) .option('--timeout ', 'Default command timeout in ms', '60000') .option('--verbose', 'Enable verbose logging') .option('--debug', 'Enable debug logging (more verbose)') @@ -197,6 +198,7 @@ if (opts.sshVersion) { debug: opts.debug || false, mode: (opts.mode === 'headless' ? 'headless' : 'persistent') as ExecutionMode, autoMirror: opts.autoMirror || false, + approveEdits: opts.approveEdits || false, }; process.stderr.write(`Starting CODESYS MCP Server v${version}\n`); @@ -207,6 +209,9 @@ if (opts.sshVersion) { if (config.autoMirror) { process.stderr.write(` Auto-mirror: ENABLED (mirror_export runs after every edit)\n`); } + if (config.approveEdits) { + process.stderr.write(` Approve edits: ENABLED (modifying tools will prompt via phobiCS-tui)\n`); + } startMcpServer(config).catch((err) => { process.stderr.write(`FATAL: ${err.message}\n`); diff --git a/src/server.ts b/src/server.ts index cdb5826..1df4f1c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,6 +25,8 @@ import { resolveMirrorRoot } from './mirror-paths'; import { inspectProjectFile } from './inspect'; import { parseProfileName } from './detect'; import { decideOpenProjectPreflight } from './preflight'; +import { readSelection } from './state-read'; +import { runApproveGate } from './approve-gate'; /** * Classifier for `bump_project_version --level=auto`. @@ -673,6 +675,51 @@ async function fileExists(filePath: string): Promise { } } +export async function buildGetUserSelectionResponse(stateFilePath: string) { + const r = await readSelection(stateFilePath); + if (r.status === 'ok') { + const lines = [ + `User is currently looking at:`, + ` Device: ${r.payload.device}`, + ` POU: ${r.payload.selection.name} (${r.payload.selection.kind})`, + ` Path: ${r.payload.selection.path}`, + ` AbsPath: ${r.payload.selection.abs_path}`, + ` Project: ${r.payload.project_dir}`, + ` Viewer line: ${r.payload.viewer_line}`, + ` Updated: ${r.payload.updated_at}`, + ]; + return { content: [{ type: 'text' as const, text: lines.join('\n') }], isError: false }; + } + if (r.status === 'invalid') { + return { + content: [ + { + type: 'text' as const, + text: `Invalid TUI state file: ${r.reason}. No active selection.`, + }, + ], + isError: false, + }; + } + return { + content: [{ type: 'text' as const, text: 'No active selection (TUI not running or stale).' }], + isError: false, + }; +} + +function defaultStateFilePath(): string { + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + return path.join(os.homedir(), 'AppData', 'Local', 'codesys-mcp', 'tui-state.json'); + } + return path.join(localAppData, 'codesys-mcp', 'tui-state.json'); + } + const xdg = process.env.XDG_STATE_HOME; + const base = xdg ?? path.join(os.homedir(), '.local', 'state'); + return path.join(base, 'codesys-mcp', 'tui-state.json'); +} + export async function startMcpServer(config: ServerConfig): Promise { // Set log level if (config.debug) setLogLevel('debug'); @@ -680,6 +727,7 @@ export async function startMcpServer(config: ServerConfig): Promise { serverLog.info(`Starting CODESYS Persistent MCP Server v0.1.0`); serverLog.info(`Mode: ${config.mode}`); + serverLog.info(`Approve edits: ${config.approveEdits ? 'ON' : 'off'}`); serverLog.info(`CODESYS Path: ${config.codesysPath}`); serverLog.info(`Profile: ${config.profileName}`); serverLog.info(`Workspace: ${config.workspaceDir}`); @@ -845,6 +893,12 @@ export async function startMcpServer(config: ServerConfig): Promise { } ); + s.tool( + 'get_user_selection', + 'Get the POU the user is currently looking at in the phobiCS-tui browser, if any. Returns a freshness-checked snapshot from the TUI state file. Useful for grounding modifying tool calls in what the user has selected.', + async () => buildGetUserSelectionResponse(defaultStateFilePath()) + ); + // ─── Project Tools ─────────────────────────────────────────────────── s.tool( @@ -1053,6 +1107,29 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + if (config.approveEdits) { + const gate = await runApproveGate({ + projectFilePath: escProjPath, + pouPath: sanPouPath, + args: { + declarationCode: args.declarationCode, + implementationCode: args.implementationCode, + }, + }); + if (gate.status === 'rejected') { + return { + content: [{ type: 'text' as const, text: gate.message }], + isError: false, + }; + } + if (gate.status === 'error') { + return { + content: [{ type: 'text' as const, text: `Approve gate error: ${gate.message}` }], + isError: true, + }; + } + // 'accepted' or 'no-existing' → fall through and apply the change. + } const result = await executor.executeScript(script); return await formatModifyingResponse( result, diff --git a/src/state-read.ts b/src/state-read.ts new file mode 100644 index 0000000..534b315 --- /dev/null +++ b/src/state-read.ts @@ -0,0 +1,46 @@ +import * as fs from 'fs'; + +export const FRESHNESS_MS = 60_000; + +export interface SelectionPayload { + version: 1; + updated_at: string; + project_dir: string; + device: string; + selection: { + kind: string; + name: string; + path: string; + abs_path: string; + }; + viewer_line: number; +} + +export type ReadResult = + | { status: 'ok'; payload: SelectionPayload } + | { status: 'missing' } + | { status: 'stale' } + | { status: 'invalid'; reason: string }; + +export async function readSelection(filePath: string): Promise { + let text: string; + try { + text = await fs.promises.readFile(filePath, 'utf8'); + } catch { + return { status: 'missing' }; + } + let parsed: SelectionPayload; + try { + parsed = JSON.parse(text); + } catch (err) { + return { status: 'invalid', reason: (err as Error).message }; + } + if (parsed.version !== 1) { + return { status: 'invalid', reason: `unsupported version ${parsed.version}` }; + } + const ageMs = Date.now() - new Date(parsed.updated_at).getTime(); + if (Number.isNaN(ageMs) || ageMs > FRESHNESS_MS) { + return { status: 'stale' }; + } + return { status: 'ok', payload: parsed }; +} diff --git a/src/tui/approve/Approve.tsx b/src/tui/approve/Approve.tsx new file mode 100644 index 0000000..3c74182 --- /dev/null +++ b/src/tui/approve/Approve.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Box, Text, useInput } from 'ink'; +import { computeHunks } from '../shared/diff.js'; +import { Hunk } from '../shared/types.js'; + +export type Decision = 'accept' | 'reject'; + +export interface ApproveProps { + fileName: string; + oldText: string; + newText: string; + onDecision: (d: Decision) => void; +} + +export function Approve({ fileName, oldText, newText, onDecision }: ApproveProps): React.ReactElement { + const hunks = React.useMemo(() => computeHunks(oldText, newText), [oldText, newText]); + const adds = hunks.filter((h) => h.kind === 'add').length; + const dels = hunks.filter((h) => h.kind === 'del').length; + + useInput((input, key) => { + if (input === 'y') return onDecision('accept'); + if (input === 'n' || input === 'q' || key.escape) return onDecision('reject'); + }); + + return ( + + + ─ Approve change? {fileName} ─── + {adds} lines, − {dels} lines ─ + + + {hunks.map((h, i) => ( + + ))} + + + y accept n reject q reject & quit ESC reject + + + ); +} + +function HunkLine({ hunk }: { hunk: Hunk }): React.ReactElement { + const sigil = hunk.kind === 'add' ? '+' : hunk.kind === 'del' ? '-' : ' '; + const color = hunk.kind === 'add' ? 'green' : hunk.kind === 'del' ? 'red' : undefined; + const lineNoStr = String(hunk.lineNo).padStart(4, ' '); + return ( + + {sigil} {lineNoStr} {hunk.text} + + ); +} diff --git a/src/tui/browser/Browser.tsx b/src/tui/browser/Browser.tsx new file mode 100644 index 0000000..d574c53 --- /dev/null +++ b/src/tui/browser/Browser.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Box, Text, useInput } from 'ink'; +import { Project, POU, Selection } from '../shared/types.js'; +import { Tree, devicePath, pouPath } from './Tree.js'; +import { Viewer } from './Viewer.js'; + +export interface BrowserProps { + project: Project; + readPou: (pou: POU) => Promise; + writeSelection: (s: Selection) => void; + onQuit: () => void; +} + +interface FlatRow { + path: string; + kind: 'device' | 'pou'; + device: string; + pou?: POU; +} + +function flatten(project: Project, expanded: Set): FlatRow[] { + const rows: FlatRow[] = []; + for (const dev of project.devices) { + rows.push({ path: devicePath(dev.name), kind: 'device', device: dev.name }); + if (!expanded.has(devicePath(dev.name))) continue; + for (const p of dev.pous) { + rows.push({ path: pouPath(dev.name, p.relPath), kind: 'pou', device: dev.name, pou: p }); + } + } + return rows; +} + +export function Browser({ project, readPou, writeSelection, onQuit }: BrowserProps): React.ReactElement { + const [expanded, setExpanded] = React.useState>(new Set()); + const [cursorIdx, setCursorIdx] = React.useState(0); + const [text, setText] = React.useState(null); + const [scrollTop] = React.useState(0); + + const rows = React.useMemo(() => flatten(project, expanded), [project, expanded]); + const cursor = rows[Math.min(cursorIdx, rows.length - 1)]; + + React.useEffect(() => { + if (!cursor || cursor.kind !== 'pou' || !cursor.pou) return; + const handle = setTimeout(() => { + writeSelection({ device: cursor.device, pou: cursor.pou!, viewerLine: scrollTop + 1 }); + }, 200); + return () => clearTimeout(handle); + }, [cursor, scrollTop, writeSelection]); + + React.useEffect(() => { + if (!cursor || cursor.kind !== 'pou' || !cursor.pou) { + setText(null); + return; + } + let cancelled = false; + readPou(cursor.pou).then((t) => { + if (!cancelled) setText(t); + }); + return () => { + cancelled = true; + }; + }, [cursor, readPou]); + + useInput((input, key) => { + if (input === 'q') return onQuit(); + if (input === 'j' || key.downArrow) { + setCursorIdx((i) => Math.min(i + 1, rows.length - 1)); + } else if (input === 'k' || key.upArrow) { + setCursorIdx((i) => Math.max(i - 1, 0)); + } else if (input === 'l' || key.return || key.rightArrow) { + if (cursor && cursor.kind === 'device') { + setExpanded((e) => { + const next = new Set(e); + next.add(cursor.path); + return next; + }); + } + } else if (input === 'h' || key.leftArrow) { + if (cursor && cursor.kind === 'device') { + setExpanded((e) => { + const next = new Set(e); + next.delete(cursor.path); + return next; + }); + } + } + }); + + return ( + + ─ {project.rootDir.split(/[/\\]/).pop()} ─ + + + + + + + + + j/k nav l expand h collapse q quit + + ); +} diff --git a/src/tui/browser/Tree.tsx b/src/tui/browser/Tree.tsx new file mode 100644 index 0000000..862c712 --- /dev/null +++ b/src/tui/browser/Tree.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { Project, POU } from '../shared/types.js'; + +export interface TreeProps { + project: Project; + cursorPath: string; + expanded: Set; +} + +export function devicePath(deviceName: string): string { + return `device:${deviceName}`; +} +export function pouPath(deviceName: string, relPath: string): string { + return `pou:${deviceName}:${relPath}`; +} + +export function Tree({ project, cursorPath, expanded }: TreeProps): React.ReactElement { + const rows: React.ReactElement[] = []; + for (const dev of project.devices) { + const dPath = devicePath(dev.name); + const isExpanded = expanded.has(dPath); + const isCursor = cursorPath === dPath; + rows.push( + + {isCursor ? '▶ ' : ' '} + {isExpanded ? '▾ ' : '▸ '} + {dev.name} {dev.pous.length} POUs + + ); + if (!isExpanded) continue; + for (const p of dev.pous) { + const pPath = pouPath(dev.name, p.relPath); + const isPCursor = cursorPath === pPath; + rows.push( + + {isPCursor ? '▶ ' : ' '} + {p.name.padEnd(18)} {p.kind.padEnd(6)} {String(p.loc).padStart(4)} L + + ); + } + } + return {rows}; +} diff --git a/src/tui/browser/Viewer.tsx b/src/tui/browser/Viewer.tsx new file mode 100644 index 0000000..a2cc331 --- /dev/null +++ b/src/tui/browser/Viewer.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { POU } from '../shared/types.js'; + +export interface ViewerProps { + pou: POU | null; + text: string | null; + scrollTop: number; + visibleRows: number; +} + +export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): React.ReactElement { + if (!pou || text == null) { + return ( + + (no POU selected) + + ); + } + const lines = text.split(/\r?\n/); + const slice = lines.slice(scrollTop, scrollTop + visibleRows); + return ( + + + {pou.name}.st ({pou.kind}, {pou.loc} L) + + {slice.map((l, i) => ( + + {String(scrollTop + i + 1).padStart(4, ' ')} {l} + + ))} + + ); +} diff --git a/src/tui/index.tsx b/src/tui/index.tsx new file mode 100644 index 0000000..a3d81f9 --- /dev/null +++ b/src/tui/index.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render } from 'ink'; +import * as fs from 'fs/promises'; +import { Approve, Decision } from './approve/Approve.js'; +import { Browser } from './browser/Browser.js'; +import { walk } from './shared/scan.js'; +import { findProjectRoot } from './shared/discover.js'; +import { writeSelection } from './shared/state-write.js'; +import { stateFilePath } from './shared/state-paths.js'; +import { Selection } from './shared/types.js'; + +const argv = process.argv.slice(2); + +async function main(): Promise { + if (argv[0] === '--version' || argv[0] === '-v') { + process.stdout.write('phobiCS-tui v0.1.0\n'); + return 0; + } + if (argv[0] === 'approve') return runApprove(argv[1], argv[2]); + return runBrowser(argv[0]); +} + +async function runBrowser(maybeRoot: string | undefined): Promise { + const root = maybeRoot + ? maybeRoot + : (await findProjectRoot(process.cwd())) ?? null; + if (!root) { + process.stderr.write( + `No mcp-mirror/ found near ${process.cwd()}. Run mirror_export in CODESYS first.\n` + ); + return 1; + } + let project; + try { + project = await walk(root); + } catch (err) { + process.stderr.write(`phobiCS-tui: ${(err as Error).message}\n`); + return 1; + } + + const stateFile = stateFilePath(); + + return new Promise((resolve) => { + const onWriteSelection = (s: Selection) => { + writeSelection(stateFile, project!.rootDir, s).catch((err) => { + process.stderr.write(`phobiCS-tui: state write failed: ${err}\n`); + }); + }; + const onQuit = () => { + app.unmount(); + resolve(0); + }; + const readPou = (pou: { absPath: string }) => fs.readFile(pou.absPath, 'utf8'); + const app = render( + + ); + }); +} + +async function runApprove(oldPath: string | undefined, newPath: string | undefined): Promise { + if (!oldPath || !newPath) { + process.stderr.write('usage: phobiCS-tui approve \n'); + return 2; + } + let oldText: string; + let newText: string; + try { + oldText = await fs.readFile(oldPath, 'utf8'); + newText = await fs.readFile(newPath, 'utf8'); + } catch (err) { + process.stderr.write(`phobiCS-tui: ${(err as Error).message}\n`); + return 2; + } + return new Promise((resolve) => { + const onDecision = (d: Decision) => { + app.unmount(); + resolve(d === 'accept' ? 0 : 1); + }; + const fileName = oldPath.split(/[/\\]/).pop() ?? oldPath; + const app = render( + + ); + process.on('SIGTERM', () => { + app.unmount(); + resolve(1); + }); + process.on('SIGINT', () => { + app.unmount(); + resolve(1); + }); + }); +} + +main() + .then((code) => process.exit(code)) + .catch((err) => { + process.stderr.write(`phobiCS-tui: ${err}\n`); + process.exit(2); + }); diff --git a/src/tui/package.json b/src/tui/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/src/tui/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/src/tui/shared/diff.ts b/src/tui/shared/diff.ts new file mode 100644 index 0000000..35e96f9 --- /dev/null +++ b/src/tui/shared/diff.ts @@ -0,0 +1,39 @@ +import { diffLines } from 'diff'; +import { Hunk } from './types.js'; + +export function computeHunks(oldText: string, newText: string): Hunk[] { + // jsdiff treats trailing-newline presence as a token boundary, so a missing + // EOF newline shows up as a spurious del+add pair on the last line. Normalize. + const a = oldText.endsWith('\n') ? oldText : oldText + '\n'; + const b = newText.endsWith('\n') ? newText : newText + '\n'; + const parts = diffLines(a, b); + const out: Hunk[] = []; + let oldLine = 1; + let newLine = 1; + + for (const part of parts) { + const lines = part.value.split('\n'); + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + if (part.added) { + for (const text of lines) { + out.push({ kind: 'add', lineNo: newLine, text }); + newLine++; + } + } else if (part.removed) { + for (const text of lines) { + out.push({ kind: 'del', lineNo: oldLine, text }); + oldLine++; + } + } else { + for (const text of lines) { + out.push({ kind: 'ctx', lineNo: newLine, text }); + oldLine++; + newLine++; + } + } + } + + return out; +} diff --git a/src/tui/shared/discover.ts b/src/tui/shared/discover.ts new file mode 100644 index 0000000..6de93d1 --- /dev/null +++ b/src/tui/shared/discover.ts @@ -0,0 +1,17 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +export async function findProjectRoot(startDir: string): Promise { + let cur = path.resolve(startDir); + while (true) { + try { + const stat = await fs.stat(path.join(cur, 'mcp-mirror')); + if (stat.isDirectory()) return cur; + } catch { + // not here, walk up + } + const parent = path.dirname(cur); + if (parent === cur) return null; + cur = parent; + } +} diff --git a/src/tui/shared/scan.ts b/src/tui/shared/scan.ts new file mode 100644 index 0000000..2ec1894 --- /dev/null +++ b/src/tui/shared/scan.ts @@ -0,0 +1,107 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { POU, POUKind, Device, Project } from './types.js'; + +const MIRROR_DIR = 'mcp-mirror'; + +export async function walk(rootDir: string): Promise { + const mirrorDir = path.join(rootDir, MIRROR_DIR); + let mirrorStat; + try { + mirrorStat = await fs.stat(mirrorDir); + } catch { + throw new Error( + `No mcp-mirror/ found at ${rootDir}. Run mirror_export in CODESYS first.` + ); + } + + const deviceNames = (await fs.readdir(mirrorDir, { withFileTypes: true })) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + const devices: Device[] = []; + for (const name of deviceNames) { + const pous = await collectPous(path.join(mirrorDir, name)); + devices.push({ name, pous }); + } + + return { + rootDir, + mirrorMtimeMs: mirrorStat.mtimeMs, + devices, + }; +} + +async function collectPous(deviceRoot: string): Promise { + const stFiles = await listStFilesRecursive(deviceRoot); + const stPathSet = new Set(stFiles.map((f) => f.toLowerCase())); + const out: POU[] = []; + + for (const abs of stFiles) { + const rel = path + .relative(deviceRoot, abs) + .split(path.sep) + .join('/'); + const stat = await fs.stat(abs); + const text = await fs.readFile(abs, 'utf8'); + const loc = text.split(/\r?\n/).filter((l) => l.trim().length > 0).length; + const name = path.basename(abs, '.st'); + const kind = classify(abs, name, stPathSet); + + out.push({ + name, + kind, + relPath: rel, + absPath: abs, + loc, + mtimeMs: stat.mtimeMs, + }); + } + return out; +} + +async function listStFilesRecursive(dir: string): Promise { + const out: string[] = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + out.push(...(await listStFilesRecursive(full))); + } else if (e.isFile() && e.name.toLowerCase().endsWith('.st')) { + out.push(full); + } + } + return out; +} + +function classify(absPath: string, name: string, stPathSet: Set): POUKind { + if (name === 'PLC_PRG') return 'PRG'; + if (name === '_MCP_PROJECT_VERSION') return 'META'; + if (name === 'Get') return parentHasSiblingSt(absPath, stPathSet) ? 'PROPERTY_GETTER' : 'OTHER'; + if (name === 'Set') return parentHasSiblingSt(absPath, stPathSet) ? 'PROPERTY_SETTER' : 'OTHER'; + // Property declarator: same-named child dir contains Get.st/Set.st + // (e.g. FB_Sweep/PropX.st alongside FB_Sweep/PropX/Get.st). + if (hasGetOrSetChild(absPath, name, stPathSet)) return 'OTHER'; + if (/^FB_/.test(name)) return 'FB'; + if (/^GVL_/.test(name)) return 'GVL'; + if (/^ST_/.test(name)) return 'STRUCT'; + if (/^e[A-Z]/.test(name)) return 'ENUM'; + if (parentHasSiblingSt(absPath, stPathSet)) return 'METHOD'; + return 'OTHER'; +} + +function hasGetOrSetChild(absPath: string, name: string, stPathSet: Set): boolean { + const parentDir = path.dirname(absPath); + const childDir = path.join(parentDir, name); + const get = path.join(childDir, 'Get.st').toLowerCase(); + const set = path.join(childDir, 'Set.st').toLowerCase(); + return stPathSet.has(get) || stPathSet.has(set); +} + +function parentHasSiblingSt(absPath: string, stPathSet: Set): boolean { + const parentDir = path.dirname(absPath); + const grandparentDir = path.dirname(parentDir); + const parentName = path.basename(parentDir); + const sibling = path.join(grandparentDir, `${parentName}.st`).toLowerCase(); + return stPathSet.has(sibling); +} diff --git a/src/tui/shared/state-paths.ts b/src/tui/shared/state-paths.ts new file mode 100644 index 0000000..e7e0e6c --- /dev/null +++ b/src/tui/shared/state-paths.ts @@ -0,0 +1,19 @@ +import * as os from 'os'; +import * as path from 'path'; + +const APP_DIR = 'codesys-mcp'; +const FILE_NAME = 'tui-state.json'; + +export function stateFilePath(): string { + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + throw new Error('LOCALAPPDATA is not set; cannot resolve TUI state file path'); + } + return path.win32.join(localAppData, APP_DIR, FILE_NAME); + } + const xdg = process.env.XDG_STATE_HOME; + const home = process.env.HOME ?? os.homedir(); + const base = xdg ?? path.posix.join(home, '.local', 'state'); + return path.posix.join(base, APP_DIR, FILE_NAME); +} diff --git a/src/tui/shared/state-write.ts b/src/tui/shared/state-write.ts new file mode 100644 index 0000000..17af437 --- /dev/null +++ b/src/tui/shared/state-write.ts @@ -0,0 +1,29 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { Selection } from './types.js'; + +export async function writeSelection( + filePath: string, + projectDir: string, + selection: Selection +): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + + const payload = { + version: 1, + updated_at: new Date().toISOString(), + project_dir: projectDir, + device: selection.device, + selection: { + kind: selection.pou.kind, + name: selection.pou.name, + path: selection.pou.relPath, + abs_path: selection.pou.absPath, + }, + viewer_line: selection.viewerLine, + }; + + const tmp = `${filePath}.${process.pid}.tmp`; + await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8'); + await fs.rename(tmp, filePath); +} diff --git a/src/tui/shared/types.ts b/src/tui/shared/types.ts new file mode 100644 index 0000000..c36775c --- /dev/null +++ b/src/tui/shared/types.ts @@ -0,0 +1,52 @@ +export type POUKind = + | 'PRG' + | 'FB' + | 'GVL' + | 'STRUCT' + | 'ENUM' + | 'METHOD' + | 'PROPERTY_GETTER' + | 'PROPERTY_SETTER' + | 'META' + | 'OTHER'; + +export interface POU { + /** Display name without .st extension */ + name: string; + kind: POUKind; + /** Path relative to the device root, with forward slashes. */ + relPath: string; + absPath: string; + /** Non-blank line count */ + loc: number; + mtimeMs: number; +} + +export interface Device { + /** Top-level subdir under mcp-mirror/, e.g. "CodesysRpi" */ + name: string; + pous: POU[]; +} + +export interface Project { + /** The project's parent directory (the dir that contains mcp-mirror/) */ + rootDir: string; + /** Mirror dir mtime — used for the "stale" indicator */ + mirrorMtimeMs: number; + devices: Device[]; +} + +export interface Selection { + device: string; + pou: POU; + viewerLine: number; +} + +export type HunkKind = 'add' | 'del' | 'ctx'; + +export interface Hunk { + kind: HunkKind; + /** 1-based line number in the side this hunk belongs to (new-side for add/ctx, old-side for del). */ + lineNo: number; + text: string; +} diff --git a/src/types.ts b/src/types.ts index 2cdab50..a7506b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,6 +67,12 @@ export interface ServerConfig extends LauncherConfig { * as a hard error -- the underlying edit already succeeded. */ autoMirror: boolean; + /** + * If true, modifying tools (set_pou_code et al.) will shell out to + * phobiCS-tui in approve mode and only proceed on exit 0. Off by default + * so existing scripted flows are not regressed. + */ + approveEdits?: boolean; } /** Script template parameters */ diff --git a/tests/tui/Approve.test.tsx b/tests/tui/Approve.test.tsx new file mode 100644 index 0000000..129ebb5 --- /dev/null +++ b/tests/tui/Approve.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'ink-testing-library'; +import { Approve } from '../../src/tui/approve/Approve.tsx'; + +const OLD = 'PROGRAM PLC_PRG\nVAR\n counter : INT := 0;\nEND_VAR'; +const NEW = 'PROGRAM PLC_PRG\nVAR\n counter : DINT := 0;\n overflow : BOOL;\nEND_VAR'; + +describe('', () => { + it('renders both deletions and additions in a unified diff', () => { + const { lastFrame } = render( + {}} /> + ); + const out = lastFrame()!; + expect(out).toContain('counter : INT := 0;'); + expect(out).toContain('counter : DINT := 0;'); + expect(out).toContain('overflow : BOOL;'); + expect(out).toMatch(/Approve change\? PLC_PRG\.st/); + }); + + it('reports add/del totals in the header', () => { + const { lastFrame } = render( + {}} /> + ); + const out = lastFrame()!; + expect(out).toMatch(/\+ 2 lines.*− 1 lines/); + }); + + const flush = () => new Promise((r) => setImmediate(r)); + + it('calls onDecision("accept") when y is pressed', async () => { + const decision = vi.fn(); + const { stdin } = render( + + ); + await flush(); + stdin.write('y'); + await flush(); + expect(decision).toHaveBeenCalledWith('accept'); + }); + + it('calls onDecision("reject") when n is pressed', async () => { + const decision = vi.fn(); + const { stdin } = render( + + ); + await flush(); + stdin.write('n'); + await flush(); + expect(decision).toHaveBeenCalledWith('reject'); + }); + + it('calls onDecision("reject") when q is pressed', async () => { + const decision = vi.fn(); + const { stdin } = render( + + ); + await flush(); + stdin.write('q'); + await flush(); + expect(decision).toHaveBeenCalledWith('reject'); + }); + + it('calls onDecision("reject") on escape', async () => { + const decision = vi.fn(); + const { stdin } = render( + + ); + await flush(); + stdin.write(String.fromCharCode(27)); + await flush(); + expect(decision).toHaveBeenCalledWith('reject'); + }); +}); diff --git a/tests/tui/Browser.test.tsx b/tests/tui/Browser.test.tsx new file mode 100644 index 0000000..08c0ff5 --- /dev/null +++ b/tests/tui/Browser.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'ink-testing-library'; +import { Browser } from '../../src/tui/browser/Browser.tsx'; +import { Project } from '../../src/tui/shared/types.ts'; + +const project: Project = { + rootDir: '/p', + mirrorMtimeMs: 0, + devices: [ + { + name: 'D1', + pous: [ + { name: 'PLC_PRG', kind: 'PRG', relPath: 'PLC_PRG.st', absPath: '/abs/PLC_PRG.st', loc: 5, mtimeMs: 0 }, + { name: 'FB_X', kind: 'FB', relPath: 'FB_X.st', absPath: '/abs/FB_X.st', loc: 9, mtimeMs: 0 }, + ], + }, + ], +}; + +const flush = () => new Promise((r) => setImmediate(r)); + +describe('', () => { + it('shows device row, expands it on l, then moves cursor onto the first POU on j', async () => { + const onWriteSelection = vi.fn(); + const readPou = async () => 'PROGRAM PLC_PRG\nEND_PROGRAM'; + const { stdin, lastFrame } = render( + {}} + /> + ); + await flush(); + expect(lastFrame()).toContain('D1'); + stdin.write('l'); + await flush(); + expect(lastFrame()).toContain('PLC_PRG'); + stdin.write('j'); + await flush(); + expect(lastFrame()).toMatch(/▶ PLC_PRG/); + }); + + it('calls writeSelection when a POU is highlighted', async () => { + const onWriteSelection = vi.fn(); + const readPou = async () => 'PROGRAM X\nEND_PROGRAM'; + const { stdin } = render( + {}} + /> + ); + await flush(); + stdin.write('l'); + await flush(); + stdin.write('j'); + await new Promise((r) => setTimeout(r, 250)); + expect(onWriteSelection).toHaveBeenCalled(); + const arg = onWriteSelection.mock.calls.at(-1)![0]; + expect(arg.device).toBe('D1'); + expect(arg.pou.name).toBe('PLC_PRG'); + }); + + it('calls onQuit on q', async () => { + const onQuit = vi.fn(); + const { stdin } = render( + ''} + writeSelection={() => {}} + onQuit={onQuit} + /> + ); + await flush(); + stdin.write('q'); + await flush(); + expect(onQuit).toHaveBeenCalled(); + }); +}); diff --git a/tests/tui/Tree.test.tsx b/tests/tui/Tree.test.tsx new file mode 100644 index 0000000..6a416c7 --- /dev/null +++ b/tests/tui/Tree.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render } from 'ink-testing-library'; +import { Tree } from '../../src/tui/browser/Tree.tsx'; +import { Project } from '../../src/tui/shared/types.ts'; + +const project: Project = { + rootDir: '/p', + mirrorMtimeMs: 0, + devices: [ + { + name: 'CodesysRpi', + pous: [ + { name: 'PLC_PRG', kind: 'PRG', relPath: 'a/PLC_PRG.st', absPath: '/abs/a/PLC_PRG.st', loc: 5, mtimeMs: 0 }, + { name: 'FB_Test', kind: 'FB', relPath: 'a/FB_Test.st', absPath: '/abs/a/FB_Test.st', loc: 87, mtimeMs: 0 }, + ], + }, + ], +}; + +describe('', () => { + it('renders devices and POU rows when expanded', () => { + const { lastFrame } = render( + + ); + const out = lastFrame()!; + expect(out).toContain('CodesysRpi'); + expect(out).toContain('PLC_PRG'); + expect(out).toContain('FB_Test'); + expect(out).toContain('PRG'); + expect(out).toContain('FB'); + }); + + it('does not show POU rows when device is collapsed', () => { + const { lastFrame } = render( + + ); + const out = lastFrame()!; + expect(out).toContain('CodesysRpi'); + expect(out).not.toContain('PLC_PRG'); + }); + + it('marks the cursor row', () => { + const { lastFrame } = render( + + ); + const out = lastFrame()!; + expect(out).toMatch(/▶ FB_Test/); + }); +}); diff --git a/tests/tui/diff.test.ts b/tests/tui/diff.test.ts new file mode 100644 index 0000000..7642598 --- /dev/null +++ b/tests/tui/diff.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { computeHunks } from '../../src/tui/shared/diff.ts'; + +describe('computeHunks', () => { + it('returns ctx hunks when both sides are identical', () => { + const a = 'one\ntwo\nthree'; + const hunks = computeHunks(a, a); + expect(hunks.every((h) => h.kind === 'ctx')).toBe(true); + expect(hunks).toHaveLength(3); + }); + + it('marks added lines', () => { + const a = 'one\ntwo'; + const b = 'one\ntwo\nthree'; + const hunks = computeHunks(a, b); + const added = hunks.filter((h) => h.kind === 'add'); + expect(added).toHaveLength(1); + expect(added[0].text).toBe('three'); + }); + + it('marks deleted lines', () => { + const a = 'one\ntwo\nthree'; + const b = 'one\ntwo'; + const hunks = computeHunks(a, b); + const deleted = hunks.filter((h) => h.kind === 'del'); + expect(deleted).toHaveLength(1); + expect(deleted[0].text).toBe('three'); + }); + + it('handles a substitution as del + add', () => { + const a = 'a\nb\nc'; + const b = 'a\nB\nc'; + const hunks = computeHunks(a, b); + expect(hunks.some((h) => h.kind === 'del' && h.text === 'b')).toBe(true); + expect(hunks.some((h) => h.kind === 'add' && h.text === 'B')).toBe(true); + }); + + it('reports add/ctx line numbers against the new side, del against the old side', () => { + const a = 'a\nb\nc'; + const b = 'a\nx\nc'; + const hunks = computeHunks(a, b); + const del = hunks.find((h) => h.kind === 'del')!; + const add = hunks.find((h) => h.kind === 'add')!; + expect(del.lineNo).toBe(2); + expect(add.lineNo).toBe(2); + }); +}); diff --git a/tests/tui/discover.test.ts b/tests/tui/discover.test.ts new file mode 100644 index 0000000..b9eac91 --- /dev/null +++ b/tests/tui/discover.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { findProjectRoot } from '../../src/tui/shared/discover.ts'; + +async function tmpDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'phobics-tui-disc-')); +} + +describe('findProjectRoot', () => { + it('returns the start dir when it directly contains mcp-mirror/', async () => { + const root = await tmpDir(); + await fs.mkdir(path.join(root, 'mcp-mirror')); + expect(await findProjectRoot(root)).toBe(root); + }); + + it('walks upward to find mcp-mirror/', async () => { + const root = await tmpDir(); + await fs.mkdir(path.join(root, 'mcp-mirror')); + const sub = path.join(root, 'a', 'b', 'c'); + await fs.mkdir(sub, { recursive: true }); + expect(await findProjectRoot(sub)).toBe(root); + }); + + it('returns null when no mcp-mirror/ is found anywhere upward', async () => { + const root = await tmpDir(); + expect(await findProjectRoot(root)).toBeNull(); + }); +}); diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep/PropX.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep/PropX.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep/PropX.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep/PropX/Get.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep/PropX/Get.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep/PropX/Get.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep/PropX/Set.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep/PropX/Set.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Sweep/PropX/Set.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Test.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Test.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Test.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Test/DoSomething.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Test/DoSomething.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/FB_Test/DoSomething.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/GVL_Test.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/GVL_Test.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/GVL_Test.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/PLC_PRG.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/PLC_PRG.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/PLC_PRG.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/ST_Sample.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/ST_Sample.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/ST_Sample.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/_MCP_PROJECT_VERSION.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/_MCP_PROJECT_VERSION.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/_MCP_PROJECT_VERSION.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/eMode.st b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/eMode.st new file mode 100644 index 0000000..96eb164 --- /dev/null +++ b/tests/tui/fixtures/mini-mirror/mcp-mirror/CodesysRpi/Plc Logic/Application/eMode.st @@ -0,0 +1,2 @@ +(* fixture *) +LINE_TWO := 0; diff --git a/tests/tui/index.integration.test.ts b/tests/tui/index.integration.test.ts new file mode 100644 index 0000000..76aeb2e --- /dev/null +++ b/tests/tui/index.integration.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { spawn } from 'child_process'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const BIN = path.resolve('dist/tui/index.js'); + +beforeAll(async () => { + await fs.access(BIN); +}); + +function run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, [BIN, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('exit', (code) => resolve({ code: code ?? -1, stdout, stderr })); + }); +} + +describe('phobiCS-tui (integration, no-TTY paths only)', () => { + it('--version prints version and exits 0', async () => { + const r = await run(['--version']); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/phobiCS-tui v\d/); + }); + + it('approve with missing file exits 2', async () => { + const r = await run(['approve', '/nonexistent/old.st', '/nonexistent/new.st']); + expect(r.code).toBe(2); + }); + + it('approve with no args exits 2 with usage on stderr', async () => { + const r = await run(['approve']); + expect(r.code).toBe(2); + expect(r.stderr).toMatch(/usage:/); + }); +}); diff --git a/tests/tui/scan.test.ts b/tests/tui/scan.test.ts new file mode 100644 index 0000000..304ec1a --- /dev/null +++ b/tests/tui/scan.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import * as path from 'path'; +import * as url from 'url'; +import { walk } from '../../src/tui/shared/scan.ts'; + +const fixtureRoot = path.join( + path.dirname(url.fileURLToPath(import.meta.url)), + 'fixtures', + 'mini-mirror' +); + +describe('scan.walk', () => { + it('classifies every .st under the fixture mirror', async () => { + const project = await walk(fixtureRoot); + expect(project.devices).toHaveLength(1); + const dev = project.devices[0]; + expect(dev.name).toBe('CodesysRpi'); + + const byName = Object.fromEntries(dev.pous.map((p) => [p.name, p])); + + expect(byName['PLC_PRG'].kind).toBe('PRG'); + expect(byName['FB_Test'].kind).toBe('FB'); + expect(byName['DoSomething'].kind).toBe('METHOD'); + expect(byName['GVL_Test'].kind).toBe('GVL'); + expect(byName['ST_Sample'].kind).toBe('STRUCT'); + expect(byName['eMode'].kind).toBe('ENUM'); + expect(byName['_MCP_PROJECT_VERSION'].kind).toBe('META'); + expect(byName['FB_Sweep'].kind).toBe('FB'); + expect(byName['PropX'].kind).toBe('OTHER'); + expect(byName['Get'].kind).toBe('PROPERTY_GETTER'); + expect(byName['Set'].kind).toBe('PROPERTY_SETTER'); + }); + + it('counts non-blank LOC', async () => { + const project = await walk(fixtureRoot); + const plcPrg = project.devices[0].pous.find((p) => p.name === 'PLC_PRG'); + expect(plcPrg).toBeDefined(); + expect(plcPrg!.loc).toBe(2); + }); + + it('sets project rootDir to the dir containing mcp-mirror/', async () => { + const project = await walk(fixtureRoot); + expect(project.rootDir).toBe(fixtureRoot); + }); + + it('throws a clear error if mcp-mirror/ is missing', async () => { + await expect(walk(path.join(fixtureRoot, 'nonexistent'))).rejects.toThrow( + /No mcp-mirror/ + ); + }); +}); diff --git a/tests/tui/state-paths.test.ts b/tests/tui/state-paths.test.ts new file mode 100644 index 0000000..4df3849 --- /dev/null +++ b/tests/tui/state-paths.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as path from 'path'; +import { stateFilePath } from '../../src/tui/shared/state-paths.ts'; + +const ORIG_PLATFORM = Object.getOwnPropertyDescriptor(process, 'platform')!; +const ORIG_ENV = { ...process.env }; + +function setPlatform(p: NodeJS.Platform) { + Object.defineProperty(process, 'platform', { value: p, configurable: true }); +} + +beforeEach(() => { + process.env = { ...ORIG_ENV }; +}); + +afterEach(() => { + Object.defineProperty(process, 'platform', ORIG_PLATFORM); + process.env = { ...ORIG_ENV }; +}); + +describe('stateFilePath', () => { + it('uses %LOCALAPPDATA%/codesys-mcp/tui-state.json on Windows', () => { + setPlatform('win32'); + process.env.LOCALAPPDATA = 'C:\\\\Users\\\\u\\\\AppData\\\\Local'; + const p = stateFilePath(); + expect(p).toBe( + path.join('C:\\\\Users\\\\u\\\\AppData\\\\Local', 'codesys-mcp', 'tui-state.json') + ); + }); + + it('uses $XDG_STATE_HOME/codesys-mcp/tui-state.json when set', () => { + setPlatform('linux'); + process.env.XDG_STATE_HOME = '/tmp/xdg-state'; + const p = stateFilePath(); + expect(p).toBe('/tmp/xdg-state/codesys-mcp/tui-state.json'); + }); + + it('falls back to ~/.local/state on Linux without XDG_STATE_HOME', () => { + setPlatform('linux'); + delete process.env.XDG_STATE_HOME; + process.env.HOME = '/home/u'; + const p = stateFilePath(); + expect(p).toBe('/home/u/.local/state/codesys-mcp/tui-state.json'); + }); + + it('throws on Windows when LOCALAPPDATA is unset', () => { + setPlatform('win32'); + delete process.env.LOCALAPPDATA; + expect(() => stateFilePath()).toThrow(/LOCALAPPDATA/); + }); +}); diff --git a/tests/tui/state-write.test.ts b/tests/tui/state-write.test.ts new file mode 100644 index 0000000..3b14089 --- /dev/null +++ b/tests/tui/state-write.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { writeSelection } from '../../src/tui/shared/state-write.ts'; +import { Selection } from '../../src/tui/shared/types.ts'; + +async function tmpDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'phobics-tui-')); +} + +const sampleSelection: Selection = { + device: 'CodesysRpi', + pou: { + name: 'FB_Test', + kind: 'FB', + relPath: 'Plc Logic/Application/FB_Test.st', + absPath: '/abs/Plc Logic/Application/FB_Test.st', + loc: 87, + mtimeMs: 0, + }, + viewerLine: 12, +}; + +describe('writeSelection', () => { + it('writes a JSON file atomically with v1 envelope', async () => { + const dir = await tmpDir(); + const target = path.join(dir, 'tui-state.json'); + await writeSelection(target, '/abs/project', sampleSelection); + + const text = await fs.readFile(target, 'utf8'); + const parsed = JSON.parse(text); + expect(parsed.version).toBe(1); + expect(parsed.project_dir).toBe('/abs/project'); + expect(parsed.device).toBe('CodesysRpi'); + expect(parsed.selection.name).toBe('FB_Test'); + expect(parsed.selection.kind).toBe('FB'); + expect(parsed.viewer_line).toBe(12); + expect(typeof parsed.updated_at).toBe('string'); + expect(() => new Date(parsed.updated_at)).not.toThrow(); + }); + + it('creates parent directories as needed', async () => { + const dir = await tmpDir(); + const target = path.join(dir, 'a', 'b', 'tui-state.json'); + await writeSelection(target, '/abs/project', sampleSelection); + const stat = await fs.stat(target); + expect(stat.isFile()).toBe(true); + }); + + it('does not leave the .tmp file behind on success', async () => { + const dir = await tmpDir(); + const target = path.join(dir, 'tui-state.json'); + await writeSelection(target, '/abs/project', sampleSelection); + const entries = await fs.readdir(dir); + expect(entries.filter((e) => e.endsWith('.tmp'))).toEqual([]); + }); +}); diff --git a/tests/unit/approve-gate.test.ts b/tests/unit/approve-gate.test.ts new file mode 100644 index 0000000..dd4d3bc --- /dev/null +++ b/tests/unit/approve-gate.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { composeMergedContent, IMPL_SENTINEL } from '../../src/approve-gate'; + +const existing = [ + 'PROGRAM PLC_PRG', + 'VAR', + ' counter : INT := 0;', + 'END_VAR', + IMPL_SENTINEL, + 'counter := counter + 1;', +].join('\n'); + +describe('composeMergedContent', () => { + it('overrides only declaration when only declarationCode is provided', () => { + const out = composeMergedContent(existing, { + declarationCode: 'PROGRAM PLC_PRG\nVAR\n counter : DINT := 0;\nEND_VAR', + implementationCode: undefined, + }); + expect(out).toContain('counter : DINT := 0;'); + expect(out).toContain('counter := counter + 1;'); + }); + + it('overrides only implementation when only implementationCode is provided', () => { + const out = composeMergedContent(existing, { + declarationCode: undefined, + implementationCode: 'counter := counter + 2;', + }); + expect(out).toContain('counter : INT := 0;'); + expect(out).toContain('counter := counter + 2;'); + }); + + it('overrides both when both are provided', () => { + const out = composeMergedContent(existing, { + declarationCode: 'PROGRAM X\nVAR\nEND_VAR', + implementationCode: 'x := 1;', + }); + expect(out).toBe(['PROGRAM X', 'VAR', 'END_VAR', IMPL_SENTINEL, 'x := 1;'].join('\n')); + }); + + it('handles existing files with no sentinel by appending one', () => { + const noSentinel = 'PROGRAM PLC_PRG\nVAR\nEND_VAR'; + const out = composeMergedContent(noSentinel, { + declarationCode: undefined, + implementationCode: 'x := 1;', + }); + expect(out).toContain(IMPL_SENTINEL); + expect(out).toContain('x := 1;'); + }); +}); diff --git a/tests/unit/get-user-selection.test.ts b/tests/unit/get-user-selection.test.ts new file mode 100644 index 0000000..45d4bc0 --- /dev/null +++ b/tests/unit/get-user-selection.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { buildGetUserSelectionResponse } from '../../src/server'; + +async function tmpFile(content: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-tool-')); + const f = path.join(dir, 'tui-state.json'); + await fs.writeFile(f, content, 'utf8'); + return f; +} + +const fresh = () => + JSON.stringify({ + version: 1, + updated_at: new Date().toISOString(), + project_dir: '/p', + device: 'D1', + selection: { + kind: 'FB', + name: 'FB_Test', + path: 'a/FB_Test.st', + abs_path: '/abs/a/FB_Test.st', + }, + viewer_line: 12, + }); + +describe('buildGetUserSelectionResponse', () => { + it('returns a structured payload for a fresh state file', async () => { + const f = await tmpFile(fresh()); + const res = await buildGetUserSelectionResponse(f); + expect(res.isError).toBeFalsy(); + expect(res.content[0].text).toContain('FB_Test'); + expect(res.content[0].text).toContain('D1'); + }); + + it('returns "no active selection" text when state file is missing', async () => { + const res = await buildGetUserSelectionResponse('/nonexistent.json'); + expect(res.isError).toBeFalsy(); + expect(res.content[0].text).toMatch(/no active selection/i); + }); + + it('returns "no active selection" text when state is stale', async () => { + const old = JSON.stringify({ + ...JSON.parse(fresh()), + updated_at: new Date(Date.now() - 120_000).toISOString(), + }); + const f = await tmpFile(old); + const res = await buildGetUserSelectionResponse(f); + expect(res.isError).toBeFalsy(); + expect(res.content[0].text).toMatch(/no active selection/i); + }); +}); diff --git a/tests/unit/state-read.test.ts b/tests/unit/state-read.test.ts new file mode 100644 index 0000000..523454a --- /dev/null +++ b/tests/unit/state-read.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { readSelection, FRESHNESS_MS } from '../../src/state-read'; + +async function tmpFile(content: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-read-')); + const f = path.join(dir, 'tui-state.json'); + await fs.writeFile(f, content, 'utf8'); + return f; +} + +const FRESH = JSON.stringify({ + version: 1, + updated_at: new Date().toISOString(), + project_dir: '/p', + device: 'D1', + selection: { + kind: 'FB', + name: 'FB_Test', + path: 'a/FB_Test.st', + abs_path: '/abs/a/FB_Test.st', + }, + viewer_line: 12, +}); + +describe('readSelection', () => { + it('returns the parsed payload when fresh', async () => { + const f = await tmpFile(FRESH); + const r = await readSelection(f); + expect(r.status).toBe('ok'); + if (r.status === 'ok') { + expect(r.payload.device).toBe('D1'); + expect(r.payload.selection.name).toBe('FB_Test'); + } + }); + + it('returns stale when updated_at is older than the freshness window', async () => { + const old = JSON.stringify({ + ...JSON.parse(FRESH), + updated_at: new Date(Date.now() - FRESHNESS_MS - 5000).toISOString(), + }); + const f = await tmpFile(old); + const r = await readSelection(f); + expect(r.status).toBe('stale'); + }); + + it('returns missing when the file does not exist', async () => { + const r = await readSelection('/nonexistent/tui-state.json'); + expect(r.status).toBe('missing'); + }); + + it('returns invalid when the JSON is malformed', async () => { + const f = await tmpFile('not json'); + const r = await readSelection(f); + expect(r.status).toBe('invalid'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 434c423..765dfbb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "sourceMap": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests"] + "exclude": ["node_modules", "dist", "tests", "src/tui"] } diff --git a/tsconfig.tui.json b/tsconfig.tui.json new file mode 100644 index 0000000..395ddd0 --- /dev/null +++ b/tsconfig.tui.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "outDir": "dist/tui", + "rootDir": "src/tui", + "jsx": "react", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/tui/**/*"], + "exclude": ["node_modules", "dist", "tests"] +}