diff --git a/web/bun.lock b/web/bun.lock
index 66e4843d..444e981d 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -5,10 +5,12 @@
"name": "vibetunnel-web",
"dependencies": {
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
+ "@types/mime-types": "^3.0.1",
"@xterm/headless": "^5.5.0",
"chalk": "^4.1.2",
"express": "^4.19.2",
"lit": "^3.3.0",
+ "mime-types": "^3.0.1",
"signal-exit": "^4.1.0",
"ws": "^8.18.2",
},
@@ -382,6 +384,8 @@
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
+ "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
+
"@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
@@ -1100,9 +1104,9 @@
"mime": ["mime@2.6.0", "", { "bin": "cli.js" }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
- "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+ "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
- "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+ "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
@@ -1544,12 +1548,8 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
- "@eslint/config-array/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
-
"@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
- "@eslint/eslintrc/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
-
"@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw=="],
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
@@ -1576,7 +1576,7 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
- "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
+ "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
@@ -1594,16 +1594,12 @@
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
- "cliui/string-width": ["string-width@3.1.0", "", { "dependencies": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^5.1.0" } }, "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w=="],
-
"cliui/wrap-ansi": ["wrap-ansi@5.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "string-width": "^3.0.0", "strip-ansi": "^5.0.0" } }, "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q=="],
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
- "espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
-
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
@@ -1616,6 +1612,8 @@
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
+ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
"get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1624,8 +1622,6 @@
"istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
- "jake/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
-
"jest-circus/pretty-format": ["pretty-format@30.0.2", "", { "dependencies": { "@jest/schemas": "30.0.1", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg=="],
"jest-cli/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -1644,14 +1640,10 @@
"jest-snapshot/pretty-format": ["pretty-format@30.0.2", "", { "dependencies": { "@jest/schemas": "30.0.1", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg=="],
- "jest-util/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
-
"jest-validate/camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"jest-validate/pretty-format": ["pretty-format@30.0.2", "", { "dependencies": { "@jest/schemas": "30.0.1", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg=="],
- "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
-
"lint-staged/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
"log-update/ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
@@ -1660,8 +1652,6 @@
"log-update/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
- "log-update/wrap-ansi": ["wrap-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q=="],
-
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -1692,8 +1682,6 @@
"string-length/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "string-width/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="],
-
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@@ -1710,11 +1698,7 @@
"test-exclude/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
- "tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
-
- "vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
-
- "vite/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
+ "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
@@ -1730,10 +1714,6 @@
"yargs/yargs-parser": ["yargs-parser@13.1.2", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg=="],
- "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
-
- "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
-
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
@@ -1760,9 +1740,9 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
- "babel-plugin-istanbul/test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+ "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
- "babel-plugin-istanbul/test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
+ "babel-plugin-istanbul/test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -1774,10 +1754,6 @@
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
- "cliui/string-width/emoji-regex": ["emoji-regex@7.0.3", "", {}, "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA=="],
-
- "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
-
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"concurrently/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
@@ -1792,12 +1768,12 @@
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
- "jake/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
-
"jest-circus/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"jest-circus/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -1846,24 +1822,20 @@
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
- "log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
-
- "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
-
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
- "string-width/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
-
"tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"test-exclude/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+ "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@@ -1888,8 +1860,6 @@
"@puppeteer/browsers/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "babel-plugin-istanbul/test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
-
"chokidar-cli/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
@@ -1916,8 +1886,6 @@
"jest-cli/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
-
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
diff --git a/web/package-lock.json b/web/package-lock.json
index 00637af6..c4dc377d 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -10,10 +10,12 @@
"license": "MIT",
"dependencies": {
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
+ "@types/mime-types": "^3.0.1",
"@xterm/headless": "^5.5.0",
"chalk": "^4.1.2",
"express": "^4.19.2",
"lit": "^3.3.0",
+ "mime-types": "^3.0.1",
"signal-exit": "^4.1.0",
"ws": "^8.18.2"
},
@@ -2779,6 +2781,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==",
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "24.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz",
@@ -3615,6 +3623,27 @@
"node": ">= 0.6"
}
},
+ "node_modules/accepts/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -6356,6 +6385,29 @@
"node": ">= 6"
}
},
+ "node_modules/form-data/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/form-data/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -8846,21 +8898,21 @@
}
},
"node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
- "mime-db": "1.52.0"
+ "mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
@@ -11586,6 +11638,27 @@
"node": ">= 0.6"
}
},
+ "node_modules/type-is/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/typed-query-selector": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz",
diff --git a/web/package.json b/web/package.json
index 713c7679..9069aacc 100644
--- a/web/package.json
+++ b/web/package.json
@@ -34,10 +34,12 @@
},
"dependencies": {
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
+ "@types/mime-types": "^3.0.1",
"@xterm/headless": "^5.5.0",
"chalk": "^4.1.2",
"express": "^4.19.2",
"lit": "^3.3.0",
+ "mime-types": "^3.0.1",
"signal-exit": "^4.1.0",
"ws": "^8.18.2"
},
diff --git a/web/src/client/app.ts b/web/src/client/app.ts
index 38d689cd..377f1077 100644
--- a/web/src/client/app.ts
+++ b/web/src/client/app.ts
@@ -8,6 +8,7 @@ import './components/session-create-form.js';
import './components/session-list.js';
import './components/session-view.js';
import './components/session-card.js';
+import './components/file-browser-enhanced.js';
import type { Session } from './components/session-list.js';
import type { SessionCard } from './components/session-card.js';
@@ -27,6 +28,7 @@ export class VibeTunnelApp extends LitElement {
@state() private selectedSessionId: string | null = null;
@state() private hideExited = this.loadHideExitedState();
@state() private showCreateModal = false;
+ @state() private showFileBrowser = false;
private hotReloadWs: WebSocket | null = null;
private errorTimeoutId: number | null = null;
@@ -38,6 +40,7 @@ export class VibeTunnelApp extends LitElement {
this.loadSessions();
this.startAutoRefresh();
this.setupRouting();
+ this.setupKeyboardShortcuts();
}
disconnectedCallback() {
@@ -47,6 +50,20 @@ export class VibeTunnelApp extends LitElement {
}
// Clean up routing listeners
window.removeEventListener('popstate', this.handlePopState);
+ // Clean up keyboard shortcuts
+ window.removeEventListener('keydown', this.handleKeyDown);
+ }
+
+ private handleKeyDown = (e: KeyboardEvent) => {
+ // Handle Cmd+O / Ctrl+O to open file browser
+ if ((e.metaKey || e.ctrlKey) && e.key === 'o' && this.currentView === 'list') {
+ e.preventDefault();
+ this.showFileBrowser = true;
+ }
+ };
+
+ private setupKeyboardShortcuts() {
+ window.addEventListener('keydown', this.handleKeyDown);
}
private showError(message: string) {
@@ -452,6 +469,7 @@ export class VibeTunnelApp extends LitElement {
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
@clean-exited-sessions=${this.handleCleanExited}
+ @open-file-browser=${() => (this.showFileBrowser = true)}
>
`}
+
+
+ (this.showFileBrowser = false)}
+ >
`;
}
}
diff --git a/web/src/client/components/app-header.ts b/web/src/client/components/app-header.ts
index ca61893d..e9ab44b9 100644
--- a/web/src/client/components/app-header.ts
+++ b/web/src/client/components/app-header.ts
@@ -46,6 +46,10 @@ export class AppHeader extends LitElement {
this.dispatchEvent(new CustomEvent('clean-exited-sessions'));
}
+ private handleOpenFileBrowser() {
+ this.dispatchEvent(new CustomEvent('open-file-browser'));
+ }
+
render() {
const runningSessions = this.sessions.filter((session) => session.status === 'running');
const exitedSessions = this.sessions.filter((session) => session.status === 'exited');
@@ -116,6 +120,13 @@ export class AppHeader extends LitElement {
+
`
: ''}
+
+
+
+
+
+
`;
}
diff --git a/web/src/client/types/monaco.d.ts b/web/src/client/types/monaco.d.ts
new file mode 100644
index 00000000..7f7db407
--- /dev/null
+++ b/web/src/client/types/monaco.d.ts
@@ -0,0 +1,8 @@
+declare global {
+ interface Window {
+ monaco: any;
+ require: any;
+ }
+}
+
+export {};
diff --git a/web/src/index.ts b/web/src/index.ts
index a19a98c5..e4fd6ceb 100644
--- a/web/src/index.ts
+++ b/web/src/index.ts
@@ -2,6 +2,7 @@
// Entry point for the server - imports the modular server which starts automatically
import { startVibeTunnelForward } from './server/fwd.js';
import { startVibeTunnelServer } from './server/server.js';
+import { VERSION } from './server/version.js';
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
@@ -14,7 +15,10 @@ process.on('unhandledRejection', (reason, promise) => {
process.exit(1);
});
-if (process.argv[2] === 'fwd') {
+if (process.argv[2] === 'version') {
+ console.log(`VibeTunnel Linux v${VERSION}`);
+ process.exit(0);
+} else if (process.argv[2] === 'fwd') {
startVibeTunnelForward(process.argv.slice(3)).catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
diff --git a/web/src/server/app.ts b/web/src/server/app.ts
index 0a46bba5..39c45f38 100644
--- a/web/src/server/app.ts
+++ b/web/src/server/app.ts
@@ -13,6 +13,7 @@ import { HQClient } from './services/hq-client.js';
import { createAuthMiddleware } from './middleware/auth.js';
import { createSessionRoutes } from './routes/sessions.js';
import { createRemoteRoutes } from './routes/remotes.js';
+import { createFilesystemRoutes } from './routes/filesystem.js';
import { ControlDirWatcher } from './services/control-dir-watcher.js';
import { BufferAggregator } from './services/buffer-aggregator.js';
import { v4 as uuidv4 } from 'uuid';
@@ -354,6 +355,9 @@ export function createApp(): AppInstance {
})
);
+ // Mount filesystem routes
+ app.use('/api', createFilesystemRoutes());
+
// WebSocket endpoint for buffer updates
wss.on('connection', (ws, _req) => {
if (bufferAggregator) {
@@ -438,6 +442,7 @@ export function createApp(): AppInstance {
remoteRegistry,
isHQMode: config.isHQMode,
hqClient,
+ ptyManager,
});
controlDirWatcher.start();
});
diff --git a/web/src/server/routes/filesystem.ts b/web/src/server/routes/filesystem.ts
new file mode 100644
index 00000000..fbcd23e7
--- /dev/null
+++ b/web/src/server/routes/filesystem.ts
@@ -0,0 +1,462 @@
+import { Router, Request, Response } from 'express';
+import * as fs from 'fs/promises';
+import * as path from 'path';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import mime from 'mime-types';
+import { createReadStream, statSync } from 'fs';
+
+const execAsync = promisify(exec);
+
+interface FileInfo {
+ name: string;
+ path: string;
+ type: 'file' | 'directory';
+ size: number;
+ modified: string;
+ permissions?: string;
+ isGitTracked?: boolean;
+ gitStatus?: 'modified' | 'added' | 'deleted' | 'untracked' | 'unchanged';
+}
+
+interface GitStatus {
+ isGitRepo: boolean;
+ branch?: string;
+ modified: string[];
+ added: string[];
+ deleted: string[];
+ untracked: string[];
+}
+
+export function createFilesystemRoutes(): Router {
+ const router = Router();
+
+ // Helper to check if path is safe (no directory traversal)
+ function isPathSafe(requestedPath: string, basePath: string): boolean {
+ const resolved = path.resolve(basePath, requestedPath);
+ return resolved.startsWith(path.resolve(basePath));
+ }
+
+ // Helper to get Git status for a directory
+ async function getGitStatus(dirPath: string): Promise {
+ try {
+ // Check if directory is a git repository
+ await execAsync('git rev-parse --git-dir', { cwd: dirPath });
+
+ // Get current branch
+ const { stdout: branch } = await execAsync('git branch --show-current', { cwd: dirPath });
+
+ // Get status
+ const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: dirPath });
+
+ const status: GitStatus = {
+ isGitRepo: true,
+ branch: branch.trim(),
+ modified: [],
+ added: [],
+ deleted: [],
+ untracked: [],
+ };
+
+ // Parse git status output
+ statusOutput.split('\n').forEach((line) => {
+ if (!line) return;
+
+ const statusCode = line.substring(0, 2);
+ const filename = line.substring(3);
+
+ if (statusCode === ' M' || statusCode === 'M ' || statusCode === 'MM') {
+ status.modified.push(filename);
+ } else if (statusCode === 'A ' || statusCode === 'AM') {
+ status.added.push(filename);
+ } else if (statusCode === ' D' || statusCode === 'D ') {
+ status.deleted.push(filename);
+ } else if (statusCode === '??') {
+ status.untracked.push(filename);
+ }
+ });
+
+ return status;
+ } catch {
+ return null;
+ }
+ }
+
+ // Helper to get file Git status
+ function getFileGitStatus(filePath: string, gitStatus: GitStatus | null): FileInfo['gitStatus'] {
+ if (!gitStatus) return undefined;
+
+ const relativePath = path.relative(process.cwd(), filePath);
+
+ if (gitStatus.modified.includes(relativePath)) return 'modified';
+ if (gitStatus.added.includes(relativePath)) return 'added';
+ if (gitStatus.deleted.includes(relativePath)) return 'deleted';
+ if (gitStatus.untracked.includes(relativePath)) return 'untracked';
+
+ return 'unchanged';
+ }
+
+ // Browse directory endpoint
+ router.get('/fs/browse', async (req: Request, res: Response) => {
+ try {
+ const requestedPath = (req.query.path as string) || '.';
+ const showHidden = req.query.showHidden === 'true';
+ const gitFilter = req.query.gitFilter as string; // 'all' | 'changed' | 'none'
+
+ // Security check
+ if (!isPathSafe(requestedPath, process.cwd())) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const fullPath = path.resolve(process.cwd(), requestedPath);
+
+ // Check if path exists and is a directory
+ const stats = await fs.stat(fullPath);
+ if (!stats.isDirectory()) {
+ return res.status(400).json({ error: 'Path is not a directory' });
+ }
+
+ // Get Git status if requested
+ const gitStatus = gitFilter !== 'none' ? await getGitStatus(fullPath) : null;
+
+ // Read directory contents
+ const entries = await fs.readdir(fullPath, { withFileTypes: true });
+
+ // Build file list
+ const files: FileInfo[] = await Promise.all(
+ entries
+ .filter((entry) => showHidden || !entry.name.startsWith('.'))
+ .map(async (entry) => {
+ const entryPath = path.join(fullPath, entry.name);
+ const stats = await fs.stat(entryPath);
+ const relativePath = path.relative(process.cwd(), entryPath);
+
+ const fileInfo: FileInfo = {
+ name: entry.name,
+ path: relativePath,
+ type: entry.isDirectory() ? 'directory' : 'file',
+ size: stats.size,
+ modified: stats.mtime.toISOString(),
+ permissions: stats.mode.toString(8).slice(-3),
+ isGitTracked: gitStatus?.isGitRepo || false,
+ gitStatus: getFileGitStatus(entryPath, gitStatus),
+ };
+
+ return fileInfo;
+ })
+ );
+
+ // Filter by Git status if requested
+ let filteredFiles = files;
+ if (gitFilter === 'changed' && gitStatus) {
+ filteredFiles = files.filter((file) => file.gitStatus && file.gitStatus !== 'unchanged');
+ }
+
+ // Sort: directories first, then by name
+ filteredFiles.sort((a, b) => {
+ if (a.type !== b.type) {
+ return a.type === 'directory' ? -1 : 1;
+ }
+ return a.name.localeCompare(b.name);
+ });
+
+ res.json({
+ path: requestedPath,
+ fullPath,
+ gitStatus,
+ files: filteredFiles,
+ });
+ } catch (error: any) {
+ console.error('Browse error:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // Get file preview
+ router.get('/fs/preview', async (req: Request, res: Response) => {
+ try {
+ const requestedPath = req.query.path as string;
+ if (!requestedPath) {
+ return res.status(400).json({ error: 'Path is required' });
+ }
+
+ // Security check
+ if (!isPathSafe(requestedPath, process.cwd())) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const fullPath = path.resolve(process.cwd(), requestedPath);
+ const stats = await fs.stat(fullPath);
+
+ if (stats.isDirectory()) {
+ return res.status(400).json({ error: 'Cannot preview directories' });
+ }
+
+ // Determine file type
+ const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
+ const isText =
+ mimeType.startsWith('text/') ||
+ mimeType === 'application/json' ||
+ mimeType === 'application/javascript' ||
+ mimeType === 'application/typescript' ||
+ mimeType === 'application/xml';
+ const isImage = mimeType.startsWith('image/');
+
+ if (isImage) {
+ // For images, return URL to fetch the image
+ res.json({
+ type: 'image',
+ mimeType,
+ url: `/api/fs/raw?path=${encodeURIComponent(requestedPath)}`,
+ size: stats.size,
+ });
+ } else if (isText || stats.size < 1024 * 1024) {
+ // Text or small files (< 1MB)
+ const content = await fs.readFile(fullPath, 'utf-8');
+ const language = getLanguageFromPath(fullPath);
+
+ res.json({
+ type: 'text',
+ content,
+ language,
+ mimeType,
+ size: stats.size,
+ });
+ } else {
+ // Binary or large files
+ res.json({
+ type: 'binary',
+ mimeType,
+ size: stats.size,
+ humanSize: formatBytes(stats.size),
+ });
+ }
+ } catch (error: any) {
+ console.error('Preview error:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // Serve raw file content
+ router.get('/fs/raw', (req: Request, res: Response) => {
+ try {
+ const requestedPath = req.query.path as string;
+ if (!requestedPath) {
+ return res.status(400).json({ error: 'Path is required' });
+ }
+
+ // Security check
+ if (!isPathSafe(requestedPath, process.cwd())) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const fullPath = path.resolve(process.cwd(), requestedPath);
+
+ // Check if file exists
+ if (!statSync(fullPath).isFile()) {
+ return res.status(404).json({ error: 'File not found' });
+ }
+
+ // Set appropriate content type
+ const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
+ res.setHeader('Content-Type', mimeType);
+
+ // Stream the file
+ const stream = createReadStream(fullPath);
+ stream.pipe(res);
+ } catch (error: any) {
+ console.error('Raw file error:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // Get file content (text files only)
+ router.get('/fs/content', async (req: Request, res: Response) => {
+ try {
+ const requestedPath = req.query.path as string;
+ if (!requestedPath) {
+ return res.status(400).json({ error: 'Path is required' });
+ }
+
+ // Security check
+ if (!isPathSafe(requestedPath, process.cwd())) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const fullPath = path.resolve(process.cwd(), requestedPath);
+ const content = await fs.readFile(fullPath, 'utf-8');
+
+ res.json({
+ path: requestedPath,
+ content,
+ language: getLanguageFromPath(fullPath),
+ });
+ } catch (error: any) {
+ console.error('Content error:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // Get Git diff for a file
+ router.get('/fs/diff', async (req: Request, res: Response) => {
+ try {
+ const requestedPath = req.query.path as string;
+ if (!requestedPath) {
+ return res.status(400).json({ error: 'Path is required' });
+ }
+
+ // Security check
+ if (!isPathSafe(requestedPath, process.cwd())) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const fullPath = path.resolve(process.cwd(), requestedPath);
+ const relativePath = path.relative(process.cwd(), fullPath);
+
+ // Get git diff
+ const { stdout: diff } = await execAsync(`git diff HEAD -- "${relativePath}"`, {
+ cwd: process.cwd(),
+ });
+
+ res.json({
+ path: requestedPath,
+ diff,
+ hasDiff: diff.length > 0,
+ });
+ } catch (error: any) {
+ console.error('Diff error:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // Create directory
+ router.post('/fs/mkdir', async (req: Request, res: Response) => {
+ try {
+ const { path: dirPath, name } = req.body;
+
+ if (!dirPath || !name) {
+ return res.status(400).json({ error: 'Path and name are required' });
+ }
+
+ // Validate name (no slashes, no dots at start)
+ if (name.includes('/') || name.includes('\\') || name.startsWith('.')) {
+ return res.status(400).json({ error: 'Invalid directory name' });
+ }
+
+ // Security check
+ if (!isPathSafe(dirPath, process.cwd())) {
+ return res.status(403).json({ error: 'Access denied' });
+ }
+
+ const fullPath = path.resolve(process.cwd(), dirPath, name);
+
+ // Create directory
+ await fs.mkdir(fullPath, { recursive: true });
+
+ res.json({
+ success: true,
+ path: path.relative(process.cwd(), fullPath),
+ });
+ } catch (error: any) {
+ console.error('Mkdir error:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ return router;
+}
+
+// Helper function to determine language from file path
+function getLanguageFromPath(filePath: string): string {
+ const ext = path.extname(filePath).toLowerCase();
+ const languageMap: Record = {
+ '.js': 'javascript',
+ '.jsx': 'javascript',
+ '.ts': 'typescript',
+ '.tsx': 'typescript',
+ '.py': 'python',
+ '.java': 'java',
+ '.c': 'c',
+ '.cpp': 'cpp',
+ '.cs': 'csharp',
+ '.php': 'php',
+ '.rb': 'ruby',
+ '.go': 'go',
+ '.rs': 'rust',
+ '.swift': 'swift',
+ '.kt': 'kotlin',
+ '.scala': 'scala',
+ '.r': 'r',
+ '.m': 'objective-c',
+ '.mm': 'objective-c',
+ '.h': 'c',
+ '.hpp': 'cpp',
+ '.sh': 'shell',
+ '.bash': 'shell',
+ '.zsh': 'shell',
+ '.fish': 'shell',
+ '.ps1': 'powershell',
+ '.html': 'html',
+ '.htm': 'html',
+ '.xml': 'xml',
+ '.css': 'css',
+ '.scss': 'scss',
+ '.sass': 'sass',
+ '.less': 'less',
+ '.json': 'json',
+ '.yaml': 'yaml',
+ '.yml': 'yaml',
+ '.toml': 'toml',
+ '.ini': 'ini',
+ '.cfg': 'ini',
+ '.conf': 'ini',
+ '.sql': 'sql',
+ '.md': 'markdown',
+ '.markdown': 'markdown',
+ '.tex': 'latex',
+ '.dockerfile': 'dockerfile',
+ '.makefile': 'makefile',
+ '.cmake': 'cmake',
+ '.gradle': 'gradle',
+ '.vue': 'vue',
+ '.svelte': 'svelte',
+ '.elm': 'elm',
+ '.clj': 'clojure',
+ '.cljs': 'clojure',
+ '.ex': 'elixir',
+ '.exs': 'elixir',
+ '.erl': 'erlang',
+ '.hrl': 'erlang',
+ '.fs': 'fsharp',
+ '.fsx': 'fsharp',
+ '.fsi': 'fsharp',
+ '.ml': 'ocaml',
+ '.mli': 'ocaml',
+ '.pas': 'pascal',
+ '.pp': 'pascal',
+ '.pl': 'perl',
+ '.pm': 'perl',
+ '.t': 'perl',
+ '.lua': 'lua',
+ '.dart': 'dart',
+ '.nim': 'nim',
+ '.nims': 'nim',
+ '.zig': 'zig',
+ '.jl': 'julia',
+ };
+
+ return languageMap[ext] || 'plaintext';
+}
+
+// Helper function to format bytes
+function formatBytes(bytes: number, decimals = 2): string {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+}
diff --git a/web/src/server/routes/sessions.ts b/web/src/server/routes/sessions.ts
index 739a09bf..88eb0fb8 100644
--- a/web/src/server/routes/sessions.ts
+++ b/web/src/server/routes/sessions.ts
@@ -6,6 +6,7 @@ import { RemoteRegistry } from '../services/remote-registry.js';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
+import * as net from 'net';
interface SessionRoutesConfig {
ptyManager: PtyManager;
@@ -121,12 +122,47 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
// Create new session (local or on remote)
router.post('/sessions', async (req, res) => {
- const { command, workingDir, name, remoteId } = req.body;
+ const { command, workingDir, name, remoteId, spawn_terminal } = req.body;
if (!command || !Array.isArray(command) || command.length === 0) {
return res.status(400).json({ error: 'Command array is required' });
}
+ // If spawn_terminal is true, use the spawn-terminal logic
+ if (spawn_terminal) {
+ try {
+ // Generate session ID
+ const sessionId = generateSessionId();
+ const sessionName = name || `session_${Date.now()}`;
+
+ // Request Mac app to spawn terminal
+ const spawnResult = await requestTerminalSpawn({
+ sessionId,
+ sessionName,
+ command,
+ workingDir: resolvePath(workingDir, process.cwd()),
+ });
+
+ if (!spawnResult.success) {
+ throw new Error(spawnResult.error || 'Failed to spawn terminal');
+ }
+
+ // Wait a bit for the session to be created
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ // Return the session ID - client will poll for the session to appear
+ res.json({ sessionId, message: 'Terminal spawn requested' });
+ return;
+ } catch (error) {
+ console.error('Error spawning terminal:', error);
+ res.status(500).json({
+ error: 'Failed to spawn terminal',
+ details: error instanceof Error ? error.message : 'Unknown error',
+ });
+ return;
+ }
+ }
+
try {
// If remoteId is specified and we're in HQ mode, forward to remote
if (remoteId && isHQMode && remoteRegistry) {
@@ -773,3 +809,85 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
return router;
}
+
+// Generate a unique session ID
+function generateSessionId(): string {
+ // Generate UUID v4
+ const bytes = new Uint8Array(16);
+ for (let i = 0; i < 16; i++) {
+ bytes[i] = Math.floor(Math.random() * 256);
+ }
+
+ // Set version (4) and variant bits
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+
+ // Convert to hex string with dashes
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
+ return [
+ hex.slice(0, 8),
+ hex.slice(8, 12),
+ hex.slice(12, 16),
+ hex.slice(16, 20),
+ hex.slice(20, 32),
+ ].join('-');
+}
+
+// Request terminal spawn from Mac app
+async function requestTerminalSpawn(params: {
+ sessionId: string;
+ sessionName: string;
+ command: string[];
+ workingDir: string;
+}): Promise<{ success: boolean; error?: string }> {
+ const socketPath = '/tmp/vibetunnel-terminal.sock';
+
+ // Check if socket exists
+ if (!fs.existsSync(socketPath)) {
+ return {
+ success: false,
+ error: 'Terminal spawn service not available. Is the Mac app running?',
+ };
+ }
+
+ const spawnRequest = {
+ workingDir: params.workingDir,
+ sessionId: params.sessionId,
+ command: params.command.join(' '),
+ terminal: null, // Let Mac app use default terminal
+ };
+
+ return new Promise((resolve) => {
+ const client = net.createConnection(socketPath, () => {
+ console.log(`Connected to terminal spawn service for session ${params.sessionId}`);
+ client.write(JSON.stringify(spawnRequest));
+ });
+
+ client.on('data', (data) => {
+ try {
+ const response = JSON.parse(data.toString());
+ console.log(`Terminal spawn response:`, response);
+ resolve({ success: response.success, error: response.error });
+ } catch (error) {
+ console.error('Failed to parse terminal spawn response:', error);
+ resolve({ success: false, error: 'Invalid response from terminal spawn service' });
+ }
+ client.end();
+ });
+
+ client.on('error', (error) => {
+ console.error('Failed to connect to terminal spawn service:', error);
+ resolve({
+ success: false,
+ error: `Connection failed: ${error.message}`,
+ });
+ });
+
+ client.on('timeout', () => {
+ client.destroy();
+ resolve({ success: false, error: 'Terminal spawn request timed out' });
+ });
+
+ client.setTimeout(5000); // 5 second timeout
+ });
+}
diff --git a/web/src/server/server.ts b/web/src/server/server.ts
index 10754927..e8bf82f1 100644
--- a/web/src/server/server.ts
+++ b/web/src/server/server.ts
@@ -70,8 +70,7 @@ export function startVibeTunnelServer() {
// When running with tsx, the main module check is different
// NOTE: When bundled as 'vibetunnel' executable, index.ts handles the startup
const isMainModule =
- process.argv[1]?.endsWith('server.ts') ||
- process.argv[1]?.endsWith('server/index.ts');
+ process.argv[1]?.endsWith('server.ts') || process.argv[1]?.endsWith('server/index.ts');
if (isMainModule) {
startVibeTunnelServer();
}
diff --git a/web/src/server/services/control-dir-watcher.ts b/web/src/server/services/control-dir-watcher.ts
index 5e26995d..631cc08d 100644
--- a/web/src/server/services/control-dir-watcher.ts
+++ b/web/src/server/services/control-dir-watcher.ts
@@ -4,12 +4,14 @@ import chalk from 'chalk';
import { RemoteRegistry } from './remote-registry.js';
import { HQClient } from './hq-client.js';
import { isShuttingDown } from './shutdown-state.js';
+import { PtyManager } from '../pty/index.js';
interface ControlDirWatcherConfig {
controlDir: string;
remoteRegistry: RemoteRegistry | null;
isHQMode: boolean;
hqClient: HQClient | null;
+ ptyManager?: PtyManager;
}
export class ControlDirWatcher {
@@ -57,6 +59,17 @@ export class ControlDirWatcher {
console.log(chalk.blue(`Detected new external session: ${sessionId}`));
+ // Check if PtyManager already knows about this session
+ if (this.config.ptyManager) {
+ const existingSession = this.config.ptyManager.getSession(sessionId);
+ if (!existingSession) {
+ // This is a new external session, PtyManager needs to track it
+ console.log(chalk.green(`Attaching to external session: ${sessionId}`));
+ // PtyManager will pick it up through its own session listing
+ // since it reads from the control directory
+ }
+ }
+
// If we're a remote server registered with HQ, immediately notify HQ
if (this.config.hqClient && !isShuttingDown()) {
try {
diff --git a/web/src/server/version.ts b/web/src/server/version.ts
index 30246ea4..768d0a32 100644
--- a/web/src/server/version.ts
+++ b/web/src/server/version.ts
@@ -1,7 +1,7 @@
// Version information for VibeTunnel Server
// This file is updated during the build process
-export const VERSION = '1.0.0';
+export const VERSION = '1.0.0-beta.3';
// BUILD_DATE will be replaced by build script, fallback to current time in dev
export const BUILD_DATE = process.env.BUILD_DATE || new Date().toISOString();
export const BUILD_TIMESTAMP = process.env.BUILD_TIMESTAMP || Date.now();
@@ -31,4 +31,4 @@ export function printVersionBanner() {
console.log(`Built: ${BUILD_DATE}`);
console.log(`Platform: ${PLATFORM}/${ARCH} Node ${NODE_VERSION}`);
console.log(`PID: ${process.pid}`);
-}
\ No newline at end of file
+}