Merge pull request #1 from phobicdotno/feature/phobics-tui
phobiCS-tui v0.1/v0.2: in-fork TUI for CODESYS exported ST + --approve-edits gate
This commit is contained in:
commit
065bce5b3d
45 changed files with 2247 additions and 6 deletions
16
README.md
16
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 <projectDir> # explicit project directory
|
||||
phobiCS-tui approve <a.st> <b.st> # 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.
|
||||
|
|
|
|||
672
package-lock.json
generated
672
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
14
package.json
14
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"
|
||||
|
|
|
|||
117
src/approve-gate.ts
Normal file
117
src/approve-gate.ts
Normal file
|
|
@ -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<string | null> {
|
||||
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<void> {
|
||||
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<GateResult> {
|
||||
const tuiBin = path.join(__dirname, 'tui', 'index.js');
|
||||
return new Promise<GateResult>((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<GateResult> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <projectDir>/mcp-mirror/ sees changes live', false)
|
||||
.option('--approve-edits', 'Gate modifying MCP tools behind a phobiCS-tui y/n diff prompt', false)
|
||||
.option('--timeout <ms>', '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`);
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
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<void> {
|
||||
// Set log level
|
||||
if (config.debug) setLogLevel('debug');
|
||||
|
|
@ -680,6 +727,7 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|||
|
||||
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<void> {
|
|||
}
|
||||
);
|
||||
|
||||
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<void> {
|
|||
},
|
||||
['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,
|
||||
|
|
|
|||
46
src/state-read.ts
Normal file
46
src/state-read.ts
Normal file
|
|
@ -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<ReadResult> {
|
||||
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 };
|
||||
}
|
||||
51
src/tui/approve/Approve.tsx
Normal file
51
src/tui/approve/Approve.tsx
Normal file
|
|
@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
─ Approve change? {fileName} ─── + {adds} lines, − {dels} lines ─
|
||||
</Text>
|
||||
<Box flexDirection="column">
|
||||
{hunks.map((h, i) => (
|
||||
<HunkLine key={i} hunk={h} />
|
||||
))}
|
||||
</Box>
|
||||
<Text>
|
||||
y accept n reject q reject & quit ESC reject
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Text color={color}>
|
||||
{sigil} {lineNoStr} {hunk.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
103
src/tui/browser/Browser.tsx
Normal file
103
src/tui/browser/Browser.tsx
Normal file
|
|
@ -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<string>;
|
||||
writeSelection: (s: Selection) => void;
|
||||
onQuit: () => void;
|
||||
}
|
||||
|
||||
interface FlatRow {
|
||||
path: string;
|
||||
kind: 'device' | 'pou';
|
||||
device: string;
|
||||
pou?: POU;
|
||||
}
|
||||
|
||||
function flatten(project: Project, expanded: Set<string>): 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<Set<string>>(new Set());
|
||||
const [cursorIdx, setCursorIdx] = React.useState(0);
|
||||
const [text, setText] = React.useState<string | null>(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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>─ {project.rootDir.split(/[/\\]/).pop()} ─</Text>
|
||||
<Box flexDirection="row">
|
||||
<Box flexDirection="column" width="40%">
|
||||
<Tree project={project} cursorPath={cursor?.path ?? ''} expanded={expanded} />
|
||||
</Box>
|
||||
<Box flexDirection="column" width="60%">
|
||||
<Viewer pou={cursor?.pou ?? null} text={text} scrollTop={scrollTop} visibleRows={20} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Text>j/k nav l expand h collapse q quit</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
44
src/tui/browser/Tree.tsx
Normal file
44
src/tui/browser/Tree.tsx
Normal file
|
|
@ -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<string>;
|
||||
}
|
||||
|
||||
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(
|
||||
<Text key={dPath}>
|
||||
{isCursor ? '▶ ' : ' '}
|
||||
{isExpanded ? '▾ ' : '▸ '}
|
||||
{dev.name} {dev.pous.length} POUs
|
||||
</Text>
|
||||
);
|
||||
if (!isExpanded) continue;
|
||||
for (const p of dev.pous) {
|
||||
const pPath = pouPath(dev.name, p.relPath);
|
||||
const isPCursor = cursorPath === pPath;
|
||||
rows.push(
|
||||
<Text key={pPath}>
|
||||
{isPCursor ? '▶ ' : ' '}
|
||||
{p.name.padEnd(18)} {p.kind.padEnd(6)} {String(p.loc).padStart(4)} L
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <Box flexDirection="column">{rows}</Box>;
|
||||
}
|
||||
34
src/tui/browser/Viewer.tsx
Normal file
34
src/tui/browser/Viewer.tsx
Normal file
|
|
@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>(no POU selected)</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const lines = text.split(/\r?\n/);
|
||||
const slice = lines.slice(scrollTop, scrollTop + visibleRows);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>
|
||||
{pou.name}.st ({pou.kind}, {pou.loc} L)
|
||||
</Text>
|
||||
{slice.map((l, i) => (
|
||||
<Text key={scrollTop + i}>
|
||||
{String(scrollTop + i + 1).padStart(4, ' ')} {l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
104
src/tui/index.tsx
Normal file
104
src/tui/index.tsx
Normal file
|
|
@ -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<number> {
|
||||
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<number> {
|
||||
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<number>((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(
|
||||
<Browser
|
||||
project={project!}
|
||||
readPou={readPou}
|
||||
writeSelection={onWriteSelection}
|
||||
onQuit={onQuit}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function runApprove(oldPath: string | undefined, newPath: string | undefined): Promise<number> {
|
||||
if (!oldPath || !newPath) {
|
||||
process.stderr.write('usage: phobiCS-tui approve <existing> <proposed>\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<number>((resolve) => {
|
||||
const onDecision = (d: Decision) => {
|
||||
app.unmount();
|
||||
resolve(d === 'accept' ? 0 : 1);
|
||||
};
|
||||
const fileName = oldPath.split(/[/\\]/).pop() ?? oldPath;
|
||||
const app = render(
|
||||
<Approve fileName={fileName} oldText={oldText} newText={newText} onDecision={onDecision} />
|
||||
);
|
||||
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);
|
||||
});
|
||||
3
src/tui/package.json
Normal file
3
src/tui/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
39
src/tui/shared/diff.ts
Normal file
39
src/tui/shared/diff.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
17
src/tui/shared/discover.ts
Normal file
17
src/tui/shared/discover.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export async function findProjectRoot(startDir: string): Promise<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
107
src/tui/shared/scan.ts
Normal file
107
src/tui/shared/scan.ts
Normal file
|
|
@ -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<Project> {
|
||||
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<POU[]> {
|
||||
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<string[]> {
|
||||
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<string>): 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<string>): 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<string>): 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);
|
||||
}
|
||||
19
src/tui/shared/state-paths.ts
Normal file
19
src/tui/shared/state-paths.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
29
src/tui/shared/state-write.ts
Normal file
29
src/tui/shared/state-write.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
52
src/tui/shared/types.ts
Normal file
52
src/tui/shared/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
74
tests/tui/Approve.test.tsx
Normal file
74
tests/tui/Approve.test.tsx
Normal file
|
|
@ -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('<Approve>', () => {
|
||||
it('renders both deletions and additions in a unified diff', () => {
|
||||
const { lastFrame } = render(
|
||||
<Approve fileName="PLC_PRG.st" oldText={OLD} newText={NEW} onDecision={() => {}} />
|
||||
);
|
||||
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(
|
||||
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={() => {}} />
|
||||
);
|
||||
const out = lastFrame()!;
|
||||
expect(out).toMatch(/\+ 2 lines.*− 1 lines/);
|
||||
});
|
||||
|
||||
const flush = () => new Promise<void>((r) => setImmediate(r));
|
||||
|
||||
it('calls onDecision("accept") when y is pressed', async () => {
|
||||
const decision = vi.fn();
|
||||
const { stdin } = render(
|
||||
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
|
||||
);
|
||||
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(
|
||||
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
|
||||
);
|
||||
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(
|
||||
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
|
||||
);
|
||||
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(
|
||||
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
|
||||
);
|
||||
await flush();
|
||||
stdin.write(String.fromCharCode(27));
|
||||
await flush();
|
||||
expect(decision).toHaveBeenCalledWith('reject');
|
||||
});
|
||||
});
|
||||
82
tests/tui/Browser.test.tsx
Normal file
82
tests/tui/Browser.test.tsx
Normal file
|
|
@ -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<void>((r) => setImmediate(r));
|
||||
|
||||
describe('<Browser>', () => {
|
||||
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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={readPou}
|
||||
writeSelection={onWriteSelection}
|
||||
onQuit={() => {}}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={readPou}
|
||||
writeSelection={onWriteSelection}
|
||||
onQuit={() => {}}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<Browser
|
||||
project={project}
|
||||
readPou={async () => ''}
|
||||
writeSelection={() => {}}
|
||||
onQuit={onQuit}
|
||||
/>
|
||||
);
|
||||
await flush();
|
||||
stdin.write('q');
|
||||
await flush();
|
||||
expect(onQuit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
58
tests/tui/Tree.test.tsx
Normal file
58
tests/tui/Tree.test.tsx
Normal file
|
|
@ -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('<Tree>', () => {
|
||||
it('renders devices and POU rows when expanded', () => {
|
||||
const { lastFrame } = render(
|
||||
<Tree
|
||||
project={project}
|
||||
cursorPath="device:CodesysRpi"
|
||||
expanded={new Set(['device:CodesysRpi'])}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<Tree project={project} cursorPath="device:CodesysRpi" expanded={new Set()} />
|
||||
);
|
||||
const out = lastFrame()!;
|
||||
expect(out).toContain('CodesysRpi');
|
||||
expect(out).not.toContain('PLC_PRG');
|
||||
});
|
||||
|
||||
it('marks the cursor row', () => {
|
||||
const { lastFrame } = render(
|
||||
<Tree
|
||||
project={project}
|
||||
cursorPath="pou:CodesysRpi:a/FB_Test.st"
|
||||
expanded={new Set(['device:CodesysRpi'])}
|
||||
/>
|
||||
);
|
||||
const out = lastFrame()!;
|
||||
expect(out).toMatch(/▶ FB_Test/);
|
||||
});
|
||||
});
|
||||
47
tests/tui/diff.test.ts
Normal file
47
tests/tui/diff.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
30
tests/tui/discover.test.ts
Normal file
30
tests/tui/discover.test.ts
Normal file
|
|
@ -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<string> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
(* fixture *)
|
||||
LINE_TWO := 0;
|
||||
42
tests/tui/index.integration.test.ts
Normal file
42
tests/tui/index.integration.test.ts
Normal file
|
|
@ -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:/);
|
||||
});
|
||||
});
|
||||
51
tests/tui/scan.test.ts
Normal file
51
tests/tui/scan.test.ts
Normal file
|
|
@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
51
tests/tui/state-paths.test.ts
Normal file
51
tests/tui/state-paths.test.ts
Normal file
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
58
tests/tui/state-write.test.ts
Normal file
58
tests/tui/state-write.test.ts
Normal file
|
|
@ -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<string> {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
49
tests/unit/approve-gate.test.ts
Normal file
49
tests/unit/approve-gate.test.ts
Normal file
|
|
@ -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;');
|
||||
});
|
||||
});
|
||||
54
tests/unit/get-user-selection.test.ts
Normal file
54
tests/unit/get-user-selection.test.ts
Normal file
|
|
@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
59
tests/unit/state-read.test.ts
Normal file
59
tests/unit/state-read.test.ts
Normal file
|
|
@ -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<string> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -14,5 +14,5 @@
|
|||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
"exclude": ["node_modules", "dist", "tests", "src/tui"]
|
||||
}
|
||||
|
|
|
|||
19
tsconfig.tui.json
Normal file
19
tsconfig.tui.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in a new issue