diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..603d6df --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Craft-style database variables +DB_DRIVER="mysql" +DB_SERVER="127.0.0.1" +DB_PORT="3306" +DB_DATABASE="craft" +DB_USER="root" +DB_PASSWORD="" + +# Or use a URL instead +# DATABASE_URL="mysql://root:@127.0.0.1:3306/craft" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..787b404 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +*.log +report.[0-9]*_[0-9]*_[0-9]*_[0-9]*.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store +.tug diff --git a/README.md b/README.md new file mode 100644 index 0000000..6efe655 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# tug + +`tug` is a Bun + TypeScript CLI/TUI for common Craft CMS deployment workflows: file sync, database sync, composer file sync, backups, diagnostics, and remote shell access. + +## Requirements + +- Bun 1.3+ +- Zig for OpenTUI development and builds +- macOS-first support in v1 +- Runtime tools: + - `ssh` + - `rsync` + - `gzip` + - `mysql` and `mysqldump` for MySQL/MariaDB projects + - `psql` and `pg_dump` for Postgres projects + +## Install + +```bash +bun install +``` + +## Run + +```bash +bun run src/cli/main.ts +``` + +## Commands + +- `tug` +- `tug init` +- `tug doctor` +- `tug db doctor` +- `tug folder pull` +- `tug folder push` +- `tug db pull` +- `tug db push` +- `tug composer pull` +- `tug composer push` +- `tug shell` +- `tug backups list` +- `tug backups open` + +## Development + +```bash +bun run lint +bun run typecheck +bun test +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..48b1e0b --- /dev/null +++ b/bun.lock @@ -0,0 +1,297 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "tug", + "dependencies": { + "@opentui/core": "^0.1.87", + "citty": "^0.2.1", + "dotenv": "^17.3.1", + "es-toolkit": "^1.45.1", + "smol-toml": "^1.6.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@types/bun": "^1.3.10", + "oxlint": "^1.55.0", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + + "@opentui/core": ["@opentui/core@0.1.87", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.55.0", "", { "os": "android", "cpu": "arm" }, "sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.55.0", "", { "os": "android", "cpu": "arm64" }, "sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.55.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.55.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.55.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.55.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.55.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.55.0", "", { "os": "none", "cpu": "arm64" }, "sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.55.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.55.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.55.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qM7W5IaFpWYGPDcNiQ8DOng3noQ97gxpH2MFH1mGsdKwI0T4oy++egSh5Z7s6AQx8WKgc9GzAsTUM4KZkFdacw=="], + + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-oVoIsme27pcXB68YxnQSAgdNGCa4A3PGWYIBUewOh9VnJaoik4JenGb5Yy+svGE+ETFhQXV9nhHqgMPsDRrO6A=="], + + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+SYt09k+xDEl/GfcU7L1zdNgm7IlvAFKV5Xl/auBwuprKG5UwXNhjRlRAWfhTMCUZWN+NDf8E+ZQx0cQi9K2/g=="], + + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="], + + "citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], + + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + + "oxlint": ["oxlint@1.55.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.55.0", "@oxlint/binding-android-arm64": "1.55.0", "@oxlint/binding-darwin-arm64": "1.55.0", "@oxlint/binding-darwin-x64": "1.55.0", "@oxlint/binding-freebsd-x64": "1.55.0", "@oxlint/binding-linux-arm-gnueabihf": "1.55.0", "@oxlint/binding-linux-arm-musleabihf": "1.55.0", "@oxlint/binding-linux-arm64-gnu": "1.55.0", "@oxlint/binding-linux-arm64-musl": "1.55.0", "@oxlint/binding-linux-ppc64-gnu": "1.55.0", "@oxlint/binding-linux-riscv64-gnu": "1.55.0", "@oxlint/binding-linux-riscv64-musl": "1.55.0", "@oxlint/binding-linux-s390x-gnu": "1.55.0", "@oxlint/binding-linux-x64-gnu": "1.55.0", "@oxlint/binding-linux-x64-musl": "1.55.0", "@oxlint/binding-openharmony-arm64": "1.55.0", "@oxlint/binding-win32-arm64-msvc": "1.55.0", "@oxlint/binding-win32-ia32-msvc": "1.55.0", "@oxlint/binding-win32-x64-msvc": "1.55.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], + + "planck": ["planck@1.4.3", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], + + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], + + "stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-color/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-contain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-cover/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-crop/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-displace/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-fisheye/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-resize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + } +} diff --git a/oxlint.json b/oxlint.json new file mode 100644 index 0000000..fbcc162 --- /dev/null +++ b/oxlint.json @@ -0,0 +1,8 @@ +{ + "ignorePatterns": ["node_modules", ".tug"], + "categories": { + "correctness": "error", + "suspicious": "error", + "style": "warn" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..57b0619 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "tug", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "Craft CMS deployment and sync workflows in a Bun/OpenTUI CLI.", + "bin": { + "tug": "./src/cli/main.ts" + }, + "scripts": { + "dev": "bun run src/cli/main.ts", + "typecheck": "tsc --noEmit", + "lint": "oxlint --deny-warnings .", + "test": "bun test", + "doctor": "bun run src/cli/main.ts doctor" + }, + "dependencies": { + "@opentui/core": "^0.1.87", + "citty": "^0.2.1", + "dotenv": "^17.3.1", + "es-toolkit": "^1.45.1", + "smol-toml": "^1.6.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/bun": "^1.3.10", + "oxlint": "^1.55.0", + "typescript": "^5.9.3" + } +} diff --git a/src/cli/commands/backups.ts b/src/cli/commands/backups.ts new file mode 100644 index 0000000..976882b --- /dev/null +++ b/src/cli/commands/backups.ts @@ -0,0 +1,30 @@ +import { defineCommand } from "citty"; +import * as backupsTask from "../../tasks/backups"; +import { printTaskResult, sharedArgs, withRuntime } from "../common"; + +function createActionCommand(action: "list" | "open") { + return defineCommand({ + meta: { + name: action, + description: action === "list" ? "List backups." : "Open backups in Finder.", + }, + args: sharedArgs, + async run(context) { + await withRuntime(context.args, async (runtime) => { + const result = await backupsTask.run(runtime, action); + printTaskResult(result); + }); + }, + }); +} + +export const backupsCommand = defineCommand({ + meta: { + name: "backups", + description: "Inspect tug backup snapshots.", + }, + subCommands: { + list: createActionCommand("list"), + open: createActionCommand("open"), + }, +}); diff --git a/src/cli/commands/composer.ts b/src/cli/commands/composer.ts new file mode 100644 index 0000000..4cabaf3 --- /dev/null +++ b/src/cli/commands/composer.ts @@ -0,0 +1,30 @@ +import { defineCommand } from "citty"; +import * as composerTask from "../../tasks/composer"; +import { printTaskResult, sharedArgs, withRuntime } from "../common"; + +function createActionCommand(action: "pull" | "push") { + return defineCommand({ + meta: { + name: action, + description: `${action === "pull" ? "Pull" : "Push"} composer files.`, + }, + args: sharedArgs, + async run(context) { + await withRuntime(context.args, async (runtime) => { + const result = await composerTask.run(runtime, action); + printTaskResult(result); + }); + }, + }); +} + +export const composerCommand = defineCommand({ + meta: { + name: "composer", + description: "Run composer sync tasks.", + }, + subCommands: { + pull: createActionCommand("pull"), + push: createActionCommand("push"), + }, +}); diff --git a/src/cli/commands/db.ts b/src/cli/commands/db.ts new file mode 100644 index 0000000..922c79f --- /dev/null +++ b/src/cli/commands/db.ts @@ -0,0 +1,50 @@ +import { defineCommand } from "citty"; +import * as databaseTask from "../../tasks/database"; +import { printTaskResult, sharedArgs, withRuntime } from "../common"; + +function createActionCommand(action: "pull" | "push") { + return defineCommand({ + meta: { + name: action, + description: `${action === "pull" ? "Pull" : "Push"} the configured database.`, + }, + args: sharedArgs, + async run(context) { + await withRuntime(context.args, async (runtime) => { + const result = await databaseTask.run(runtime, action); + printTaskResult(result); + }); + }, + }); +} + +export const dbCommand = defineCommand({ + meta: { + name: "db", + description: "Run database sync tasks.", + }, + subCommands: { + doctor: defineCommand({ + meta: { + name: "doctor", + description: "Check local and remote database prerequisites.", + }, + args: sharedArgs, + async run(context) { + await withRuntime(context.args, async (runtime) => { + const report = await databaseTask.doctor(runtime); + if (runtime.options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(databaseTask.formatDatabaseDoctorReport(report)); + } + if (!report.ok) { + process.exitCode = 1; + } + }); + }, + }), + pull: createActionCommand("pull"), + push: createActionCommand("push"), + }, +}); diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts new file mode 100644 index 0000000..b6887e7 --- /dev/null +++ b/src/cli/commands/doctor.ts @@ -0,0 +1,37 @@ +import { defineCommand } from "citty"; +import { inspectProject } from "../../core/doctor"; +import { BunProcessRunner } from "../../core/process"; +import { optionsFromArgs, sharedArgs } from "../common"; + +export const doctorCommand = defineCommand({ + meta: { + name: "doctor", + description: "Validate tug config, tools, and SSH reachability.", + }, + args: sharedArgs, + async run(context) { + const options = optionsFromArgs(context.args); + const runner = new BunProcessRunner(); + const report = await inspectProject( + options, + runner, + options.json + ? undefined + : (check) => { + const badge = + check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL"; + console.log(`[${badge}] ${check.label}: ${check.message}`); + }, + ); + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + if (!report.ok) { + process.exitCode = 1; + } + return; + } + if (!report.ok) { + process.exitCode = 1; + } + }, +}); diff --git a/src/cli/commands/folder.ts b/src/cli/commands/folder.ts new file mode 100644 index 0000000..8dd254f --- /dev/null +++ b/src/cli/commands/folder.ts @@ -0,0 +1,30 @@ +import { defineCommand } from "citty"; +import * as folderTask from "../../tasks/folder"; +import { printTaskResult, sharedArgs, withRuntime } from "../common"; + +function createActionCommand(action: "pull" | "push") { + return defineCommand({ + meta: { + name: action, + description: `${action === "pull" ? "Pull" : "Push"} configured folders.`, + }, + args: sharedArgs, + async run(context) { + await withRuntime(context.args, async (runtime) => { + const result = await folderTask.run(runtime, action); + printTaskResult(result); + }); + }, + }); +} + +export const folderCommand = defineCommand({ + meta: { + name: "folder", + description: "Run folder sync tasks.", + }, + subCommands: { + pull: createActionCommand("pull"), + push: createActionCommand("push"), + }, +}); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts new file mode 100644 index 0000000..5a9bddd --- /dev/null +++ b/src/cli/commands/init.ts @@ -0,0 +1,20 @@ +import { defineCommand } from "citty"; +import { writeInitialConfig } from "../../core/config"; +import { optionsFromArgs, sharedArgs } from "../common"; + +export const initCommand = defineCommand({ + meta: { + name: "init", + description: "Create a starter tug.toml file.", + }, + args: sharedArgs, + async run(context) { + const options = optionsFromArgs(context.args); + const filePath = await writeInitialConfig( + options.cwd, + options.configPath, + options.force, + ); + console.log(`Created ${filePath}`); + }, +}); diff --git a/src/cli/commands/shell.ts b/src/cli/commands/shell.ts new file mode 100644 index 0000000..795afe3 --- /dev/null +++ b/src/cli/commands/shell.ts @@ -0,0 +1,17 @@ +import { defineCommand } from "citty"; +import * as shellTask from "../../tasks/shell"; +import { printTaskResult, sharedArgs, withRuntime } from "../common"; + +export const shellCommand = defineCommand({ + meta: { + name: "shell", + description: "Open a remote shell session.", + }, + args: sharedArgs, + async run(context) { + await withRuntime(context.args, async (runtime) => { + const result = await shellTask.run(runtime); + printTaskResult(result); + }); + }, +}); diff --git a/src/cli/common.ts b/src/cli/common.ts new file mode 100644 index 0000000..987742c --- /dev/null +++ b/src/cli/common.ts @@ -0,0 +1,58 @@ +import { createLoadedRuntime } from "../core/runtime"; +import type { LoadedRuntime, RuntimeOptions, TaskResult } from "../types"; + +export const sharedArgs = { + config: { + type: "string" as const, + description: "Path to tug.toml", + }, + "env-file": { + type: "string" as const, + description: "Override local .env path", + }, + force: { + type: "boolean" as const, + description: "Skip confirmations for destructive tasks", + }, + "dry-run": { + type: "boolean" as const, + description: "Print file sync changes without mutating files", + }, + verbose: { + type: "boolean" as const, + description: "Show extra output", + }, + json: { + type: "boolean" as const, + description: "Print machine-readable output when supported", + }, +}; + +export function optionsFromArgs(args: Record): RuntimeOptions { + return { + cwd: process.cwd(), + configPath: typeof args.config === "string" ? args.config : undefined, + envFile: typeof args["env-file"] === "string" ? args["env-file"] : undefined, + force: Boolean(args.force), + dryRun: Boolean(args["dry-run"]), + verbose: Boolean(args.verbose), + json: Boolean(args.json), + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), + }; +} + +export async function withRuntime( + args: Record, + callback: (runtime: LoadedRuntime) => Promise, +): Promise { + const options = optionsFromArgs(args); + const runtime = await createLoadedRuntime(options); + return callback(runtime); +} + +export function printTaskResult(result: TaskResult): void { + console.log(result.summary); + if (result.artifacts && result.artifacts.length > 0) { + console.log(`Artifacts:\n${result.artifacts.join("\n")}`); + } +} diff --git a/src/cli/main.ts b/src/cli/main.ts new file mode 100755 index 0000000..2535278 --- /dev/null +++ b/src/cli/main.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun + +import { defineCommand, runMain } from "citty"; +import { backupsCommand } from "./commands/backups"; +import { composerCommand } from "./commands/composer"; +import { dbCommand } from "./commands/db"; +import { doctorCommand } from "./commands/doctor"; +import { folderCommand } from "./commands/folder"; +import { initCommand } from "./commands/init"; +import { shellCommand } from "./commands/shell"; +import { optionsFromArgs, sharedArgs } from "./common"; +import { startTui } from "../tui/app"; + +const main = defineCommand({ + meta: { + name: "tug", + version: "0.1.0", + description: "Craft CMS deployment and sync workflows in a TUI-first CLI.", + }, + args: sharedArgs, + subCommands: { + init: initCommand, + doctor: doctorCommand, + folder: folderCommand, + db: dbCommand, + composer: composerCommand, + shell: shellCommand, + backups: backupsCommand, + }, + async run(context) { + const knownSubcommands = new Set([ + "init", + "doctor", + "folder", + "db", + "composer", + "shell", + "backups", + ]); + const firstPositional = Array.isArray(context.args._) + ? context.args._.find((value: unknown) => typeof value === "string") + : undefined; + const hasSubcommand = + typeof firstPositional === "string" && knownSubcommands.has(firstPositional); + if (hasSubcommand) { + return; + } + const options = optionsFromArgs(context.args); + if (!options.interactive) { + throw new Error("The interactive dashboard requires a TTY. Use a subcommand instead."); + } + await startTui(options); + }, +}); + +runMain(main).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 0000000..aca5e16 --- /dev/null +++ b/src/core/config.ts @@ -0,0 +1,129 @@ +import { access, copyFile, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { parse, stringify } from "smol-toml"; +import { z } from "zod"; +import { resolveConfigPath } from "./paths"; +import type { RuntimeOptions, TugConfig } from "../types"; + +const configSchema = z.object({ + project: z + .object({ + name: z.string().optional(), + disabled_tasks: z.array(z.string()).default([]), + }) + .default({ disabled_tasks: [] }), + remote: z.object({ + host: z.string().min(1), + user: z.string().min(1), + port: z.number().int().positive().default(22), + app_path: z.string().min(1), + env_path: z.string().min(1), + }), + files: z.object({ + push: z.array(z.string()).default([]), + pull: z.array(z.string()).default([]), + exclude: z.array(z.string()).default([]), + }), + database: z.object({ + engine: z.enum(["mysql", "postgres"]), + local_env_file: z.string().min(1).default(".env"), + remote_env_path: z.string().min(1), + backup_dir: z.string().min(1).default(".tug/backups"), + }), + composer: z.object({ + enabled: z.boolean().default(true), + }), +}); + +const exampleConfig: TugConfig = { + project: { name: "my-craft-site", disabled_tasks: [] }, + remote: { + host: "example.com", + user: "deploy", + port: 22, + app_path: "/srv/users/deploy/apps/site/current", + env_path: "/srv/users/deploy/apps/site/current/.env", + }, + files: { + push: ["templates", "config", "web/dist"], + pull: ["web/uploads"], + exclude: [".DS_Store", ".git", ".env"], + }, + database: { + engine: "mysql", + local_env_file: ".env", + remote_env_path: "/srv/users/deploy/apps/site/current/.env", + backup_dir: ".tug/backups", + }, + composer: { enabled: true }, +}; + +export function renderExampleConfig(): string { + return stringify(exampleConfig); +} + +export async function configExists(cwd: string, explicitPath?: string): Promise { + try { + await access(resolveConfigPath(cwd, explicitPath)); + return true; + } catch { + return false; + } +} + +export async function readConfig(cwd: string, explicitPath?: string): Promise { + const configPath = resolveConfigPath(cwd, explicitPath); + const raw = await readFile(configPath, "utf8"); + const parsed = parse(raw); + return configSchema.parse(parsed); +} + +export async function writeInitialConfig( + cwd: string, + explicitPath?: string, + force = false, +): Promise { + const destination = resolveConfigPath(cwd, explicitPath); + if (!force) { + await access(destination) + .then(() => { + throw new Error(`Refusing to overwrite ${destination}. Re-run with --force to replace it.`); + }) + .catch((error: unknown) => { + if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + throw error; + } + }); + } + await writeFile(destination, renderExampleConfig(), "utf8"); + const exampleEnvSource = path.resolve(cwd, ".env.example"); + const exampleEnvTarget = path.resolve(cwd, ".env"); + try { + await access(exampleEnvSource); + if (!force) { + await access(exampleEnvTarget) + .then(() => undefined) + .catch((error: unknown) => { + if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + throw error; + } + }); + try { + await access(exampleEnvTarget); + return destination; + } catch { + // .env does not exist yet, continue. + } + } + await copyFile(exampleEnvSource, exampleEnvTarget); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + throw error; + } + } + return destination; +} + +export async function loadRuntimeConfig(options: RuntimeOptions): Promise { + return readConfig(options.cwd, options.configPath); +} diff --git a/src/core/doctor.ts b/src/core/doctor.ts new file mode 100644 index 0000000..5cb928c --- /dev/null +++ b/src/core/doctor.ts @@ -0,0 +1,184 @@ +import { configExists, readConfig } from "./config"; +import { commandExists } from "./process"; +import type { + DoctorCheck, + DoctorReport, + ProcessRunner, + RuntimeOptions, + TugConfig, +} from "../types"; + +function resultFromChecks(checks: DoctorCheck[]): DoctorReport { + return { + ok: checks.every((check) => check.status !== "fail"), + checks, + }; +} + +function databaseTools(engine: "mysql" | "postgres"): string[] { + return engine === "mysql" ? ["mysql", "mysqldump"] : ["psql", "pg_dump"]; +} + +function requiredTools(config: TugConfig | null): string[] { + const tools = ["ssh", "rsync", "gzip"]; + if (config) { + tools.push(...databaseTools(config.database.engine)); + } + return tools; +} + +// SSH can connect successfully yet stall on the remote command; cap the probe and +// treat timeout the same as "tool not available" so doctor stays responsive. +async function remoteToolExists( + runner: ProcessRunner, + config: TugConfig, + tool: string, +): Promise { + try { + const result = await Promise.race([ + runner.run({ + command: "ssh", + args: [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=2", + "-o", + "NumberOfPasswordPrompts=0", + "-o", + "PreferredAuthentications=publickey", + "-p", + String(config.remote.port), + `${config.remote.user}@${config.remote.host}`, + `command -v ${tool} >/dev/null 2>&1`, + ], + stdout: "pipe", + stderr: "pipe", + allowFailure: true, + }), + new Promise<{ exitCode: number; stdout: string; stderr: string }>((resolve) => { + setTimeout(() => { + resolve({ exitCode: 124, stdout: "", stderr: "Timed out checking remote tool." }); + }, 3000); + }), + ]); + return result.exitCode === 0; + } catch { + return false; + } +} + +export async function inspectProject( + options: RuntimeOptions, + runner: ProcessRunner, + onCheck?: (check: DoctorCheck) => void, +): Promise { + const checks: DoctorCheck[] = []; + const addCheck = (check: DoctorCheck): void => { + checks.push(check); + onCheck?.(check); + }; + const hasConfig = await configExists(options.cwd, options.configPath); + if (!hasConfig) { + addCheck({ + id: "config", + label: "Config file", + status: "fail", + message: "Missing tug.toml. Run `tug init` first.", + }); + return resultFromChecks(checks); + } + + let config: TugConfig; + try { + config = await readConfig(options.cwd, options.configPath); + addCheck({ + id: "config", + label: "Config file", + status: "pass", + message: "Loaded tug.toml successfully.", + }); + } catch (error) { + addCheck({ + id: "config-parse", + label: "Config validation", + status: "fail", + message: error instanceof Error ? error.message : "Failed to parse tug.toml.", + }); + return resultFromChecks(checks); + } + + for (const tool of requiredTools(config)) { + const exists = await commandExists(runner, tool); + addCheck({ + id: `tool-${tool}`, + label: `Tool: ${tool}`, + status: exists ? "pass" : "fail", + message: exists ? `${tool} is available.` : `${tool} is required but missing.`, + }); + } + + try { + await runner.run({ + command: "ssh", + args: [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=2", + "-o", + "NumberOfPasswordPrompts=0", + "-o", + "PreferredAuthentications=publickey", + "-p", + String(config.remote.port), + `${config.remote.user}@${config.remote.host}`, + "true", + ], + stdout: "pipe", + stderr: "pipe", + display: `ssh ${config.remote.user}@${config.remote.host} true`, + }); + addCheck({ + id: "ssh-reachability", + label: "SSH reachability", + status: "pass", + message: `SSH connectivity to ${config.remote.host} succeeded.`, + }); + + const remoteTools = databaseTools(config.database.engine); + for (const tool of remoteTools) { + const exists = await remoteToolExists(runner, config, tool); + addCheck({ + id: `remote-tool-${tool}`, + label: `Remote tool: ${tool}`, + status: exists ? "pass" : "warn", + message: exists + ? `${tool} is available on ${config.remote.host}.` + : `${tool} is missing on ${config.remote.host} for SSH user ${config.remote.user}.`, + }); + } + } catch (error) { + addCheck({ + id: "ssh-reachability", + label: "SSH reachability", + status: "fail", + message: + error instanceof Error + ? error.message + : `Unable to verify SSH connectivity to ${config.remote.host}.`, + }); + } + + return resultFromChecks(checks); +} + +export function formatDoctorReport(report: DoctorReport): string { + return report.checks + .map((check) => { + const badge = + check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL"; + return `[${badge}] ${check.label}: ${check.message}`; + }) + .join("\n"); +} diff --git a/src/core/env.ts b/src/core/env.ts new file mode 100644 index 0000000..3b81110 --- /dev/null +++ b/src/core/env.ts @@ -0,0 +1,151 @@ +import path from "node:path"; +import dotenv from "dotenv"; +import { shellQuote } from "./shell"; +import type { + DatabaseCredentials, + DatabaseEngine, + ProcessRunner, + RuntimeOptions, + TugConfig, +} from "../types"; + +function normalizeEngine(engine: string | undefined): DatabaseEngine | null { + if (!engine) { + return null; + } + + const value = engine.toLowerCase(); + if (value === "mysql" || value === "mariadb") { + return "mysql"; + } + if (value === "postgres" || value === "postgresql" || value === "pgsql") { + return "postgres"; + } + return null; +} + +function parseDatabaseUrl(urlString: string): DatabaseCredentials { + let url: URL; + try { + url = new URL(urlString); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid DATABASE_URL: ${message}`); + } + const engine = normalizeEngine(url.protocol.replace(":", "")); + if (!engine) { + throw new Error(`Unsupported DATABASE_URL protocol: ${url.protocol}`); + } + + return { + engine, + host: url.hostname, + port: url.port ? Number(url.port) : engine === "mysql" ? 3306 : 5432, + database: url.pathname.replace(/^\//, ""), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + url: urlString, + }; +} + +function parseCraftStyleEnv( + source: Record, + fallbackEngine: DatabaseEngine, +): DatabaseCredentials { + const envEngine = normalizeEngine(source.DB_DRIVER) ?? fallbackEngine; + const port = Number(source.DB_PORT ?? (envEngine === "mysql" ? 3306 : 5432)); + const database = source.DB_DATABASE; + const user = source.DB_USER; + const password = source.DB_PASSWORD ?? ""; + const host = source.DB_SERVER ?? "127.0.0.1"; + + if (!database || !user) { + throw new Error("Missing DB_DATABASE or DB_USER in environment"); + } + + return { + engine: envEngine, + host, + port, + database, + user, + password, + }; +} + +export async function readLocalEnv( + cwd: string, + config: TugConfig, + options: RuntimeOptions, +): Promise> { + const envPath = path.resolve(cwd, options.envFile ?? config.database.local_env_file); + try { + const content = await Bun.file(envPath).text(); + return dotenv.parse(content); + } catch { + return {}; + } +} + +export async function resolveLocalDatabaseCredentials( + cwd: string, + config: TugConfig, + options: RuntimeOptions, +): Promise { + const localEnv = await readLocalEnv(cwd, config, options); + const urlValue = process.env.DATABASE_URL ?? localEnv.DATABASE_URL; + if (urlValue) { + return parseDatabaseUrl(urlValue); + } + + return parseCraftStyleEnv( + { + DB_DRIVER: process.env.DB_DRIVER ?? localEnv.DB_DRIVER, + DB_SERVER: process.env.DB_SERVER ?? localEnv.DB_SERVER, + DB_PORT: process.env.DB_PORT ?? localEnv.DB_PORT, + DB_DATABASE: process.env.DB_DATABASE ?? localEnv.DB_DATABASE, + DB_USER: process.env.DB_USER ?? localEnv.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD ?? localEnv.DB_PASSWORD, + }, + config.database.engine, + ); +} + +export async function fetchRemoteEnv( + config: TugConfig, + runner: ProcessRunner, +): Promise> { + const remoteEnvPath = config.database.remote_env_path || config.remote.env_path; + const result = await runner.run({ + command: "ssh", + args: [ + "-p", + String(config.remote.port), + `${config.remote.user}@${config.remote.host}`, + `cat ${shellQuote(remoteEnvPath)}`, + ], + stdout: "pipe", + stderr: "pipe", + allowFailure: true, + display: `ssh ${config.remote.user}@${config.remote.host} cat ${remoteEnvPath}`, + }); + if (result.exitCode !== 0) { + throw new Error( + `Failed to read remote env file at ${remoteEnvPath}: ${result.stderr || result.stdout || `exit ${result.exitCode}`}`, + ); + } + return dotenv.parse(result.stdout); +} + +export async function resolveRemoteDatabaseCredentials( + config: TugConfig, + runner: ProcessRunner, +): Promise { + const remoteEnv = await fetchRemoteEnv(config, runner); + const urlValue = remoteEnv.DATABASE_URL; + if (urlValue) { + return parseDatabaseUrl(urlValue); + } + + return parseCraftStyleEnv(remoteEnv, config.database.engine); +} diff --git a/src/core/logging.ts b/src/core/logging.ts new file mode 100644 index 0000000..d36868d --- /dev/null +++ b/src/core/logging.ts @@ -0,0 +1,32 @@ +import type { Logger } from "../types"; + +export class MemoryLogger implements Logger { + #lines: string[] = []; + #listeners = new Set<(lines: string[]) => void>(); + + add(line: string): void { + this.#lines = [...this.#lines, line]; + for (const listener of this.#listeners) { + listener(this.snapshot()); + } + } + + clear(): void { + this.#lines = []; + for (const listener of this.#listeners) { + listener([]); + } + } + + snapshot(): string[] { + return [...this.#lines]; + } + + subscribe(listener: (lines: string[]) => void): () => void { + this.#listeners.add(listener); + listener(this.snapshot()); + return () => { + this.#listeners.delete(listener); + }; + } +} diff --git a/src/core/paths.ts b/src/core/paths.ts new file mode 100644 index 0000000..c168849 --- /dev/null +++ b/src/core/paths.ts @@ -0,0 +1,64 @@ +import path from "node:path"; +import { mkdir, readdir } from "node:fs/promises"; +import type { TugConfig } from "../types"; + +export const APP_DIRNAME = ".tug"; +export const TEMP_DIRNAME = "tmp"; +export const DEFAULT_CONFIG_FILE = "tug.toml"; + +export interface RuntimePaths { + rootDir: string; + tempDir: string; + backupRoot: string; +} + +export function resolveConfigPath(cwd: string, provided?: string): string { + return path.resolve(cwd, provided ?? DEFAULT_CONFIG_FILE); +} + +export function resolveRuntimePaths(cwd: string, config: TugConfig): RuntimePaths { + const rootDir = path.resolve(cwd, APP_DIRNAME); + const tempDir = path.resolve(rootDir, TEMP_DIRNAME); + const backupRoot = path.resolve(cwd, config.database.backup_dir); + return { rootDir, tempDir, backupRoot }; +} + +export async function ensureRuntimePaths(paths: RuntimePaths): Promise { + await mkdir(paths.rootDir, { recursive: true }); + await mkdir(paths.tempDir, { recursive: true }); + await mkdir(paths.backupRoot, { recursive: true }); +} + +export function timestampToken(now = new Date()): string { + const year = String(now.getFullYear()); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const seconds = String(now.getSeconds()).padStart(2, "0"); + return `${year}${month}${day}-${hours}${minutes}${seconds}`; +} + +export async function createBackupSession(cwd: string, config: TugConfig, token = timestampToken()): Promise<{ + token: string; + backupDir: string; + tempDir: string; +}> { + const paths = resolveRuntimePaths(cwd, config); + await ensureRuntimePaths(paths); + const backupDir = path.resolve(paths.backupRoot, token); + const tempDir = path.resolve(paths.tempDir, token); + await mkdir(backupDir, { recursive: true }); + await mkdir(tempDir, { recursive: true }); + return { token, backupDir, tempDir }; +} + +export async function listBackups(cwd: string, config: TugConfig): Promise { + const paths = resolveRuntimePaths(cwd, config); + await ensureRuntimePaths(paths); + const entries = await readdir(paths.backupRoot, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((left, right) => right.localeCompare(left)); +} diff --git a/src/core/process.ts b/src/core/process.ts new file mode 100644 index 0000000..4527c9a --- /dev/null +++ b/src/core/process.ts @@ -0,0 +1,91 @@ +import { joinCommand } from "./shell"; +import type { + CommandHooks, + CommandResult, + CommandSpec, + ProcessRunner, +} from "../types"; + +async function readStream( + stream: ReadableStream | null, + onText?: (text: string) => void, +): Promise { + if (!stream) { + return ""; + } + + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + while (true) { + const chunk = await reader.read(); + if (chunk.done) { + text += decoder.decode(); + break; + } + + const next = decoder.decode(chunk.value, { stream: true }); + text += next; + onText?.(next); + } + + return text; +} + +export class BunProcessRunner implements ProcessRunner { + async run(spec: CommandSpec, hooks: CommandHooks = {}): Promise { + const selectedShell = process.env.SHELL ?? "/bin/sh"; + const command = spec.shell + ? [selectedShell, "-lc", spec.command] + : [spec.command, ...(spec.args ?? [])]; + const stdinMode = + typeof spec.stdin === "string" && spec.stdin !== "inherit" && spec.stdin !== "pipe" + ? "pipe" + : (spec.stdin ?? "pipe"); + const stdoutMode = spec.stdout ?? "pipe"; + const stderrMode = spec.stderr ?? "pipe"; + + const child = Bun.spawn({ + cmd: command, + cwd: spec.cwd, + env: spec.env ? { ...process.env, ...spec.env } : process.env, + stdin: stdinMode, + stdout: stdoutMode, + stderr: stderrMode, + }); + + if (typeof spec.stdin === "string" && stdinMode === "pipe" && child.stdin) { + child.stdin.write(spec.stdin); + child.stdin.end(); + } + + const [stdout, stderr, exitCode] = await Promise.all([ + readStream(stdoutMode === "pipe" ? (child.stdout ?? null) : null, hooks.onStdout), + readStream(stderrMode === "pipe" ? (child.stderr ?? null) : null, hooks.onStderr), + child.exited, + ]); + + const result = { exitCode, stdout, stderr }; + if (exitCode !== 0 && !spec.allowFailure) { + const rendered = spec.display ?? joinCommand(command); + throw new Error(`${rendered} exited with status ${exitCode}\n${stderr || stdout}`.trim()); + } + + return result; + } +} + +export async function commandExists(runner: ProcessRunner, tool: string): Promise { + try { + const result = await runner.run({ + command: "/usr/bin/env", + args: ["which", tool], + stdout: "pipe", + stderr: "pipe", + }); + return result.exitCode === 0; + } catch { + return false; + } +} diff --git a/src/core/runtime.ts b/src/core/runtime.ts new file mode 100644 index 0000000..8951916 --- /dev/null +++ b/src/core/runtime.ts @@ -0,0 +1,14 @@ +import { loadRuntimeConfig } from "./config"; +import { MemoryLogger } from "./logging"; +import { BunProcessRunner } from "./process"; +import type { LoadedRuntime, RuntimeOptions } from "../types"; + +export async function createLoadedRuntime(options: RuntimeOptions): Promise { + const config = await loadRuntimeConfig(options); + return { + options, + config, + runner: new BunProcessRunner(), + logger: new MemoryLogger(), + }; +} diff --git a/src/core/shell.ts b/src/core/shell.ts new file mode 100644 index 0000000..729dd98 --- /dev/null +++ b/src/core/shell.ts @@ -0,0 +1,7 @@ +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export function joinCommand(parts: string[]): string { + return parts.map(shellQuote).join(" "); +} diff --git a/src/tasks/backups.ts b/src/tasks/backups.ts new file mode 100644 index 0000000..84126f4 --- /dev/null +++ b/src/tasks/backups.ts @@ -0,0 +1,67 @@ +import type { + BackupAction, + CommandSpec, + LoadedRuntime, + TaskPlan, + TaskResult, +} from "../types"; +import { listBackups, resolveRuntimePaths } from "../core/paths"; +import { ok } from "./shared"; + +function getPlatformOpener(target: string): CommandSpec { + if (process.platform === "darwin") { + return { + command: "open", + args: [target], + stdout: "pipe", + stderr: "pipe", + display: `open ${target}`, + }; + } + if (process.platform === "win32") { + return { + command: "cmd", + args: ["/c", "start", "", target], + stdout: "pipe", + stderr: "pipe", + display: `start ${target}`, + }; + } + return { + command: "xdg-open", + args: [target], + stdout: "pipe", + stderr: "pipe", + display: `xdg-open ${target}`, + }; +} + +export async function plan(runtime: LoadedRuntime, action: BackupAction): Promise { + const runtimePaths = resolveRuntimePaths(runtime.options.cwd, runtime.config); + return { + id: action === "list" ? "backups" : "backupsOpen", + title: action === "list" ? "List backups" : "Open backups", + summary: + action === "list" ? "Show available backup snapshots." : "Open the backup directory.", + target: runtimePaths.backupRoot, + destructive: false, + commands: + action === "open" + ? [getPlatformOpener(runtimePaths.backupRoot)] + : [], + }; +} + +export async function confirmMessage(): Promise { + return "Open the backups folder?"; +} + +export async function run(runtime: LoadedRuntime, action: BackupAction): Promise { + if (action === "list") { + const backups = await listBackups(runtime.options.cwd, runtime.config); + return ok(backups.length > 0 ? backups.join("\n") : "No backups found."); + } + const taskPlan = await plan(runtime, action); + await runtime.runner.run(taskPlan.commands[0]!); + return ok("Opened backups folder."); +} diff --git a/src/tasks/composer.ts b/src/tasks/composer.ts new file mode 100644 index 0000000..db92f25 --- /dev/null +++ b/src/tasks/composer.ts @@ -0,0 +1,123 @@ +import path from "node:path"; +import { copyFile } from "node:fs/promises"; +import type { + CommandSpec, + ComposerAction, + LoadedRuntime, + TaskPlan, + TaskResult, +} from "../types"; +import { createBackupSession } from "../core/paths"; +import { shellQuote } from "../core/shell"; +import { ensureComposerEnabled, ensureForceForDestructive, executePlan, fileExists, ok } from "./shared"; + +const composerFiles = ["composer.json", "composer.lock"] as const; + +async function backupLocalComposerFiles(runtime: LoadedRuntime, backupDir: string): Promise { + for (const file of composerFiles) { + const from = path.resolve(runtime.options.cwd, file); + if (await fileExists(from)) { + await copyFile(from, path.resolve(backupDir, `local-${file}`)); + } + } +} + +function buildRemotePullCommands(runtime: LoadedRuntime): CommandSpec[] { + return composerFiles.map((file, index) => ({ + command: "rsync", + args: [ + "--archive", + `--rsh=ssh -p ${runtime.config.remote.port}`, + `${runtime.config.remote.user}@${runtime.config.remote.host}:${shellQuote(path.posix.join(runtime.config.remote.app_path, file))}`, + path.resolve(runtime.options.cwd, file), + ], + stdout: "pipe", + stderr: "pipe", + display: `rsync pull ${file}`, + allowFailure: index === 1, + })); +} + +function buildRemoteBackupCommands(runtime: LoadedRuntime, backupDir: string): CommandSpec[] { + return composerFiles.map((file) => ({ + command: "rsync", + args: [ + "--archive", + `--rsh=ssh -p ${runtime.config.remote.port}`, + `${runtime.config.remote.user}@${runtime.config.remote.host}:${shellQuote(path.posix.join(runtime.config.remote.app_path, file))}`, + path.resolve(backupDir, `remote-${file}`), + ], + stdout: "pipe", + stderr: "pipe", + display: `backup remote ${file}`, + allowFailure: file === "composer.lock", + })); +} + +function buildRemotePushCommands(runtime: LoadedRuntime): CommandSpec[] { + return composerFiles.map((file) => ({ + command: "rsync", + args: [ + "--archive", + `--rsh=ssh -p ${runtime.config.remote.port}`, + path.resolve(runtime.options.cwd, file), + `${runtime.config.remote.user}@${runtime.config.remote.host}:${shellQuote(path.posix.join(runtime.config.remote.app_path, file))}`, + ], + stdout: "pipe", + stderr: "pipe", + display: `rsync push ${file}`, + allowFailure: file === "composer.lock", + })); +} + +export async function plan( + runtime: LoadedRuntime, + action: ComposerAction, + backupDir?: string, +): Promise { + ensureComposerEnabled(runtime.config); + const commands = + action === "pull" + ? buildRemotePullCommands(runtime) + : [ + ...(backupDir ? buildRemoteBackupCommands(runtime, backupDir) : []), + ...buildRemotePushCommands(runtime), + ]; + + return { + id: action === "pull" ? "composerPull" : "composerPush", + title: action === "pull" ? "Composer pull" : "Composer push", + summary: + action === "pull" + ? "Replace local composer files from the remote app." + : "Upload local composer files to the remote app.", + target: runtime.config.remote.host, + destructive: action === "push" || action === "pull", + commands, + }; +} + +export async function confirmMessage(runtime: LoadedRuntime, action: ComposerAction): Promise { + return action === "pull" + ? `Overwrite local composer files from ${runtime.config.remote.host}?` + : `Overwrite remote composer files on ${runtime.config.remote.host}?`; +} + +export async function run(runtime: LoadedRuntime, action: ComposerAction): Promise { + if (!runtime.options.force) { + throw new Error("Composer sync is destructive. Re-run with --force or use the interactive TUI."); + } + const session = await createBackupSession(runtime.options.cwd, runtime.config); + const taskPlan = await plan(runtime, action, session.backupDir); + ensureForceForDestructive(runtime, taskPlan); + if (action === "pull") { + await backupLocalComposerFiles(runtime, session.backupDir); + } else { + const localComposer = path.resolve(runtime.options.cwd, "composer.json"); + if (!(await fileExists(localComposer))) { + throw new Error("composer.json is required for composer push."); + } + } + await executePlan(runtime, taskPlan); + return ok(`${taskPlan.title} finished successfully.`, [session.backupDir]); +} diff --git a/src/tasks/database.ts b/src/tasks/database.ts new file mode 100644 index 0000000..501102b --- /dev/null +++ b/src/tasks/database.ts @@ -0,0 +1,459 @@ +import path from "node:path"; +import type { + CommandSpec, + DatabaseAction, + DatabaseCredentials, + DatabaseEngine, + DoctorCheck, + LoadedRuntime, + TaskPlan, + TaskResult, +} from "../types"; +import { resolveLocalDatabaseCredentials, resolveRemoteDatabaseCredentials } from "../core/env"; +import { createBackupSession } from "../core/paths"; +import { commandExists } from "../core/process"; +import { shellQuote } from "../core/shell"; +import { ensureForceForDestructive, ensureTaskEnabled, executePlan, ok } from "./shared"; + +const dropMysqlTablesSql = + "SET FOREIGN_KEY_CHECKS = 0;SET GROUP_CONCAT_MAX_LEN=32768;SET @tables = NULL;SELECT GROUP_CONCAT(CONCAT('`', table_name, '`')) INTO @tables FROM information_schema.tables WHERE table_schema = DATABASE();SET @tables = IFNULL(@tables, '');SET @stmt = IF(@tables = '', 'SELECT 1', CONCAT('DROP TABLE IF EXISTS ', @tables));PREPARE stmt FROM @stmt;EXECUTE stmt;DEALLOCATE PREPARE stmt;SET FOREIGN_KEY_CHECKS = 1;"; + +function mysqlEnv(password: string): Record { + return { MYSQL_PWD: password }; +} + +function postgresEnv(password: string): Record { + return { PGPASSWORD: password }; +} + +function mysqlDumpSpec(credentials: DatabaseCredentials, outputPath: string, label: string): CommandSpec { + return { + command: + `mysqldump --host=${shellQuote(credentials.host)} --port=${credentials.port} --user=${shellQuote(credentials.user)} --lock-tables=false --no-tablespaces --column-statistics=0 ${shellQuote(credentials.database)} | gzip > ${shellQuote(outputPath)}`, + shell: true, + env: mysqlEnv(credentials.password), + stdout: "pipe", + stderr: "pipe", + display: label, + }; +} + +function mysqlImportSpec(credentials: DatabaseCredentials, inputPath: string, label: string): CommandSpec { + return { + command: + `gzip -dc ${shellQuote(inputPath)} | mysql --host=${shellQuote(credentials.host)} --port=${credentials.port} --user=${shellQuote(credentials.user)} ${shellQuote(credentials.database)}`, + shell: true, + env: mysqlEnv(credentials.password), + stdout: "pipe", + stderr: "pipe", + display: label, + }; +} + +function mysqlClearSpec(credentials: DatabaseCredentials, label: string): CommandSpec { + return { + command: "mysql", + args: [ + `--host=${credentials.host}`, + `--port=${credentials.port}`, + `--user=${credentials.user}`, + credentials.database, + "-e", + dropMysqlTablesSql, + ], + env: mysqlEnv(credentials.password), + stdout: "pipe", + stderr: "pipe", + display: label, + }; +} + +function postgresDumpSpec(credentials: DatabaseCredentials, outputPath: string, label: string): CommandSpec { + return { + command: + `pg_dump --clean --if-exists --no-owner --no-privileges --host=${shellQuote(credentials.host)} --port=${credentials.port} --username=${shellQuote(credentials.user)} ${shellQuote(credentials.database)} | gzip > ${shellQuote(outputPath)}`, + shell: true, + env: postgresEnv(credentials.password), + stdout: "pipe", + stderr: "pipe", + display: label, + }; +} + +function postgresImportSpec(credentials: DatabaseCredentials, inputPath: string, label: string): CommandSpec { + return { + command: + `gzip -dc ${shellQuote(inputPath)} | psql --host=${shellQuote(credentials.host)} --port=${credentials.port} --username=${shellQuote(credentials.user)} --dbname=${shellQuote(credentials.database)} --set ON_ERROR_STOP=on`, + shell: true, + env: postgresEnv(credentials.password), + stdout: "pipe", + stderr: "pipe", + display: label, + }; +} + +function postgresClearSpec(credentials: DatabaseCredentials, label: string): CommandSpec { + return { + command: "psql", + args: [ + `--host=${credentials.host}`, + `--port=${credentials.port}`, + `--username=${credentials.user}`, + `--dbname=${credentials.database}`, + "--set", + "ON_ERROR_STOP=on", + "-c", + "DROP SCHEMA public CASCADE; CREATE SCHEMA public;", + ], + env: postgresEnv(credentials.password), + stdout: "pipe", + stderr: "pipe", + display: label, + }; +} + +function specToShellCommand(spec: CommandSpec): string { + if (spec.shell) { + return spec.command; + } + return [spec.command, ...((spec.args ?? []).map(shellQuote))].join(" "); +} + +function dumpSpec(engine: DatabaseEngine, credentials: DatabaseCredentials, outputPath: string, label: string): CommandSpec { + return engine === "mysql" + ? mysqlDumpSpec(credentials, outputPath, label) + : postgresDumpSpec(credentials, outputPath, label); +} + +function importSpec(engine: DatabaseEngine, credentials: DatabaseCredentials, inputPath: string, label: string): CommandSpec { + return engine === "mysql" + ? mysqlImportSpec(credentials, inputPath, label) + : postgresImportSpec(credentials, inputPath, label); +} + +function clearSpec(engine: DatabaseEngine, credentials: DatabaseCredentials, label: string): CommandSpec { + return engine === "mysql" + ? mysqlClearSpec(credentials, label) + : postgresClearSpec(credentials, label); +} + +function sshCommand(runtime: LoadedRuntime, remoteScript: string, env?: Record): CommandSpec { + const prefix = env + ? `${Object.entries(env) + .map(([key, value]) => `${key}=${shellQuote(value ?? "")}`) + .join(" ")} ` + : ""; + return { + command: "ssh", + args: [ + "-p", + String(runtime.config.remote.port), + `${runtime.config.remote.user}@${runtime.config.remote.host}`, + `${prefix}${remoteScript}`, + ], + stdout: "pipe", + stderr: "pipe", + display: `ssh ${runtime.config.remote.user}@${runtime.config.remote.host}`, + }; +} + +function rsyncCopyFromRemote(runtime: LoadedRuntime, from: string, to: string, label: string): CommandSpec { + return { + command: "rsync", + args: [ + "--archive", + `--rsh=ssh -p ${runtime.config.remote.port}`, + `${runtime.config.remote.user}@${runtime.config.remote.host}:${from}`, + to, + ], + stdout: "pipe", + stderr: "pipe", + display: label, + }; +} + +function rsyncCopyToRemote(runtime: LoadedRuntime, from: string, to: string, label: string): CommandSpec { + return { + command: "rsync", + args: [ + "--archive", + `--rsh=ssh -p ${runtime.config.remote.port}`, + from, + `${runtime.config.remote.user}@${runtime.config.remote.host}:${to}`, + ], + stdout: "pipe", + stderr: "pipe", + display: label, + }; +} + +function remoteTempPath(runtime: LoadedRuntime, fileName: string): string { + return path.posix.join(runtime.config.remote.app_path, ".tug", "tmp", fileName); +} + +async function remoteCommandExists( + runtime: LoadedRuntime, + tool: string, +): Promise { + const result = await runtime.runner.run({ + command: "ssh", + args: [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=2", + "-o", + "NumberOfPasswordPrompts=0", + "-o", + "PreferredAuthentications=publickey", + "-p", + String(runtime.config.remote.port), + `${runtime.config.remote.user}@${runtime.config.remote.host}`, + `command -v ${tool} >/dev/null 2>&1`, + ], + stdout: "pipe", + stderr: "pipe", + allowFailure: true, + display: `check remote tool ${tool}`, + }); + return result.exitCode === 0; +} + +function toolsFor( + engine: DatabaseEngine, + side: "local" | "remote", + action: DatabaseAction, +): string[] { + if (engine === "mysql") { + if (side === "local") { + return action === "pull" ? ["mysql"] : ["mysqldump"]; + } + return action === "pull" ? ["mysqldump"] : ["mysqldump", "mysql"]; + } + + if (side === "local") { + return action === "pull" ? ["psql"] : ["pg_dump"]; + } + return action === "pull" ? ["pg_dump"] : ["pg_dump", "psql"]; +} + +async function assertDatabasePrereqs( + runtime: LoadedRuntime, + localEngine: DatabaseEngine, + remoteEngine: DatabaseEngine, + action: DatabaseAction, +): Promise { + for (const tool of toolsFor(localEngine, "local", action)) { + const exists = await commandExists(runtime.runner, tool); + if (!exists) { + throw new Error(`Local database prerequisite missing: ${tool} is not installed or not on PATH.`); + } + } + + for (const tool of toolsFor(remoteEngine, "remote", action)) { + const exists = await remoteCommandExists(runtime, tool); + if (!exists) { + throw new Error( + `Remote database prerequisite missing: ${tool} is not installed or not on PATH for ${runtime.config.remote.user}@${runtime.config.remote.host}.`, + ); + } + } +} + +export async function buildDatabaseCommands( + runtime: LoadedRuntime, + action: DatabaseAction, +): Promise<{ plan: TaskPlan; artifacts: string[] }> { + const taskId = action === "push" ? "databasePush" : "databasePull"; + ensureTaskEnabled(runtime.config, taskId); + + const localCreds = await resolveLocalDatabaseCredentials( + runtime.options.cwd, + runtime.config, + runtime.options, + ); + const remoteCreds = await resolveRemoteDatabaseCredentials(runtime.config, runtime.runner); + await assertDatabasePrereqs(runtime, localCreds.engine, remoteCreds.engine, action); + const session = await createBackupSession(runtime.options.cwd, runtime.config); + const localBackup = path.resolve(session.backupDir, `local-${action === "push" ? "before-push" : "before-pull"}.sql.gz`); + const remoteBackup = path.resolve(session.backupDir, `remote-${action === "push" ? "before-push" : "source"}.sql.gz`); + const localTemp = path.resolve(session.tempDir, `${action}.sql.gz`); + const remoteTemp = remoteTempPath(runtime, `${session.token}-${action}.sql.gz`); + const remoteBackupTemp = remoteTempPath(runtime, `${session.token}-backup.sql.gz`); + + const ensureRemoteTmp = sshCommand( + runtime, + `mkdir -p ${shellQuote(path.posix.dirname(remoteTemp))}`, + ); + + const commands: CommandSpec[] = + action === "pull" + ? [ + ensureRemoteTmp, + sshCommand( + runtime, + dumpSpec(remoteCreds.engine, remoteCreds, remoteTemp, "remote dump").command, + remoteCreds.engine === "mysql" ? mysqlEnv(remoteCreds.password) : postgresEnv(remoteCreds.password), + ), + rsyncCopyFromRemote(runtime, remoteTemp, localTemp, "download remote database dump"), + sshCommand(runtime, `rm -f ${shellQuote(remoteTemp)}`), + dumpSpec(localCreds.engine, localCreds, localBackup, "backup local database"), + clearSpec(localCreds.engine, localCreds, "clear local database"), + importSpec(localCreds.engine, localCreds, localTemp, "import pulled database"), + ] + : [ + ensureRemoteTmp, + dumpSpec(localCreds.engine, localCreds, localBackup, "backup local database"), + sshCommand( + runtime, + dumpSpec(remoteCreds.engine, remoteCreds, remoteBackupTemp, "backup remote database").command, + remoteCreds.engine === "mysql" ? mysqlEnv(remoteCreds.password) : postgresEnv(remoteCreds.password), + ), + rsyncCopyFromRemote(runtime, remoteBackupTemp, remoteBackup, "download remote backup"), + sshCommand(runtime, `rm -f ${shellQuote(remoteBackupTemp)}`), + dumpSpec(localCreds.engine, localCreds, localTemp, "create upload database dump"), + rsyncCopyToRemote(runtime, localTemp, remoteTemp, "upload local database dump"), + sshCommand( + runtime, + specToShellCommand(clearSpec(remoteCreds.engine, remoteCreds, "clear remote database")), + remoteCreds.engine === "mysql" ? mysqlEnv(remoteCreds.password) : postgresEnv(remoteCreds.password), + ), + sshCommand( + runtime, + specToShellCommand(importSpec(remoteCreds.engine, remoteCreds, remoteTemp, "import remote database")), + remoteCreds.engine === "mysql" ? mysqlEnv(remoteCreds.password) : postgresEnv(remoteCreds.password), + ), + sshCommand(runtime, `rm -f ${shellQuote(remoteTemp)}`), + ]; + + return { + plan: { + id: taskId, + title: action === "push" ? "Database push" : "Database pull", + summary: + action === "push" + ? "Replace the remote database with a local dump." + : "Replace the local database with a remote dump.", + target: runtime.config.remote.host, + destructive: true, + commands, + }, + artifacts: [session.backupDir], + }; +} + +export interface DatabaseDoctorReport { + ok: boolean; + engine: DatabaseEngine; + checks: DoctorCheck[]; + localCredentials?: DatabaseCredentials; + remoteCredentials?: DatabaseCredentials; +} + +export function formatDatabaseDoctorReport(report: DatabaseDoctorReport): string { + const lines = [`Database engine: ${report.engine}`, ""]; + for (const check of report.checks) { + const badge = + check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL"; + lines.push(`[${badge}] ${check.label}: ${check.message}`); + } + return lines.join("\n"); +} + +export async function doctor(runtime: LoadedRuntime): Promise { + const checks: DoctorCheck[] = []; + const add = (check: DoctorCheck): void => { + checks.push(check); + }; + + let localCredentials: DatabaseCredentials | undefined; + try { + localCredentials = await resolveLocalDatabaseCredentials( + runtime.options.cwd, + runtime.config, + runtime.options, + ); + add({ + id: "db-local-env", + label: "Local DB env", + status: "pass", + message: `${localCredentials.engine}://${localCredentials.user}@${localCredentials.host}:${localCredentials.port}/${localCredentials.database}`, + }); + } catch (error) { + add({ + id: "db-local-env", + label: "Local DB env", + status: "fail", + message: error instanceof Error ? error.message : "Could not resolve local DB settings.", + }); + } + + let remoteCredentials: DatabaseCredentials | undefined; + try { + remoteCredentials = await resolveRemoteDatabaseCredentials(runtime.config, runtime.runner); + add({ + id: "db-remote-env", + label: "Remote DB env", + status: "pass", + message: `${remoteCredentials.engine}://${remoteCredentials.user}@${remoteCredentials.host}:${remoteCredentials.port}/${remoteCredentials.database}`, + }); + } catch (error) { + add({ + id: "db-remote-env", + label: "Remote DB env", + status: "fail", + message: error instanceof Error ? error.message : "Could not resolve remote DB settings.", + }); + } + + const engine = localCredentials?.engine ?? remoteCredentials?.engine ?? runtime.config.database.engine; + const localTools = engine === "mysql" ? ["mysqldump", "mysql"] : ["pg_dump", "psql"]; + const remoteTools = engine === "mysql" ? ["mysqldump", "mysql"] : ["pg_dump", "psql"]; + + for (const tool of localTools) { + const exists = await commandExists(runtime.runner, tool); + add({ + id: `db-local-tool-${tool}`, + label: `Local tool: ${tool}`, + status: exists ? "pass" : "fail", + message: exists ? `${tool} is available locally.` : `${tool} is missing locally.`, + }); + } + + for (const tool of remoteTools) { + const exists = await remoteCommandExists(runtime, tool); + add({ + id: `db-remote-tool-${tool}`, + label: `Remote tool: ${tool}`, + status: exists ? "pass" : "fail", + message: exists + ? `${tool} is available on ${runtime.config.remote.host}.` + : `${tool} is missing on ${runtime.config.remote.host} for ${runtime.config.remote.user}.`, + }); + } + + return { + ok: checks.every((check) => check.status === "pass"), + engine, + checks, + localCredentials, + remoteCredentials, + }; +} + +export async function plan(runtime: LoadedRuntime, action: DatabaseAction): Promise { + return (await buildDatabaseCommands(runtime, action)).plan; +} + +export async function confirmMessage(runtime: LoadedRuntime, action: DatabaseAction): Promise { + return action === "push" + ? `Replace the remote database on ${runtime.config.remote.host}?` + : "Replace the local database with the remote database?"; +} + +export async function run(runtime: LoadedRuntime, action: DatabaseAction): Promise { + const built = await buildDatabaseCommands(runtime, action); + ensureForceForDestructive(runtime, built.plan); + await executePlan(runtime, built.plan); + return ok(`${built.plan.title} finished successfully.`, built.artifacts); +} diff --git a/src/tasks/folder.ts b/src/tasks/folder.ts new file mode 100644 index 0000000..ac3306d --- /dev/null +++ b/src/tasks/folder.ts @@ -0,0 +1,108 @@ +import path from "node:path"; +import { uniq } from "es-toolkit"; +import { shellQuote } from "../core/shell"; +import type { + CommandSpec, + FolderAction, + LoadedRuntime, + TaskPlan, + TaskResult, +} from "../types"; +import { ensureForceForDestructive, ensureTaskEnabled, executePlan, ok } from "./shared"; + +function baseExcludeArgs(extra: string[]): string[] { + return uniq([".git", ".env", ".DS_Store", ...extra]).flatMap((pattern) => [ + `--exclude=${pattern}`, + ]); +} + +export function buildFolderPushCommands(runtime: LoadedRuntime): CommandSpec[] { + return runtime.config.files.push.map((folder) => { + const remotePath = path.posix.join(runtime.config.remote.app_path, folder); + const remoteFolder = shellQuote(`${remotePath}/`); + return { + command: "rsync", + args: [ + "--archive", + "--compress", + "--itemize-changes", + "--delete", + ...(runtime.options.dryRun ? ["--dry-run"] : []), + ...baseExcludeArgs(runtime.config.files.exclude), + `--rsh=ssh -p ${runtime.config.remote.port}`, + `--rsync-path=mkdir -p ${shellQuote(remotePath)} && rsync`, + `${path.resolve(runtime.options.cwd, folder)}/`, + `${runtime.config.remote.user}@${runtime.config.remote.host}:${remoteFolder}`, + ], + stdout: "pipe" as const, + stderr: "pipe" as const, + display: `rsync push ${folder}`, + }; + }); +} + +export function buildFolderPullCommands(runtime: LoadedRuntime): CommandSpec[] { + const mkdirCommands = runtime.options.dryRun ? [] : runtime.config.files.pull.map((folder) => ({ + command: "mkdir", + args: ["-p", path.resolve(runtime.options.cwd, folder)], + stdout: "pipe" as const, + stderr: "pipe" as const, + display: `mkdir -p ${folder}`, + })); + const rsyncCommands = runtime.config.files.pull.map((folder) => { + const remotePath = path.posix.join(runtime.config.remote.app_path, folder); + return { + command: "rsync", + args: [ + "--archive", + "--compress", + "--itemize-changes", + ...(runtime.options.dryRun ? ["--dry-run"] : []), + ...baseExcludeArgs(runtime.config.files.exclude), + `--rsh=ssh -p ${runtime.config.remote.port}`, + `${runtime.config.remote.user}@${runtime.config.remote.host}:${shellQuote(`${remotePath}/`)}`, + `${path.resolve(runtime.options.cwd, folder)}/`, + ], + stdout: "pipe" as const, + stderr: "pipe" as const, + display: `rsync pull ${folder}`, + }; + }); + return [...mkdirCommands, ...rsyncCommands]; +} + +export async function plan(runtime: LoadedRuntime, action: FolderAction): Promise { + const taskId = action === "push" ? "folderPush" : "folderPull"; + ensureTaskEnabled(runtime.config, taskId); + const folders = action === "push" ? runtime.config.files.push : runtime.config.files.pull; + if (folders.length === 0) { + throw new Error(`No ${action} folders configured in tug.toml.`); + } + + return { + id: taskId, + title: action === "push" ? "Folder push" : "Folder pull", + summary: + action === "push" + ? "Sync configured local folders to the remote app path." + : "Sync configured remote folders back to the local project.", + target: runtime.config.remote.host, + destructive: action === "push", + commands: action === "push" ? buildFolderPushCommands(runtime) : buildFolderPullCommands(runtime), + }; +} + +export async function confirmMessage(runtime: LoadedRuntime, action: FolderAction): Promise { + return action === "push" + ? `Push ${runtime.config.files.push.length} folder(s) to ${runtime.config.remote.host}?` + : `Pull ${runtime.config.files.pull.length} folder(s) from ${runtime.config.remote.host}?`; +} + +export async function run(runtime: LoadedRuntime, action: FolderAction): Promise { + const taskPlan = await plan(runtime, action); + if (action === "push") { + ensureForceForDestructive(runtime, taskPlan); + } + await executePlan(runtime, taskPlan); + return ok(`${taskPlan.title} finished successfully.`); +} diff --git a/src/tasks/index.ts b/src/tasks/index.ts new file mode 100644 index 0000000..a21a980 --- /dev/null +++ b/src/tasks/index.ts @@ -0,0 +1,54 @@ +import type { TaskDescriptor } from "../types"; + +export const taskDescriptors: TaskDescriptor[] = [ + { + id: "folderPull", + title: "Folder pull", + description: "Sync configured remote folders into the local project.", + action: "pull", + }, + { + id: "folderPush", + title: "Folder push", + description: "Sync configured local folders to the remote project.", + action: "push", + }, + { + id: "databasePull", + title: "Database pull", + description: "Replace the local database with a remote dump.", + action: "pull", + }, + { + id: "databasePush", + title: "Database push", + description: "Replace the remote database with a local dump.", + action: "push", + }, + { + id: "composerPull", + title: "Composer pull", + description: "Replace local composer files from the remote app.", + disabledBy: "composer", + action: "pull", + }, + { + id: "composerPush", + title: "Composer push", + description: "Upload local composer files to the remote app.", + disabledBy: "composer", + action: "push", + }, + { + id: "backups", + title: "View backups", + description: "List and open backup snapshots.", + action: "list", + }, + { + id: "terminal", + title: "Remote shell", + description: "Open a shell in the remote app path.", + action: "shell", + }, +]; diff --git a/src/tasks/shared.ts b/src/tasks/shared.ts new file mode 100644 index 0000000..94a134c --- /dev/null +++ b/src/tasks/shared.ts @@ -0,0 +1,57 @@ +import { access } from "node:fs/promises"; +import type { LoadedRuntime, TaskPlan, TaskResult, TugConfig } from "../types"; + +export async function fileExists(target: string): Promise { + try { + await access(target); + return true; + } catch { + return false; + } +} + +export function isDisabled(config: TugConfig, taskId: string): boolean { + return config.project.disabled_tasks.includes(taskId); +} + +export function ensureTaskEnabled(config: TugConfig, taskId: string): void { + if (isDisabled(config, taskId)) { + throw new Error(`Task ${taskId} is disabled in tug.toml.`); + } +} + +export function ensureComposerEnabled(config: TugConfig): void { + if (!config.composer.enabled) { + throw new Error("Composer tasks are disabled in tug.toml."); + } +} + +export function ensureForceForDestructive(runtime: LoadedRuntime, plan: TaskPlan): void { + if (plan.destructive && !runtime.options.force) { + throw new Error(`"${plan.title}" is destructive. Re-run with --force or use the interactive TUI.`); + } +} + +export async function executePlan(runtime: LoadedRuntime, plan: TaskPlan): Promise { + for (const command of plan.commands) { + runtime.logger.add(`$ ${command.display ?? [command.command, ...(command.args ?? [])].join(" ")}`); + await runtime.runner.run(command, { + onStdout: (text) => { + const trimmed = text.trimEnd(); + if (trimmed) { + runtime.logger.add(trimmed); + } + }, + onStderr: (text) => { + const trimmed = text.trimEnd(); + if (trimmed) { + runtime.logger.add(trimmed); + } + }, + }); + } +} + +export function ok(summary: string, artifacts?: string[]): TaskResult { + return { ok: true, summary, artifacts }; +} diff --git a/src/tasks/shell.ts b/src/tasks/shell.ts new file mode 100644 index 0000000..548d6c7 --- /dev/null +++ b/src/tasks/shell.ts @@ -0,0 +1,42 @@ +import type { CommandSpec, LoadedRuntime, TaskPlan, TaskResult } from "../types"; +import { shellQuote } from "../core/shell"; +import { executePlan, ok } from "./shared"; + +export function buildShellCommand(runtime: LoadedRuntime): CommandSpec { + const remoteAppPath = shellQuote(runtime.config.remote.app_path); + return { + command: "ssh", + args: [ + "-t", + "-p", + String(runtime.config.remote.port), + `${runtime.config.remote.user}@${runtime.config.remote.host}`, + `cd ${remoteAppPath} && exec $SHELL -l`, + ], + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + display: `ssh ${runtime.config.remote.user}@${runtime.config.remote.host}`, + }; +} + +export async function plan(runtime: LoadedRuntime): Promise { + return { + id: "terminal", + title: "Remote shell", + summary: "Open an interactive shell in the remote app directory.", + target: runtime.config.remote.host, + destructive: false, + commands: [buildShellCommand(runtime)], + }; +} + +export async function confirmMessage(): Promise { + return "Open a remote shell session?"; +} + +export async function run(runtime: LoadedRuntime): Promise { + const taskPlan = await plan(runtime); + await executePlan(runtime, taskPlan); + return ok("Remote shell session exited."); +} diff --git a/src/tui/app.ts b/src/tui/app.ts new file mode 100644 index 0000000..57ad78b --- /dev/null +++ b/src/tui/app.ts @@ -0,0 +1,375 @@ +import { + BoxRenderable, + CliRenderEvents, + SelectRenderable, + SelectRenderableEvents, + TextRenderable, + createCliRenderer, +} from "@opentui/core"; +import { inspectProject } from "../core/doctor"; +import { createLoadedRuntime } from "../core/runtime"; +import * as backupsTask from "../tasks/backups"; +import * as composerTask from "../tasks/composer"; +import * as databaseTask from "../tasks/database"; +import * as folderTask from "../tasks/folder"; +import * as shellTask from "../tasks/shell"; +import { taskDescriptors } from "../tasks"; +import type { + DoctorReport, + LoadedRuntime, + RuntimeOptions, + TaskDescriptor, +} from "../types"; +import { formatConfirmation } from "./components/confirmation-dialog"; +import { renderDashboardDetails } from "./screens/dashboard"; +import { renderTaskRunner } from "./screens/task-runner"; + +interface InteractiveTask { + descriptor: TaskDescriptor; + execute: (runtime: LoadedRuntime) => Promise<{ summary: string }>; + confirm: (runtime: LoadedRuntime) => Promise; + destructive: boolean; +} + +function enabledTasks(runtime: LoadedRuntime): InteractiveTask[] { + return taskDescriptors + .filter( + (task) => + task.disabledBy !== "composer" || runtime.config.composer.enabled, + ) + .filter((task) => !runtime.config.project.disabled_tasks.includes(task.id)) + .map((descriptor) => { + switch (descriptor.id) { + case "folderPull": + return { + descriptor, + execute: (runtime) => + folderTask.run( + { ...runtime, options: { ...runtime.options, force: true } }, + "pull", + ), + confirm: (runtime) => folderTask.confirmMessage(runtime, "pull"), + destructive: false, + }; + case "folderPush": + return { + descriptor, + execute: (runtime) => + folderTask.run( + { ...runtime, options: { ...runtime.options, force: true } }, + "push", + ), + confirm: (runtime) => folderTask.confirmMessage(runtime, "push"), + destructive: true, + }; + case "databasePull": + return { + descriptor, + execute: (runtime) => + databaseTask.run( + { ...runtime, options: { ...runtime.options, force: true } }, + "pull", + ), + confirm: (runtime) => databaseTask.confirmMessage(runtime, "pull"), + destructive: true, + }; + case "databasePush": + return { + descriptor, + execute: (runtime) => + databaseTask.run( + { ...runtime, options: { ...runtime.options, force: true } }, + "push", + ), + confirm: (runtime) => databaseTask.confirmMessage(runtime, "push"), + destructive: true, + }; + case "composerPull": + return { + descriptor, + execute: (runtime) => + composerTask.run( + { ...runtime, options: { ...runtime.options, force: true } }, + "pull", + ), + confirm: (runtime) => composerTask.confirmMessage(runtime, "pull"), + destructive: true, + }; + case "composerPush": + return { + descriptor, + execute: (runtime) => + composerTask.run( + { ...runtime, options: { ...runtime.options, force: true } }, + "push", + ), + confirm: (runtime) => composerTask.confirmMessage(runtime, "push"), + destructive: true, + }; + case "backups": + return { + descriptor, + execute: (runtime) => backupsTask.run(runtime, "list"), + confirm: async () => "List backups?", + destructive: false, + }; + case "terminal": + return { + descriptor, + execute: (runtime) => shellTask.run(runtime), + confirm: () => shellTask.confirmMessage(), + destructive: false, + }; + default: + throw new Error(`Unknown task ${descriptor.id}`); + } + }); +} + +export async function startTui(options: RuntimeOptions): Promise { + const runtime = await createLoadedRuntime({ ...options, interactive: true }); + let doctorReport: DoctorReport = { + ok: true, + checks: [ + { + id: "preflight", + label: "Preflight", + status: "warn" as const, + message: "Running checks...", + }, + ], + }; + const tasks = enabledTasks(runtime); + if (tasks.length === 0) { + throw new Error("No enabled tasks are available."); + } + + const renderer = await createCliRenderer({ + useAlternateScreen: true, + useConsole: process.env.NODE_ENV !== "production", + openConsoleOnError: true, + exitOnCtrlC: false, + }); + + let selectedIndex = 0; + let lastSummary = ""; + let confirmMode = false; + let activeTask: InteractiveTask | null = null; + let running = false; + + const root = new BoxRenderable(renderer, { + width: "100%", + height: "100%", + flexDirection: "column", + padding: 1, + gap: 1, + backgroundColor: "#111111", + }); + + const topRow = new BoxRenderable(renderer, { + flexDirection: "row", + flexGrow: 1, + gap: 1, + width: "100%", + }); + const leftPane = new BoxRenderable(renderer, { + width: 60, + border: true, + borderStyle: "single", + title: "Tasks", + }); + const rightPane = new BoxRenderable(renderer, { + flexGrow: 1, + border: true, + borderStyle: "single", + title: "Details", + padding: 1, + }); + const logPane = new BoxRenderable(renderer, { + height: 18, + border: true, + borderStyle: "single", + title: "Run Log", + padding: 1, + }); + const select = new SelectRenderable(renderer, { + width: "100%", + height: "100%", + options: tasks.map((task) => ({ + name: task.descriptor.title, + description: task.descriptor.description, + })), + }); + const detailsText = new TextRenderable(renderer, { + content: "", + }); + const logText = new TextRenderable(renderer, { + content: "Ready.", + }); + const modal = new BoxRenderable(renderer, { + position: "absolute", + left: 10, + top: 6, + width: "70%", + height: 9, + border: true, + borderStyle: "double", + title: "Confirm", + padding: 1, + backgroundColor: "#1b1b1b", + }); + const modalText = new TextRenderable(renderer, { content: "" }); + + modal.add(modalText); + leftPane.add(select); + rightPane.add(detailsText); + logPane.add(logText); + topRow.add(leftPane); + topRow.add(rightPane); + root.add(topRow); + root.add(logPane); + renderer.root.add(root); + + const updateDetails = (): void => { + const selectedTask = tasks[selectedIndex]!.descriptor; + detailsText.content = renderDashboardDetails({ + config: runtime.config, + selectedTask, + doctor: doctorReport, + lastSummary, + }); + }; + + const updateLogs = (status: string): void => { + logText.content = renderTaskRunner({ + title: activeTask?.descriptor.title ?? "No active task", + status, + logs: runtime.logger.snapshot(), + }); + }; + + runtime.logger.subscribe(() => { + updateLogs(running ? "running" : "idle"); + }); + + const hideModal = (): void => { + if (confirmMode) { + root.remove(modal.id); + } + confirmMode = false; + modalText.content = ""; + }; + + const showModal = (text: string): void => { + confirmMode = true; + modalText.content = formatConfirmation(text); + if (!root.getChildren().some((child) => child.id === modal.id)) { + root.add(modal); + } + }; + + const executeTask = async (task: InteractiveTask): Promise => { + activeTask = task; + runtime.logger.clear(); + running = true; + updateLogs("running"); + + if (task.descriptor.id === "terminal") { + renderer.destroy(); + try { + const result = await task.execute(runtime); + console.log(result.summary); + process.exitCode = 0; + } catch (error) { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + console.error(message); + process.exitCode = 1; + } + return; + } + + try { + const result = await task.execute(runtime); + lastSummary = result.summary; + } catch (error) { + lastSummary = error instanceof Error ? error.message : "Task failed."; + runtime.logger.add(lastSummary); + } finally { + running = false; + activeTask = null; + updateDetails(); + updateLogs("idle"); + } + }; + + select.on(SelectRenderableEvents.SELECTION_CHANGED, (index: number) => { + selectedIndex = index; + updateDetails(); + }); + select.on(SelectRenderableEvents.ITEM_SELECTED, async () => { + if (running) { + return; + } + const selected = tasks[selectedIndex]!; + if (selected.destructive) { + showModal(await selected.confirm(runtime)); + activeTask = selected; + return; + } + await executeTask(selected); + }); + + renderer.keyInput.on("keypress", async (key) => { + if (key.ctrl && key.name === "c") { + renderer.destroy(); + process.exitCode = 130; + return; + } + if (key.name === "q" && !running && !confirmMode) { + renderer.destroy(); + process.exitCode = 0; + return; + } + if (key.name === "escape" && confirmMode) { + hideModal(); + activeTask = null; + return; + } + if (key.name === "`" && process.env.NODE_ENV !== "production") { + renderer.console.toggle(); + return; + } + if (confirmMode && key.name === "return" && activeTask) { + hideModal(); + await executeTask(activeTask); + } + }); + + renderer.on(CliRenderEvents.DESTROY, () => {}); + + updateDetails(); + updateLogs("idle"); + select.focus(); + renderer.start(); + + void inspectProject(runtime.options, runtime.runner) + .then((report) => { + doctorReport = report; + updateDetails(); + }) + .catch((error) => { + doctorReport = { + ok: false, + checks: [ + { + id: "preflight-error", + label: "Preflight", + status: "warn", + message: + error instanceof Error ? error.message : "Preflight failed.", + }, + ], + }; + updateDetails(); + }); +} diff --git a/src/tui/components/confirmation-dialog.ts b/src/tui/components/confirmation-dialog.ts new file mode 100644 index 0000000..3b1dfe3 --- /dev/null +++ b/src/tui/components/confirmation-dialog.ts @@ -0,0 +1,3 @@ +export function formatConfirmation(prompt: string): string { + return `${prompt}\n\nUse Enter to confirm or Esc to cancel.`; +} diff --git a/src/tui/screens/dashboard.ts b/src/tui/screens/dashboard.ts new file mode 100644 index 0000000..629cc1a --- /dev/null +++ b/src/tui/screens/dashboard.ts @@ -0,0 +1,32 @@ +import type { DoctorReport, TaskDescriptor, TugConfig } from "../../types"; + +export function renderDashboardDetails(args: { + config: TugConfig; + selectedTask: TaskDescriptor; + doctor: DoctorReport; + lastSummary: string; +}): string { + const doctorLines = args.doctor.checks + .slice(0, 4) + .map((check) => { + const badge = + check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL"; + return `[${badge}] ${check.label}`; + }) + .join("\n"); + + return [ + `${args.selectedTask.title}`, + "", + args.selectedTask.description, + "", + `Remote: ${args.config.remote.user}@${args.config.remote.host}:${args.config.remote.app_path}`, + `Database: ${args.config.database.engine}`, + "", + "Preflight:", + doctorLines, + "", + "Last run:", + args.lastSummary || "Nothing run yet.", + ].join("\n"); +} diff --git a/src/tui/screens/task-runner.ts b/src/tui/screens/task-runner.ts new file mode 100644 index 0000000..1d8f7d7 --- /dev/null +++ b/src/tui/screens/task-runner.ts @@ -0,0 +1,7 @@ +export function renderTaskRunner(args: { + title: string; + status: string; + logs: string[]; +}): string { + return [`${args.title}`, "", `Status: ${args.status}`, "", ...args.logs.slice(-14)].join("\n"); +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..86c51ea --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,146 @@ +export type DatabaseEngine = "mysql" | "postgres"; +export type FolderAction = "pull" | "push"; +export type DatabaseAction = "pull" | "push"; +export type ComposerAction = "pull" | "push"; +export type BackupAction = "list" | "open"; + +export interface TugProjectConfig { + name?: string; + disabled_tasks: string[]; +} + +export interface TugRemoteConfig { + host: string; + user: string; + port: number; + app_path: string; + env_path: string; +} + +export interface TugFilesConfig { + push: string[]; + pull: string[]; + exclude: string[]; +} + +export interface TugDatabaseConfig { + engine: DatabaseEngine; + local_env_file: string; + remote_env_path: string; + backup_dir: string; +} + +export interface TugComposerConfig { + enabled: boolean; +} + +export interface TugConfig { + project: TugProjectConfig; + remote: TugRemoteConfig; + files: TugFilesConfig; + database: TugDatabaseConfig; + composer: TugComposerConfig; +} + +export interface RuntimeOptions { + cwd: string; + configPath?: string; + envFile?: string; + force: boolean; + dryRun: boolean; + verbose: boolean; + json: boolean; + interactive: boolean; +} + +export interface CommandSpec { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + shell?: boolean; + stdin?: "inherit" | "pipe" | string; + stdout?: "inherit" | "pipe"; + stderr?: "inherit" | "pipe"; + display?: string; + allowFailure?: boolean; +} + +export interface CommandHooks { + onStdout?: (text: string) => void; + onStderr?: (text: string) => void; +} + +export interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface ProcessRunner { + run(spec: CommandSpec, hooks?: CommandHooks): Promise; +} + +export interface Logger { + add(line: string): void; + clear(): void; + snapshot(): string[]; + subscribe(listener: (lines: string[]) => void): () => void; +} + +export interface TaskPlan { + id: string; + title: string; + summary: string; + target: string; + destructive: boolean; + commands: CommandSpec[]; +} + +export interface TaskResult { + ok: boolean; + summary: string; + artifacts?: string[]; +} + +export interface DoctorCheck { + id: string; + label: string; + status: "pass" | "warn" | "fail"; + message: string; +} + +export interface DoctorReport { + ok: boolean; + checks: DoctorCheck[]; +} + +export interface DatabaseCredentials { + engine: DatabaseEngine; + host: string; + port: number; + database: string; + user: string; + password: string; + url?: string; +} + +export interface LoadedRuntime { + options: RuntimeOptions; + config: TugConfig; + runner: ProcessRunner; + logger: Logger; +} + +export interface TaskDescriptor { + id: string; + title: string; + description: string; + disabledBy?: "composer"; + action?: + | FolderAction + | DatabaseAction + | ComposerAction + | BackupAction + | "shell"; +} diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..b45129d --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { readConfig, renderExampleConfig, writeInitialConfig } from "../src/core/config"; +import { + cleanupTempProject, + createTempProject, + writeProjectFile, +} from "./helpers"; + +const tempDirs: string[] = []; + +afterEach(async () => { + while (tempDirs.length > 0) { + await cleanupTempProject(tempDirs.pop()!); + } +}); + +describe("config", () => { + test("writes and reads tug.toml", async () => { + const cwd = await createTempProject(); + tempDirs.push(cwd); + await writeProjectFile(cwd, ".env.example", "DB_DATABASE=test\nDB_USER=root\n"); + await writeInitialConfig(cwd); + const config = await readConfig(cwd); + const envCopy = await readFile(`${cwd}/.env`, "utf8"); + expect(config.remote.host).toBe("example.com"); + expect(config.database.engine).toBe("mysql"); + expect(envCopy).toBe("DB_DATABASE=test\nDB_USER=root\n"); + }); + + test("refuses to overwrite tug.toml without force", async () => { + const cwd = await createTempProject(); + tempDirs.push(cwd); + await writeProjectFile(cwd, "tug.toml", "existing = true\n"); + await expect(writeInitialConfig(cwd)).rejects.toThrow("Refusing to overwrite"); + }); + + test("renders example config with project section", () => { + const rendered = renderExampleConfig(); + expect(rendered).toContain("[project]"); + expect(rendered).toContain('engine = "mysql"'); + }); +}); diff --git a/tests/database.test.ts b/tests/database.test.ts new file mode 100644 index 0000000..482df9e --- /dev/null +++ b/tests/database.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { buildDatabaseCommands, doctor as databaseDoctor } from "../src/tasks/database"; +import type { TugConfig } from "../src/types"; +import { + MockRunner, + cleanupTempProject, + createRuntime, + createTempProject, + writeProjectFile, +} from "./helpers"; + +const tempDirs: string[] = []; + +afterEach(async () => { + while (tempDirs.length > 0) { + await cleanupTempProject(tempDirs.pop()!); + } +}); + +function createConfig(engine: "mysql" | "postgres"): TugConfig { + return { + project: { disabled_tasks: [] }, + remote: { + host: "example.com", + user: "deploy", + port: 22, + app_path: "/srv/app/current", + env_path: "/srv/app/current/.env", + }, + files: { push: [], pull: [], exclude: [] }, + database: { + engine, + local_env_file: ".env", + remote_env_path: "/srv/app/current/.env", + backup_dir: ".tug/backups", + }, + composer: { enabled: true }, + }; +} + +describe("database orchestration", () => { + test("builds mysql pull commands from local and remote env", async () => { + const cwd = await createTempProject(); + tempDirs.push(cwd); + await writeProjectFile( + cwd, + ".env", + 'DB_DRIVER="mysql"\nDB_SERVER="127.0.0.1"\nDB_PORT="3306"\nDB_DATABASE="craft"\nDB_USER="root"\nDB_PASSWORD="secret"\n', + ); + const runner = new MockRunner(async (spec) => { + if (spec.command === "ssh" && spec.args?.at(-1)?.startsWith("cat ")) { + return { + exitCode: 0, + stdout: + 'DB_DRIVER="mysql"\nDB_SERVER="127.0.0.1"\nDB_PORT="3306"\nDB_DATABASE="remote_craft"\nDB_USER="remote"\nDB_PASSWORD="remote-secret"\n', + stderr: "", + }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + const runtime = createRuntime(cwd, createConfig("mysql"), runner); + const built = await buildDatabaseCommands(runtime, "pull"); + expect(built.plan.commands.some((command) => command.display === "import pulled database")).toBe( + true, + ); + expect(built.plan.commands.some((command) => command.display === "backup local database")).toBe( + true, + ); + }); + + test("builds postgres push commands from DATABASE_URL", async () => { + const cwd = await createTempProject(); + tempDirs.push(cwd); + await writeProjectFile( + cwd, + ".env", + 'DATABASE_URL="postgres://craft:secret@localhost:5432/craftdb"\n', + ); + const runner = new MockRunner(async (spec) => { + if (spec.command === "ssh" && spec.args?.at(-1)?.startsWith("cat ")) { + return { + exitCode: 0, + stdout: 'DATABASE_URL="postgres://remote:remote-secret@db.internal:5432/remotedb"\n', + stderr: "", + }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + const runtime = createRuntime(cwd, createConfig("postgres"), runner); + const built = await buildDatabaseCommands(runtime, "push"); + expect(built.plan.commands.some((command) => command.display === "upload local database dump")).toBe( + true, + ); + expect( + built.plan.commands.some((command) => command.command === "ssh" && command.args?.at(-1)?.includes("psql")), + ).toBe(true); + }); + + test("fails fast with clear local prerequisite error", async () => { + const cwd = await createTempProject(); + tempDirs.push(cwd); + await writeProjectFile( + cwd, + ".env", + 'DB_DRIVER="mysql"\nDB_SERVER="127.0.0.1"\nDB_PORT="3306"\nDB_DATABASE="craft"\nDB_USER="root"\nDB_PASSWORD="secret"\n', + ); + const runner = new MockRunner(async (spec) => { + if (spec.command === "ssh" && spec.args?.at(-1)?.startsWith("cat ")) { + return { + exitCode: 0, + stdout: + 'DB_DRIVER="mysql"\nDB_SERVER="127.0.0.1"\nDB_PORT="3306"\nDB_DATABASE="remote_craft"\nDB_USER="remote"\nDB_PASSWORD="remote-secret"\n', + stderr: "", + }; + } + if (spec.command === "/usr/bin/env" && spec.args?.[0] === "which") { + if (spec.args[1] === "mysql") { + return { exitCode: 1, stdout: "", stderr: "" }; + } + return { exitCode: 0, stdout: `/usr/bin/${spec.args[1]}\n`, stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + const runtime = createRuntime(cwd, createConfig("mysql"), runner); + await expect(buildDatabaseCommands(runtime, "pull")).rejects.toThrow( + "Local database prerequisite missing: mysql is not installed or not on PATH.", + ); + }); + + test("db doctor reports local and remote tool readiness", async () => { + const cwd = await createTempProject(); + tempDirs.push(cwd); + await writeProjectFile( + cwd, + ".env", + 'DB_DRIVER="mysql"\nDB_SERVER="127.0.0.1"\nDB_PORT="3306"\nDB_DATABASE="craft"\nDB_USER="root"\nDB_PASSWORD="secret"\n', + ); + const runner = new MockRunner(async (spec) => { + if (spec.command === "ssh" && spec.args?.at(-1)?.startsWith("cat ")) { + return { + exitCode: 0, + stdout: + 'DB_DRIVER="mysql"\nDB_SERVER="db.internal"\nDB_PORT="3306"\nDB_DATABASE="remote_craft"\nDB_USER="remote"\nDB_PASSWORD="remote-secret"\n', + stderr: "", + }; + } + if (spec.command === "/usr/bin/env" && spec.args?.[0] === "which") { + return { exitCode: 0, stdout: `/usr/bin/${spec.args[1]}\n`, stderr: "" }; + } + if (spec.command === "ssh" && spec.args?.at(-1)?.includes("command -v")) { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); + const runtime = createRuntime(cwd, createConfig("mysql"), runner); + const report = await databaseDoctor(runtime); + expect(report.ok).toBe(true); + expect(report.checks.some((check) => check.id === "db-local-tool-mysqldump")).toBe(true); + expect(report.checks.some((check) => check.id === "db-remote-tool-mysql")).toBe(true); + }); +}); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts new file mode 100644 index 0000000..5111739 --- /dev/null +++ b/tests/doctor.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { inspectProject } from "../src/core/doctor"; +import { cleanupTempProject, createTempProject } from "./helpers"; +import { MockRunner } from "./helpers"; + +const tempDirs: string[] = []; + +afterEach(async () => { + while (tempDirs.length > 0) { + await cleanupTempProject(tempDirs.pop()!); + } +}); + +describe("doctor", () => { + test("fails when tug.toml is missing", async () => { + const cwd = await createTempProject(); + tempDirs.push(cwd); + const report = await inspectProject( + { + cwd, + force: false, + dryRun: false, + verbose: false, + json: false, + interactive: false, + }, + new MockRunner(), + ); + expect(report.ok).toBe(false); + expect(report.checks[0]?.id).toBe("config"); + }); +}); diff --git a/tests/env.test.ts b/tests/env.test.ts new file mode 100644 index 0000000..61c95b3 --- /dev/null +++ b/tests/env.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { resolveLocalDatabaseCredentials } from "../src/core/env"; +import type { TugConfig } from "../src/types"; +import { + cleanupTempProject, + createTempProject, + writeProjectFile, +} from "./helpers"; + +const tempDirs: string[] = []; + +const config: TugConfig = { + project: { disabled_tasks: [] }, + remote: { + host: "example.com", + user: "deploy", + port: 22, + app_path: "/srv/app/current", + env_path: "/srv/app/current/.env", + }, + files: { push: [], pull: [], exclude: [] }, + database: { + engine: "mysql", + local_env_file: ".env", + remote_env_path: "/srv/app/current/.env", + backup_dir: ".tug/backups", + }, + composer: { enabled: true }, +}; + +afterEach(async () => { + while (tempDirs.length > 0) { + await cleanupTempProject(tempDirs.pop()!); + } +}); + +describe("env resolution", () => { + test("resolves Craft-style database variables", async () => { + const cwd = await createTempProject(); + tempDirs.push(cwd); + await writeProjectFile( + cwd, + ".env", + 'DB_DRIVER="mysql"\nDB_SERVER="127.0.0.1"\nDB_PORT="3307"\nDB_DATABASE="craft"\nDB_USER="root"\nDB_PASSWORD="secret"\n', + ); + const credentials = await resolveLocalDatabaseCredentials(cwd, config, { + cwd, + force: false, + dryRun: false, + verbose: false, + json: false, + interactive: false, + }); + expect(credentials.engine).toBe("mysql"); + expect(credentials.port).toBe(3307); + expect(credentials.database).toBe("craft"); + }); + + test("resolves DATABASE_URL", async () => { + const cwd = await createTempProject(); + tempDirs.push(cwd); + await writeProjectFile( + cwd, + ".env", + 'DATABASE_URL="postgres://craft:secret@db.internal:5432/craftdb"\n', + ); + const credentials = await resolveLocalDatabaseCredentials(cwd, config, { + cwd, + force: false, + dryRun: false, + verbose: false, + json: false, + interactive: false, + }); + expect(credentials.engine).toBe("postgres"); + expect(credentials.host).toBe("db.internal"); + expect(credentials.database).toBe("craftdb"); + }); +}); diff --git a/tests/folder.test.ts b/tests/folder.test.ts new file mode 100644 index 0000000..5347490 --- /dev/null +++ b/tests/folder.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { buildFolderPullCommands, buildFolderPushCommands } from "../src/tasks/folder"; +import type { TugConfig } from "../src/types"; +import { MockRunner, createRuntime } from "./helpers"; + +const config: TugConfig = { + project: { disabled_tasks: [] }, + remote: { + host: "example.com", + user: "deploy", + port: 22, + app_path: "/srv/app/current", + env_path: "/srv/app/current/.env", + }, + files: { + push: ["templates", "config"], + pull: ["web/uploads"], + exclude: [".cache"], + }, + database: { + engine: "mysql", + local_env_file: ".env", + remote_env_path: "/srv/app/current/.env", + backup_dir: ".tug/backups", + }, + composer: { enabled: true }, +}; + +describe("folder commands", () => { + test("builds push commands with delete and excludes", () => { + const runtime = createRuntime("/tmp/project", config, new MockRunner()); + const commands = buildFolderPushCommands(runtime); + expect(commands).toHaveLength(2); + expect(commands[0]?.args).toContain("--delete"); + expect(commands[0]?.args).toContain("--exclude=.cache"); + }); + + test("builds pull commands without delete", () => { + const runtime = createRuntime("/tmp/project", config, new MockRunner(), { dryRun: true }); + const commands = buildFolderPullCommands(runtime); + expect(commands[0]?.command).toBe("rsync"); + expect(commands[0]?.args).not.toContain("--delete"); + expect(commands[0]?.args).toContain("--dry-run"); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..0573800 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,69 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { MemoryLogger } from "../src/core/logging"; +import type { + CommandHooks, + CommandResult, + CommandSpec, + LoadedRuntime, + ProcessRunner, + RuntimeOptions, + TugConfig, +} from "../src/types"; + +export class MockRunner implements ProcessRunner { + public calls: CommandSpec[] = []; + + public constructor( + private readonly handler: ( + spec: CommandSpec, + hooks?: CommandHooks, + ) => Promise | CommandResult = async () => ({ + exitCode: 0, + stdout: "", + stderr: "", + }), + ) {} + + async run(spec: CommandSpec, hooks?: CommandHooks): Promise { + this.calls.push(spec); + return this.handler(spec, hooks); + } +} + +export async function createTempProject(): Promise { + return mkdtemp(path.join(os.tmpdir(), "tug-test-")); +} + +export async function cleanupTempProject(cwd: string): Promise { + await rm(cwd, { recursive: true, force: true }); +} + +export async function writeProjectFile(cwd: string, fileName: string, content: string): Promise { + const destination = path.resolve(cwd, fileName); + await writeFile(destination, content, "utf8"); + return destination; +} + +export function createRuntime( + cwd: string, + config: TugConfig, + runner: ProcessRunner, + overrides: Partial = {}, +): LoadedRuntime { + return { + options: { + cwd, + force: true, + dryRun: false, + verbose: false, + json: false, + interactive: false, + ...overrides, + }, + config, + runner, + logger: new MemoryLogger(), + }; +} diff --git a/tests/process.test.ts b/tests/process.test.ts new file mode 100644 index 0000000..ce9d708 --- /dev/null +++ b/tests/process.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { BunProcessRunner } from "../src/core/process"; + +describe("process runner", () => { + test("merges custom env with PATH for spawned commands", async () => { + const runner = new BunProcessRunner(); + const result = await runner.run({ + command: process.env.SHELL ?? "/bin/sh", + args: ["-lc", "printf '%s\n' \"$TUG_TEST_ENV\" && which ls"], + env: { + TUG_TEST_ENV: "1", + }, + stdout: "pipe", + stderr: "pipe", + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("1"); + expect(result.stdout.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..223d44d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": false, + "types": ["bun"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/tug.example.toml b/tug.example.toml new file mode 100644 index 0000000..a27c7c9 --- /dev/null +++ b/tug.example.toml @@ -0,0 +1,24 @@ +[project] +name = "my-craft-site" +disabled_tasks = [] + +[remote] +host = "example.com" +user = "deploy" +port = 22 +app_path = "/srv/users/deploy/apps/site/current" +env_path = "/srv/users/deploy/apps/site/current/.env" + +[files] +push = ["templates", "config", "web/dist"] +pull = ["web/uploads"] +exclude = [".DS_Store", ".git", ".env"] + +[database] +engine = "mysql" +local_env_file = ".env" +remote_env_path = "/srv/users/deploy/apps/site/current/.env" +backup_dir = ".tug/backups" + +[composer] +enabled = true