diff --git a/bun.lock b/bun.lock index b8fcbf7b..5fc002a6 100644 --- a/bun.lock +++ b/bun.lock @@ -30,13 +30,13 @@ "@eslint/js": "10.0.1", "@types/node": "^25.9.3", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", "eslint": "^10.5.0", "fast-check": "4.8.0", "globals": "^17.6.0", "typescript": "^6.0.3", - "vitest": "^4.1.8", + "vitest": "^4.1.9", }, }, "packages/app": { @@ -83,10 +83,10 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.9", "@vitest/eslint-plugin": "^1.6.20", "biome": "npm:@biomejs/biome@^2.5.0", "eslint": "^10.5.0", @@ -96,14 +96,14 @@ "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", - "eslint-plugin-unicorn": "^65.0.1", + "eslint-plugin-unicorn": "^67.0.0", "fast-check": "4.8.0", "globals": "^17.6.0", "jscpd": "^5.0.9", "typescript": "^6.0.3", - "typescript-eslint": "^8.61.0", + "typescript-eslint": "^8.61.1", "vite": "^8.0.16", - "vitest": "^4.1.8", + "vitest": "^4.1.9", "ws": "^8.21.0", }, }, @@ -128,9 +128,9 @@ "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", "@ton-ai-core/vibecode-linter": "^1.0.11", "@types/node": "^25.9.3", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", - "@vitest/coverage-v8": "^4.1.8", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", + "@vitest/coverage-v8": "^4.1.9", "@vitest/eslint-plugin": "^1.6.20", "eslint": "^10.5.0", "eslint-import-resolver-typescript": "^4.4.5", @@ -139,14 +139,14 @@ "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", - "eslint-plugin-unicorn": "^65.0.1", + "eslint-plugin-unicorn": "^67.0.0", "fast-check": "^4.8.0", "globals": "^17.6.0", "jscpd": "^5.0.9", "typescript": "^6.0.3", - "typescript-eslint": "^8.61.0", + "typescript-eslint": "^8.61.1", "vite": "^8.0.16", - "vitest": "^4.1.8", + "vitest": "^4.1.9", }, }, "packages/docker-git-session-sync": { @@ -161,7 +161,7 @@ "@vitejs/plugin-react": "^6.0.2", "typescript": "^6.0.3", "vite": "^8.0.16", - "vitest": "^4.1.8", + "vitest": "^4.1.9", }, }, "packages/lib": { @@ -197,9 +197,9 @@ "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", "@ton-ai-core/vibecode-linter": "^1.0.11", "@types/node": "^25.9.3", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", - "@vitest/coverage-v8": "^4.1.8", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", + "@vitest/coverage-v8": "^4.1.9", "@vitest/eslint-plugin": "^1.6.20", "eslint": "^10.5.0", "eslint-import-resolver-typescript": "^4.4.5", @@ -208,14 +208,14 @@ "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", - "eslint-plugin-unicorn": "^65.0.1", + "eslint-plugin-unicorn": "^67.0.0", "fast-check": "^4.8.0", "globals": "^17.6.0", "jscpd": "^5.0.9", "typescript": "^6.0.3", - "typescript-eslint": "^8.61.0", + "typescript-eslint": "^8.61.1", "vite": "^8.0.16", - "vitest": "^4.1.8", + "vitest": "^4.1.9", }, }, "packages/terminal": { @@ -244,9 +244,9 @@ "@types/node": "^25.9.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", - "@vitest/coverage-v8": "^4.1.8", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", + "@vitest/coverage-v8": "^4.1.9", "@vitest/eslint-plugin": "^1.6.20", "eslint": "^10.5.0", "eslint-import-resolver-typescript": "^4.4.5", @@ -255,16 +255,16 @@ "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", - "eslint-plugin-unicorn": "^65.0.1", + "eslint-plugin-unicorn": "^67.0.0", "fast-check": "^4.8.0", "globals": "^17.6.0", "jscpd": "^5.0.9", "react-dom": "19.2.4", "typescript": "^6.0.3", - "typescript-eslint": "^8.61.0", + "typescript-eslint": "^8.61.1", "vite": "^8.0.16", "vite-tsconfig-paths": "^6.1.1", - "vitest": "^4.1.8", + "vitest": "^4.1.9", }, }, }, @@ -297,7 +297,7 @@ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], @@ -751,25 +751,25 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/type-utils": "8.61.1", "@typescript-eslint/utils": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.1", "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0" } }, "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1" } }, "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/utils": "8.61.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.61.1", "", {}, "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.1", "@typescript-eslint/tsconfig-utils": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w=="], "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], @@ -811,23 +811,23 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.9", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.9", "vitest": "4.1.9" }, "optionalPeers": ["@vitest/browser"] }, "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g=="], "@vitest/eslint-plugin": ["@vitest/eslint-plugin@1.6.20", "", { "dependencies": { "@typescript-eslint/scope-manager": "^8.58.0", "@typescript-eslint/utils": "^8.58.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "*", "eslint": ">=8.57.0", "typescript": ">=5.0.0", "vitest": "*" }, "optionalPeers": ["@typescript-eslint/eslint-plugin", "typescript", "vitest"] }, "sha512-xRwWHFG0Utp6hXtbGiWk4VdKXCGdExD8kbWrrmFEiG5dk8anOJ+vbWbeOa8EbkocKQRTsx7JAWETccZiBgFp/Q=="], - "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="], + "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - "@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="], + "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - "@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="], + "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - "@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="], + "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - "@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], + "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -1085,7 +1085,7 @@ "eslint-plugin-sort-destructure-keys": ["eslint-plugin-sort-destructure-keys@3.0.0", "", { "dependencies": { "natural-compare-lite": "1.4.0" }, "peerDependencies": { "eslint": "10.1.0" } }, "sha512-ian2KEdGi8xZW50SVz9HIP9PDQN4XWeo3Hax3LsDk0ojL+wrwk40az8bKCnt3q2J7I3q5xF2ncZ0arj2q8Ou+A=="], - "eslint-plugin-unicorn": ["eslint-plugin-unicorn@65.0.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "@eslint-community/eslint-utils": "^4.9.1", "change-case": "^5.4.4", "ci-info": "^4.4.0", "core-js-compat": "^3.49.0", "detect-indent": "^7.0.2", "find-up-simple": "^1.0.1", "globals": "^17.4.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", "jsesc": "^3.1.0", "pluralize": "^8.0.0", "regjsparser": "^0.13.0", "semver": "^7.7.4", "strip-indent": "^4.1.1" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-daCrQrgxOoOz2uMPWB3Y3vvv/5q+ncwICI8IjoebiwtW87CaY4tAN5EEiRXTYVnf7qi1v1BGBdHOSnZLV0rx6A=="], + "eslint-plugin-unicorn": ["eslint-plugin-unicorn@67.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "@eslint-community/eslint-utils": "^4.9.1", "browserslist": "^4.28.2", "change-case": "^5.4.4", "ci-info": "^4.4.0", "core-js-compat": "^3.49.0", "detect-indent": "^7.0.2", "find-up-simple": "^1.0.1", "globals": "^17.6.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", "jsesc": "^3.1.0", "pluralize": "^8.0.0", "regjsparser": "^0.13.1", "semver": "^7.8.4", "strip-indent": "^4.1.1" }, "peerDependencies": { "eslint": ">=10.4" } }, "sha512-XEYhNfAFFFYQLq++53iqpgIPFqvjasf5lsUJuCdmd/QvbRfcLbmPTjcTyVEXc5yBudWIg08SSEGgqJtEojk1ug=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "4.3.1", "@types/estree": "1.0.8", "esrecurse": "4.3.0", "estraverse": "5.3.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], @@ -1645,7 +1645,7 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-errors": "1.3.0", "get-proto": "1.0.1", "gopd": "1.2.0", "set-function-name": "2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - "regjsparser": ["regjsparser@0.13.0", "", { "dependencies": { "jsesc": "3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q=="], + "regjsparser": ["regjsparser@0.13.2", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ=="], "repeat-string": ["repeat-string@1.6.1", "", {}, "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w=="], @@ -1771,7 +1771,7 @@ "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], @@ -1811,7 +1811,7 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.61.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.61.0", "@typescript-eslint/parser": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw=="], + "typescript-eslint": ["typescript-eslint@8.61.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.61.1", "@typescript-eslint/parser": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/utils": "8.61.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "1.0.4", "has-bigints": "1.1.0", "has-symbols": "1.1.0", "which-boxed-primitive": "1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], @@ -1843,7 +1843,7 @@ "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], - "vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="], + "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], @@ -1895,6 +1895,8 @@ "zx": ["zx@8.8.5", "", { "bin": { "zx": "build/cli.js" } }, "sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA=="], + "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1905,6 +1907,10 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@changesets/apply-release-plan/detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], "@digitalbazaar/http-client/undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], @@ -1995,6 +2001,8 @@ "@ton-ai-core/vibecode-linter/jscpd": ["jscpd@4.0.8", "", { "dependencies": { "@jscpd/badge-reporter": "4.0.4", "@jscpd/core": "4.0.4", "@jscpd/finder": "4.0.4", "@jscpd/html-reporter": "4.0.4", "@jscpd/tokenizer": "4.0.4", "colors": "1.4.0", "commander": "5.1.0", "fs-extra": "11.3.2", "gitignore-to-glob": "0.3.0", "jscpd-sarif-reporter": "4.0.6" }, "bin": { "jscpd": "bin/jscpd" } }, "sha512-d2VNT/2Hv4dxT2/59He8Lyda4DYOxPRyRG9zBaOpTZAqJCVf2xLrBlZkT8Va6Lo9u3X2qz8Bpq4HrDi4JsrQhA=="], + "@ts-morph/common/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "@types/glob/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], "@types/minimatch/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "5.0.4" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], @@ -2003,7 +2011,9 @@ "@types/ws/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], - "@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "@vitest/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0" } }, "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA=="], + + "@vitest/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA=="], "effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], @@ -2013,6 +2023,8 @@ "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "eslint-import-resolver-typescript/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "eslint-module-utils/@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "debug": "4.4.3" }, "peerDependencies": { "eslint": "10.1.0", "typescript": "5.9.3" } }, "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -2037,6 +2049,8 @@ "eslint-plugin-sort-destructure-keys/eslint": ["eslint@10.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "@eslint-community/regexpp": "4.12.2", "@eslint/config-array": "0.23.3", "@eslint/config-helpers": "0.5.3", "@eslint/core": "1.1.1", "@eslint/plugin-kit": "0.6.1", "@humanfs/node": "0.16.7", "@humanwhocodes/module-importer": "1.0.1", "@humanwhocodes/retry": "0.4.3", "@types/estree": "1.0.8", "ajv": "6.14.0", "cross-spawn": "7.0.6", "debug": "4.4.3", "escape-string-regexp": "4.0.0", "eslint-scope": "9.1.2", "eslint-visitor-keys": "5.0.1", "espree": "11.2.0", "esquery": "1.7.0", "esutils": "2.0.3", "fast-deep-equal": "3.1.3", "file-entry-cache": "8.0.0", "find-up": "5.0.0", "glob-parent": "6.0.2", "ignore": "5.3.2", "imurmurhash": "0.1.4", "is-glob": "4.0.3", "json-stable-stringify-without-jsonify": "1.0.1", "minimatch": "10.2.4", "natural-compare": "1.4.0", "optionator": "0.9.4" }, "optionalDependencies": { "jiti": "2.6.1" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA=="], + "eslint-plugin-unicorn/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -2089,10 +2103,6 @@ "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "1.2.8" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - "vite/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "vitest/vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], - "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -2259,6 +2269,14 @@ "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@vitest/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="], + + "@vitest/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], + + "@vitest/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="], + + "@vitest/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA=="], + "effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "eslint-module-utils/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1" } }, "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg=="], @@ -2353,6 +2371,8 @@ "jscpd-sarif-reporter/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "magicast/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "node-sarif-builder/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "2.0.1" }, "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "node-sarif-builder/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -2361,8 +2381,6 @@ "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "1.0.3" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "vitest/vite/rolldown": ["rolldown@1.0.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -2423,6 +2441,8 @@ "@prover-coder-ai/eslint-plugin-suggest-members/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw=="], + "@prover-coder-ai/eslint-plugin-suggest-members/@typescript-eslint/utils/@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "@prover-coder-ai/eslint-plugin-suggest-members/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "@ton-ai-core/vibecode-linter/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], @@ -2431,6 +2451,12 @@ "@ton-ai-core/vibecode-linter/jscpd/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "@vitest/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA=="], + + "@vitest/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ=="], + + "@vitest/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], + "eslint-module-utils/@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "8.57.1", "@typescript-eslint/types": "8.57.1", "debug": "4.4.3" }, "peerDependencies": { "typescript": "5.9.3" } }, "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg=="], "eslint-module-utils/@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.1", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg=="], @@ -2477,38 +2503,6 @@ "read-pkg-up/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "2.3.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - "vitest/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], - - "vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="], - - "vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="], - - "vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA=="], - - "vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ=="], - - "vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw=="], - - "vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w=="], - - "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ=="], - - "vitest/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A=="], - - "vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ=="], - "@effect/vitest/vitest/vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "@effect/vitest/vitest/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="], @@ -2567,8 +2561,6 @@ "read-pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "2.2.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@effect/vitest/vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "1.7.1", "@emnapi/runtime": "1.7.1", "@tybys/wasm-util": "0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], "@effect/vitest/vitest/vite/rolldown/@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "2.8.1" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], diff --git a/packages/api/package.json b/packages/api/package.json index 542de80d..465809ec 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -44,12 +44,12 @@ "@eslint/js": "10.0.1", "@types/node": "^25.9.3", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", "eslint": "^10.5.0", "fast-check": "4.8.0", "globals": "^17.6.0", "typescript": "^6.0.3", - "vitest": "^4.1.8" + "vitest": "^4.1.9" } } diff --git a/packages/app/package.json b/packages/app/package.json index e494b786..f7ef9ede 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -101,10 +101,10 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.9", "@vitest/eslint-plugin": "^1.6.20", "biome": "npm:@biomejs/biome@^2.5.0", "eslint": "^10.5.0", @@ -114,14 +114,14 @@ "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", - "eslint-plugin-unicorn": "^65.0.1", + "eslint-plugin-unicorn": "^67.0.0", "fast-check": "4.8.0", "globals": "^17.6.0", "jscpd": "^5.0.9", "typescript": "^6.0.3", - "typescript-eslint": "^8.61.0", + "typescript-eslint": "^8.61.1", "vite": "^8.0.16", - "vitest": "^4.1.8", + "vitest": "^4.1.9", "ws": "^8.21.0" } } diff --git a/packages/app/src/docker-git/api-client-auth.ts b/packages/app/src/docker-git/api-client-auth.ts index 2719cd88..ec371b80 100644 --- a/packages/app/src/docker-git/api-client-auth.ts +++ b/packages/app/src/docker-git/api-client-auth.ts @@ -5,9 +5,9 @@ import { Effect } from "effect" import { authStreamMarkerExitCode, type AuthStreamMarkers, - authStreamSucceeded, codexLoginFailureMessage, codexLoginStreamMarkers, + didAuthStreamSucceed, githubLoginFailureMessage, githubLoginStreamMarkers, gitlabLoginFailureMessage, @@ -62,14 +62,14 @@ const requestMarkedAuthStream = ( const output = yield* _(requestTextStream("POST", path, body, writer.writeChunk)) writer.flushVisiblePending() - if (authStreamSucceeded(output, markers)) { + if (didAuthStreamSucceed(output, markers)) { return output } + const exitCode = authStreamMarkerExitCode(output, markers) + const message = failureMessage(output, exitCode) return yield* _( - Effect.fail( - streamFailure("POST", path, failureMessage(output, authStreamMarkerExitCode(output, markers))) - ) + Effect.fail(streamFailure("POST", path, message)) ) }) diff --git a/packages/app/src/docker-git/api-client-create.ts b/packages/app/src/docker-git/api-client-create.ts index b49413f2..76231dd9 100644 --- a/packages/app/src/docker-git/api-client-create.ts +++ b/packages/app/src/docker-git/api-client-create.ts @@ -25,9 +25,9 @@ export const buildCreateProjectRequest = ( containerName: config.containerName, serviceName: config.serviceName, volumeName: config.volumeName, - authorizedKeysPath: resolvedPaths.authorizedKeysContents === undefined - ? resolvedPaths.authorizedKeysPath - : defaultTemplateConfig.authorizedKeysPath, + authorizedKeysPath: (resolvedPaths.authorizedKeysContents === undefined + ? resolvedPaths + : defaultTemplateConfig).authorizedKeysPath, authorizedKeysContents: resolvedPaths.authorizedKeysContents, envGlobalPath: config.envGlobalPath, envProjectPath: config.envProjectPath, @@ -55,6 +55,6 @@ export const buildCreateProjectRequest = ( force: command.force, forceEnv: command.forceEnv, waitForClone: command.waitForClone, - ...(options?.async === true ? { async: true } : {}) + ...((options?.async === true) && { async: true }) } satisfies JsonRequest } diff --git a/packages/app/src/docker-git/api-client-events.ts b/packages/app/src/docker-git/api-client-events.ts index ff3e753f..3f477947 100644 --- a/packages/app/src/docker-git/api-client-events.ts +++ b/packages/app/src/docker-git/api-client-events.ts @@ -206,15 +206,14 @@ export const waitForProjectCreation = ( export const startProjectEventPolling = (projectId: string, initialCursor: number) => Effect.gen(function*(_) { const cursorRef = yield* _(Ref.make(initialCursor)) + const pollSchedule = Schedule.addDelay( + Schedule.forever, + () => projectEventPollInterval + ) const fiber = yield* _( pollProjectEventsOnce(projectId, cursorRef).pipe( Effect.ignore, - Effect.repeat( - Schedule.addDelay( - Schedule.forever, - () => projectEventPollInterval - ) - ), + Effect.repeat(pollSchedule), Effect.fork ) ) diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts index 4996455f..785969b0 100644 --- a/packages/app/src/docker-git/api-client.ts +++ b/packages/app/src/docker-git/api-client.ts @@ -60,9 +60,7 @@ const projectPath = (projectId: string, suffix = ""): string => `/projects/${enc const decodeProjectResponse = (payload: JsonValue) => { const object = asObject(payload) - return object === null - ? decodeProjectDetails(payload) - : decodeProjectDetails(object["project"] ?? payload) + return decodeProjectDetails(object === null ? payload : object["project"] ?? payload) } const decodeProjectsResponse = ( @@ -70,7 +68,7 @@ const decodeProjectsResponse = ( decode: (value: JsonValue) => A | null ): ReadonlyArray => { const object = asObject(payload) - const items = object === null ? asArray(payload) : asArray(object["projects"]) + const items = asArray(object === null ? payload : object["projects"]) return items .map((item) => decode(item)) .filter((value): value is A => value !== null) @@ -93,8 +91,8 @@ const createProjectAsync = ( resolvedPaths: ResolvedCreateRequestPaths ) => Effect.gen(function*(_) { - const createRequest = buildCreateProjectRequest(command, resolvedPaths, { async: true }) - const payload = yield* _(request("POST", "/projects", createRequest)) + const requestBody = buildCreateProjectRequest(command, resolvedPaths, { async: true }) + const payload = yield* _(request("POST", "/projects", requestBody)) const accepted = decodeCreateProjectAccepted(payload) if (accepted === null) { return yield* _(Effect.fail(invalidCreateAcceptedResponse())) @@ -141,8 +139,8 @@ const createProjectWithResolvedPaths = ( return yield* _(createProjectAsync(command, resolvedPaths)) } - const createRequest = buildCreateProjectRequest(command, resolvedPaths) - const projectId = asString(createRequest.outDir) + const requestBody = buildCreateProjectRequest(command, resolvedPaths) + const projectId = asString(requestBody.outDir) const initialCursor = projectId === null ? null : yield* _( @@ -153,13 +151,12 @@ const createProjectWithResolvedPaths = ( const eventPolling = projectId === null || initialCursor === null ? null : yield* _(startProjectEventPolling(projectId, initialCursor)) + const ensureStopped = eventPolling === null + ? Effect.void + : stopProjectEventPolling(eventPolling) const payload = yield* _( - request("POST", "/projects", createRequest).pipe( - Effect.ensuring( - eventPolling === null - ? Effect.void - : stopProjectEventPolling(eventPolling) - ) + request("POST", "/projects", requestBody).pipe( + Effect.ensuring(ensureStopped) ) ) return decodeProjectResponse(payload) @@ -176,10 +173,9 @@ const withProjectEventPolling = ( ) ) const eventPolling = yield* _(startProjectEventPolling(projectId, initialCursor)) + const ensureStopped = stopProjectEventPolling(eventPolling) return yield* _( - effect.pipe( - Effect.ensuring(stopProjectEventPolling(eventPolling)) - ) + effect.pipe(Effect.ensuring(ensureStopped)) ) }) @@ -236,8 +232,8 @@ export const readProjectLogs = (projectId: string) => Effect.map((payload) => readProjectOutput(payload)) ) -export const readContainerTaskSnapshot = (projectId: string, includeDefault: boolean) => - request("GET", projectPath(projectId, `/tasks${includeDefault ? "?includeDefault=true" : ""}`)).pipe( +export const readContainerTaskSnapshot = (projectId: string, shouldIncludeDefault: boolean) => + request("GET", projectPath(projectId, `/tasks${shouldIncludeDefault ? "?includeDefault=true" : ""}`)).pipe( Effect.map((payload) => decodeContainerTaskSnapshot(payload)) ) @@ -275,7 +271,8 @@ export const createAuthTerminalSession = ( export const deleteTerminalSessionByPath = (path: string) => requestVoid("DELETE", path) -export const applyAllProjects = (activeOnly: boolean) => requestVoid("POST", "/projects/apply-all", { activeOnly }) +export const applyAllProjects = (isActiveOnly: boolean) => + requestVoid("POST", "/projects/apply-all", { activeOnly: isActiveOnly }) export const downAllProjects = () => requestVoid("POST", "/projects/down-all") diff --git a/packages/app/src/docker-git/api-http.ts b/packages/app/src/docker-git/api-http.ts index 1c92901d..1875f398 100644 --- a/packages/app/src/docker-git/api-http.ts +++ b/packages/app/src/docker-git/api-http.ts @@ -53,11 +53,11 @@ const readErrorPayload = (body: JsonValue): ApiErrorPayload | undefined => { const details = error["details"] return { - ...(type === null ? {} : { type }), - ...(message === null ? {} : { message }), - ...(provider === null ? {} : { provider }), - ...(command === null ? {} : { command }), - ...(details === undefined ? {} : { details }) + ...(type !== null && { type }), + ...(message !== null && { message }), + ...(provider !== null && { provider }), + ...(command !== null && { command }), + ...(details !== undefined && { details }) } } diff --git a/packages/app/src/docker-git/api-json.ts b/packages/app/src/docker-git/api-json.ts index e91fd4ef..35db25e5 100644 --- a/packages/app/src/docker-git/api-json.ts +++ b/packages/app/src/docker-git/api-json.ts @@ -6,13 +6,9 @@ import { type JsonObject, type JsonValue, JsonValueSchema } from "../shared/json export type { JsonObject, JsonValue } from "../shared/json-schema.js" -export type JsonRequest = - | boolean - | number - | string - | null - | { readonly [key: string]: JsonRequest | undefined } - | ReadonlyArray +export type JsonRequest = boolean | number | string | ReadonlyArray | { + readonly [key: string]: JsonRequest | undefined +} | null const JsonValueFromStringSchema: Schema.Schema = Schema.parseJson(JsonValueSchema) @@ -47,11 +43,12 @@ export const asString = (value: JsonValue | undefined): string | null => typeof const renderGithubStatusLine = (entry: JsonObject): string | null => { const label = asString(entry["label"]) const status = asString(entry["status"]) - const login = asString(entry["login"]) if (label === null || status === null) { return null } + const login = asString(entry["login"]) + if (status === "valid") { return login === null ? `- ${label}: valid (owner unavailable)` diff --git a/packages/app/src/docker-git/browser-frontend-state.ts b/packages/app/src/docker-git/browser-frontend-state.ts index 2d332083..49a33027 100644 --- a/packages/app/src/docker-git/browser-frontend-state.ts +++ b/packages/app/src/docker-git/browser-frontend-state.ts @@ -112,11 +112,11 @@ export const readBrowserFrontendState = ( Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const exists = yield* _(Effect.either(fs.exists(statePath))) - const fileExists = Either.match(exists, { + const isFileExists = Either.match(exists, { onLeft: () => false, onRight: (value) => value }) - if (!fileExists) { + if (!isFileExists) { return null } diff --git a/packages/app/src/docker-git/browser-frontend.ts b/packages/app/src/docker-git/browser-frontend.ts index 0b1e593c..56b9798d 100644 --- a/packages/app/src/docker-git/browser-frontend.ts +++ b/packages/app/src/docker-git/browser-frontend.ts @@ -390,7 +390,7 @@ export const runBrowserFrontendCommandWithOptions = ( if (!decision.shouldStartWeb) { return Effect.log(`docker-git browser frontend is already running at http://${decision.host}:${decision.port}/`) } - return options.daemon ? runBrowserFrontendDaemon(decision) : runBrowserFrontend(decision) + return (options.daemon ? runBrowserFrontendDaemon : runBrowserFrontend)(decision) }) ) diff --git a/packages/app/src/docker-git/cli/parser-clone.ts b/packages/app/src/docker-git/cli/parser-clone.ts index 7a890410..5accc845 100644 --- a/packages/app/src/docker-git/cli/parser-clone.ts +++ b/packages/app/src/docker-git/cli/parser-clone.ts @@ -43,8 +43,8 @@ export const parseClone = (args: ReadonlyArray): Either.Either ({ _tag: "Open", - ...(parts.projectRef === undefined ? {} : { projectRef: parts.projectRef }), - ...(parts.projectDir === undefined ? {} : { projectDir: parts.projectDir }) + ...(parts.projectRef !== undefined && { projectRef: parts.projectRef }), + ...(parts.projectDir !== undefined && { projectDir: parts.projectDir }) }) // CHANGE: parse open as a distinct selector-based command @@ -42,12 +42,9 @@ export const parseOpen = (args: ReadonlyArray): Either.Either Either.right( buildOpenCommand({ - ...(trimToUndefined(raw.projectDir) === undefined - ? {} - : { projectDir: trimToUndefined(raw.projectDir) }), - ...(trimToUndefined(raw.containerName ?? raw.repoUrl ?? positionalRef) === undefined - ? {} - : { projectRef: trimToUndefined(raw.containerName ?? raw.repoUrl ?? positionalRef) }) + ...(trimToUndefined(raw.projectDir) !== undefined && { projectDir: trimToUndefined(raw.projectDir) }), + ...(trimToUndefined(raw.containerName ?? raw.repoUrl ?? positionalRef) !== undefined && + { projectRef: trimToUndefined(raw.containerName ?? raw.repoUrl ?? positionalRef) }) }) )) } diff --git a/packages/app/src/docker-git/cli/parser-scrap.ts b/packages/app/src/docker-git/cli/parser-scrap.ts index c86dd9bf..93d5ea80 100644 --- a/packages/app/src/docker-git/cli/parser-scrap.ts +++ b/packages/app/src/docker-git/cli/parser-scrap.ts @@ -48,13 +48,13 @@ const makeScrapExportCommand = (projectDir: string, archivePath: string, mode: " const makeScrapImportCommand = ( projectDir: string, archivePath: string, - wipe: boolean, + shouldWipe: boolean, mode: "session" ): Command => ({ _tag: "ScrapImport", projectDir, archivePath, - wipe, + wipe: shouldWipe, mode }) diff --git a/packages/app/src/docker-git/cli/parser-shared.ts b/packages/app/src/docker-git/cli/parser-shared.ts index e48171fe..67829d8f 100644 --- a/packages/app/src/docker-git/cli/parser-shared.ts +++ b/packages/app/src/docker-git/cli/parser-shared.ts @@ -62,7 +62,7 @@ export const parsePositiveInt = ( option: string, raw: string ): Either.Either => { - const value = Number.parseInt(raw, 10) + const value = Math.trunc(Number(raw)) if (!Number.isFinite(value) || value <= 0) { const error: ParseError = { _tag: "InvalidOption", diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index f3e4df62..b3fb68a5 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -46,8 +46,8 @@ const parseBrowser = (args: ReadonlyArray): Either.Either): Either.Either => { - const activeOnly = args.includes("--active") - const command: Command = { _tag: "ApplyAll", activeOnly } + const isActiveOnly = args.includes("--active") + const command: Command = { _tag: "ApplyAll", activeOnly: isActiveOnly } return Either.right(command) } diff --git a/packages/app/src/docker-git/cli/read-command.ts b/packages/app/src/docker-git/cli/read-command.ts index fc801e72..a3b02450 100644 --- a/packages/app/src/docker-git/cli/read-command.ts +++ b/packages/app/src/docker-git/cli/read-command.ts @@ -19,7 +19,7 @@ const applyControllerResourceLimitEnv = ( ): Effect.Effect> => Effect.sync(() => { const assignments = controllerResourceLimitEnvAssignments(parsed.controllerResourceLimits) - const forceRecreate = shouldForceRecreateForControllerResourceLimitIntent(parsed.controllerResourceLimits, { + const isForceRecreate = shouldForceRecreateForControllerResourceLimitIntent(parsed.controllerResourceLimits, { cpuLimit: process.env[controllerCpuLimitEnvKey], ramLimit: process.env[controllerMemoryLimitEnvKey], pidsLimit: process.env[controllerPidsLimitEnvKey] @@ -29,7 +29,7 @@ const applyControllerResourceLimitEnv = ( process.env[assignment.key] = assignment.value } - process.env[controllerResourceLimitsForceRecreateEnvKey] = forceRecreate ? "1" : "0" + process.env[controllerResourceLimitsForceRecreateEnvKey] = isForceRecreate ? "1" : "0" return parsed.args }) diff --git a/packages/app/src/docker-git/controller-compose-runtime.ts b/packages/app/src/docker-git/controller-compose-runtime.ts index dfb29704..a08670f0 100644 --- a/packages/app/src/docker-git/controller-compose-runtime.ts +++ b/packages/app/src/docker-git/controller-compose-runtime.ts @@ -54,8 +54,8 @@ export const resolveControllerRuntimeOverlayPath = ( path.dirname(composePath), overlayFileName ) - const exists = yield* _(fs.exists(runtimeOverlayPath).pipe(Effect.mapError(mapComposePathError))) - return exists + const isExists = yield* _(fs.exists(runtimeOverlayPath).pipe(Effect.mapError(mapComposePathError))) + return isExists ? runtimeOverlayPath : yield* _( Effect.fail( diff --git a/packages/app/src/docker-git/controller-compose.ts b/packages/app/src/docker-git/controller-compose.ts index bb02853c..d2bc191d 100644 --- a/packages/app/src/docker-git/controller-compose.ts +++ b/packages/app/src/docker-git/controller-compose.ts @@ -184,16 +184,16 @@ export const ensureSkillerSubmoduleInitialized = ( const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) const packagePath = path.join(rootDir, skillerPackagePath) - const existsBeforeInit = yield* _(fs.exists(packagePath).pipe(Effect.mapError(mapSkillerPathError))) - if (existsBeforeInit) { + const isExistsBeforeInit = yield* _(fs.exists(packagePath).pipe(Effect.mapError(mapSkillerPathError))) + if (isExistsBeforeInit) { return } yield* _(Effect.log("Initializing Skiller submodule for docker-git controller build.")) yield* _(runSkillerSubmoduleInit(rootDir)) - const existsAfterInit = yield* _(fs.exists(packagePath).pipe(Effect.mapError(mapSkillerPathError))) - if (existsAfterInit) { + const isExistsAfterInit = yield* _(fs.exists(packagePath).pipe(Effect.mapError(mapSkillerPathError))) + if (isExistsAfterInit) { return } @@ -231,8 +231,8 @@ const requireGpuOverlayPath = ( const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) const gpuOverlayPath = path.join(path.dirname(composePath), "docker-compose.gpu.yml") - const exists = yield* _(fs.exists(gpuOverlayPath).pipe(Effect.mapError(mapComposePathError))) - return exists + const isExists = yield* _(fs.exists(gpuOverlayPath).pipe(Effect.mapError(mapComposePathError))) + return isExists ? gpuOverlayPath : yield* _( Effect.fail( diff --git a/packages/app/src/docker-git/controller-docker-diagnostics.ts b/packages/app/src/docker-git/controller-docker-diagnostics.ts index 06d558d9..5a5fb92b 100644 --- a/packages/app/src/docker-git/controller-docker-diagnostics.ts +++ b/packages/app/src/docker-git/controller-docker-diagnostics.ts @@ -17,7 +17,7 @@ export type DockerProbeOutcome = { const lowercase = (text: string): string => text.toLowerCase() -const containsAny = (haystack: string, needles: ReadonlyArray): boolean => +const hasAny = (haystack: string, needles: ReadonlyArray): boolean => needles.some((needle) => haystack.includes(needle)) const isCliMissingExitCode = (exitCode: number): boolean => exitCode === 127 @@ -44,15 +44,15 @@ const daemonDownMarkers: ReadonlyArray = [ export const classifyDockerProbeFailure = (outcome: DockerProbeOutcome): DockerProbeFailureKind => { const normalized = lowercase(outcome.stderr) - if (containsAny(normalized, permissionMarkers)) { + if (hasAny(normalized, permissionMarkers)) { return "socket-permission-denied" } - if (isCliMissingExitCode(outcome.exitCode) && containsAny(normalized, cliMissingMarkers)) { + if (isCliMissingExitCode(outcome.exitCode) && hasAny(normalized, cliMissingMarkers)) { return "docker-cli-missing" } - if (containsAny(normalized, daemonDownMarkers)) { + if (hasAny(normalized, daemonDownMarkers)) { return "daemon-unreachable" } diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index 5f695b54..a0eb032d 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -107,17 +107,14 @@ export const resolveDockerCommand = (): Effect.Effect< } const dockerHostRaw = process.env["DOCKER_HOST"]?.trim() ?? "" + const accessDeniedMessage = renderDockerAccessDeniedMessage({ + directProbe, + sudoProbe, + apiBaseUrl: resolveConfiguredApiBaseUrl(), + dockerHost: dockerHostRaw.length > 0 ? dockerHostRaw : null + }) return yield* _( - Effect.fail( - controllerBootstrapError( - renderDockerAccessDeniedMessage({ - directProbe, - sudoProbe, - apiBaseUrl: resolveConfiguredApiBaseUrl(), - dockerHost: dockerHostRaw.length > 0 ? dockerHostRaw : null - }) - ) - ) + Effect.fail(controllerBootstrapError(accessDeniedMessage)) ) }) @@ -256,7 +253,7 @@ const mapDockerCaptureError = // QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" // REF: CodeRabbit PR #344 review 4349265315 // SOURCE: n/a -// FORMAT THEOREM: includeOutput -> failure_with_output; !includeOutput -> base_failure +// FORMAT THEOREM: shouldIncludeOutput -> failure_with_output; !shouldIncludeOutput -> base_failure // PURITY: CORE // EFFECT: n/a // INVARIANT: both modes preserve headline, command and exit code @@ -268,14 +265,14 @@ const mapDockerCaptureError = * @param invocation - Resolved Docker invocation. * @param exitCode - Process exit code. * @param output - Combined stdout/stderr from the process. - * @param includeOutput - Whether the message should include captured process output. + * @param shouldIncludeOutput - Whether the message should include captured process output. * @returns Stable Docker failure message. * * @pure true * @effect n/a * @invariant Base diagnostics always include command and exit code. * @precondition `exitCode` is the observed process exit code. - * @postcondition Captured output appears only when `includeOutput` is true and output is non-empty. + * @postcondition Captured output appears only when `shouldIncludeOutput` is true and output is non-empty. * @complexity O(n) where n = |output|. * @throws Never */ @@ -284,9 +281,9 @@ const formatDockerCaptureFailure = ( invocation: DockerInvocation, exitCode: number, output: string, - includeOutput: boolean + shouldIncludeOutput: boolean ): string => - includeOutput + shouldIncludeOutput ? formatDockerInvocationFailureWithOutput(`${label} failed.`, invocation, exitCode, output) : formatDockerInvocationFailure(`${label} failed.`, invocation, exitCode) @@ -305,7 +302,7 @@ const formatDockerCaptureFailure = ( * * @param args - Docker CLI arguments after the executable. * @param label - Operation label used in diagnostics. - * @param includeOutput - Whether non-zero exit diagnostics include captured stdout/stderr. + * @param shouldIncludeOutput - Whether non-zero exit diagnostics include captured stdout/stderr. * @returns Effect containing stdout on success. * * @pure false @@ -319,7 +316,7 @@ const formatDockerCaptureFailure = ( const runDockerCaptureWithOutputMode = ( args: ReadonlyArray, label: string, - includeOutput: boolean + shouldIncludeOutput: boolean ): Effect.Effect => resolveDockerInvocation(args).pipe( Effect.flatMap((invocation) => @@ -331,7 +328,7 @@ const runDockerCaptureWithOutputMode = ( }, [0], (exitCode, output) => - controllerBootstrapError(formatDockerCaptureFailure(label, invocation, exitCode, output, includeOutput)) + controllerBootstrapError(formatDockerCaptureFailure(label, invocation, exitCode, output, shouldIncludeOutput)) ) ), Effect.mapError(mapDockerCaptureError(label)) @@ -379,10 +376,11 @@ export const runCompose = ( ): Effect.Effect => Effect.gen(function*(_) { const dockerCommand = yield* _(resolveDockerCommand()) + const composeFiles = yield* _(ControllerCompose.resolveControllerComposeFiles()) const invocation = buildDockerInvocation(dockerCommand, [ "compose", ...ControllerCompose.controllerComposeProjectArgs, - ...ControllerCompose.composeFilesToArgs(yield* _(ControllerCompose.resolveControllerComposeFiles())), + ...ControllerCompose.composeFilesToArgs(composeFiles), ...args ]) const exitCode = yield* _( @@ -402,12 +400,13 @@ export const runCompose = ( return } + const failureMessage = formatDockerInvocationFailure( + "Failed to start docker-git controller.", + invocation, + exitCode + ) return yield* _( - Effect.fail( - controllerBootstrapError( - formatDockerInvocationFailure("Failed to start docker-git controller.", invocation, exitCode) - ) - ) + Effect.fail(controllerBootstrapError(failureMessage)) ) }) diff --git a/packages/app/src/docker-git/controller-health.ts b/packages/app/src/docker-git/controller-health.ts index 24a7911e..53a2f3a8 100644 --- a/packages/app/src/docker-git/controller-health.ts +++ b/packages/app/src/docker-git/controller-health.ts @@ -81,7 +81,7 @@ const findReachableHealthProbe = ( if (Either.isLeft(healthy)) { continue } - if (matchesExpectedRevision(healthy.right, expectedRevision)) { + if (hasExpectedRevision(healthy.right, expectedRevision)) { return healthy.right } mismatches.push(describeRevisionMismatch(healthy.right)) @@ -101,7 +101,7 @@ const findReachableHealthProbeOrNull = ( }) ) -const matchesExpectedRevision = ( +const hasExpectedRevision = ( probe: HealthProbeResult, expectedRevision: string | undefined ): boolean => expectedRevision === undefined || probe.revision === expectedRevision @@ -113,12 +113,12 @@ const noMatchingHealthProbeError = ( expectedRevision: string | undefined, mismatches: ReadonlyArray ): ControllerBootstrapError => - expectedRevision !== undefined && mismatches.length > 0 - ? controllerBootstrapError( - `No docker-git controller endpoint with revision ${expectedRevision} responded. ` + + controllerBootstrapError( + expectedRevision !== undefined && mismatches.length > 0 + ? `No docker-git controller endpoint with revision ${expectedRevision} responded. ` + `Reachable mismatched controllers: ${mismatches.join(", ")}.` - ) - : controllerBootstrapError("No docker-git controller endpoint responded to /health.") + : "No docker-git controller endpoint responded to /health." + ) export const findReachableApiBaseUrlWithHttpClient = ( candidateUrls: ReadonlyArray, diff --git a/packages/app/src/docker-git/controller-resource-limits-shell.ts b/packages/app/src/docker-git/controller-resource-limits-shell.ts index 9298617c..d84d9a98 100644 --- a/packages/app/src/docker-git/controller-resource-limits-shell.ts +++ b/packages/app/src/docker-git/controller-resource-limits-shell.ts @@ -63,16 +63,11 @@ export const prepareControllerResourceLimitEnv = (): Effect.Effect => Effect.gen(function*(_) { const absolutePath = path.join(rootDir, relativePath) - const exists = yield* _(fs.exists(absolutePath)) - if (!exists) { + const isExists = yield* _(fs.exists(absolutePath)) + if (!isExists) { hashMissingPath(chunks, relativePath) return } @@ -148,10 +148,10 @@ export const parseControllerRevisionLabelOutput = (output: string): string | nul } export const shouldForceRecreateController = ( - controllerExists: boolean, + hasController: boolean, localRevision: string, currentRevision: string | null -): boolean => controllerExists && currentRevision !== localRevision +): boolean => hasController && currentRevision !== localRevision // CHANGE: compute a deterministic revision fingerprint for the local controller source // WHY: host CLI must rebuild the controller when local API/lib sources changed, even if /health still responds diff --git a/packages/app/src/docker-git/controller.ts b/packages/app/src/docker-git/controller.ts index 824243a4..feaa5f47 100644 --- a/packages/app/src/docker-git/controller.ts +++ b/packages/app/src/docker-git/controller.ts @@ -26,7 +26,7 @@ import type { ControllerBootstrapError } from "./host-errors.js" export type { ControllerRuntime } from "./controller-docker.js" export { buildApiBaseUrlCandidates, isRemoteDockerHost } from "./controller-reachability.js" -let selectedApiBaseUrl: string | undefined +const apiBaseUrlCache: { selected: string | undefined } = { selected: undefined } const controllerBootstrapError = (message: string): ControllerBootstrapError => ({ _tag: "ControllerBootstrapError", @@ -36,44 +36,39 @@ const controllerBootstrapError = (message: string): ControllerBootstrapError => type ControllerEffect = Effect.Effect const rememberSelectedApiBaseUrl = (value: string): void => { - selectedApiBaseUrl = trimTrailingSlashes(value) + apiBaseUrlCache.selected = trimTrailingSlashes(value) } export const resolveApiBaseUrl = (): string => - resolveExplicitApiBaseUrl() ?? selectedApiBaseUrl ?? resolveConfiguredApiBaseUrl() + resolveExplicitApiBaseUrl() ?? apiBaseUrlCache.selected ?? resolveConfiguredApiBaseUrl() const waitForReachableApiBaseUrl = ( candidateUrls: ReadonlyArray, currentContainerNetworks: DockerNetworkIps, controllerNetworks: DockerNetworkIps, expectedRevision: string | undefined -): ControllerEffect => - pipe( +): ControllerEffect => { + const retrySchedule = Schedule.addDelay(Schedule.recurs(30), () => Duration.seconds(2)) + return pipe( findReachableApiBaseUrl(candidateUrls, expectedRevision), - Effect.retry( - Schedule.addDelay(Schedule.recurs(30), () => Duration.seconds(2)) - ), + Effect.retry(retrySchedule), Effect.matchEffect({ onFailure: (error) => Effect.gen(function*(_) { const diagnostics = yield* _( collectReachabilityDiagnostics(candidateUrls, currentContainerNetworks, controllerNetworks) ) - return yield* _( - Effect.fail( - controllerBootstrapError( - [ - "docker-git controller did not become reachable.", - error.message, - diagnostics - ].join("\n") - ) - ) - ) + const message = [ + "docker-git controller did not become reachable.", + error.message, + diagnostics + ].join("\n") + return yield* _(Effect.fail(controllerBootstrapError(message))) }), onSuccess: (apiBaseUrl) => Effect.succeed(apiBaseUrl) }) ) +} const failIfRemoteDockerWithoutApiUrl = ( currentContainerNetworks: DockerNetworkIps @@ -143,16 +138,16 @@ const loadControllerBootstrapContext = (): ControllerEffect => if (explicitApiBaseUrl !== undefined) { const reachableBeforeDocker = yield* _( findReachableDirectHealthProbe({ - cachedApiBaseUrl: selectedApiBaseUrl, + cachedApiBaseUrl: apiBaseUrlCache.selected, defaultLocalApiBaseUrl, explicitApiBaseUrl }) @@ -278,10 +273,10 @@ export const ensureControllerReady = (): ControllerEffect => } const localControllerRevision = yield* _(ControllerDocker.prepareLocalControllerRevision()) - const forceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits() + const isForceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits() const reachableBeforeDocker = yield* _( findReachableDirectHealthProbe({ - cachedApiBaseUrl: selectedApiBaseUrl, + cachedApiBaseUrl: apiBaseUrlCache.selected, defaultLocalApiBaseUrl, explicitApiBaseUrl, expectedRevision: localControllerRevision @@ -290,7 +285,7 @@ export const ensureControllerReady = (): ControllerEffect => if ( reachableBeforeDocker !== null && reachableBeforeDocker.revision === localControllerRevision && - !forceRecreateForResourceLimits + !isForceRecreateForResourceLimits ) { rememberSelectedApiBaseUrl(reachableBeforeDocker.apiBaseUrl) return @@ -298,8 +293,8 @@ export const ensureControllerReady = (): ControllerEffect => const bootstrapContext = yield* _(loadControllerBootstrapContext()) yield* _(failIfRemoteDockerWithoutApiUrl(bootstrapContext.currentContainerNetworks)) - const reusedExistingController = yield* _(reuseReachableControllerIfPossible(bootstrapContext)) - if (reusedExistingController) { + const isReusedExistingController = yield* _(reuseReachableControllerIfPossible(bootstrapContext)) + if (isReusedExistingController) { return } yield* _(startAndRememberController(bootstrapContext)) @@ -315,12 +310,18 @@ export const restartController = (): ControllerEffect => const bootstrapContext = yield* _(loadControllerBootstrapContext()) yield* _(failIfRemoteDockerWithoutApiUrl(bootstrapContext.currentContainerNetworks)) - const forceRecreateController = true - const buildController = shouldBuildControllerImage({ + const isForceRecreateController = true + const isBuildController = shouldBuildControllerImage({ currentControllerRevision: bootstrapContext.currentControllerRevision, currentImageRevision: bootstrapContext.currentImageRevision, - forceRecreateController, + forceRecreateController: isForceRecreateController, localControllerRevision: bootstrapContext.localControllerRevision }) - yield* _(startAndRememberController({ ...bootstrapContext, buildController, forceRecreateController })) + yield* _( + startAndRememberController({ + ...bootstrapContext, + buildController: isBuildController, + forceRecreateController: isForceRecreateController + }) + ) }) diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts index 0a015881..291450c4 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts @@ -13,7 +13,7 @@ import { const parsePort = (value: string): Either.Either => { const parsed = Number(value) - if (!Number.isInteger(parsed)) { + if (!Number.isSafeInteger(parsed)) { return Either.left({ _tag: "InvalidOption", option: "--ssh-port", @@ -35,14 +35,14 @@ const isAsciiLetterCode = (code: number): boolean => (code >= 65 && code <= 90) const isPathSeparator = (value: string | undefined): boolean => value === "/" || value === "\\" const rootPathLength = (value: string): number => { - if (isPathSeparator(value[0])) { + if (isPathSeparator(value.at(0))) { return 1 } if ( value.length >= 3 && isAsciiLetterCode(value.codePointAt(0) ?? 0) && - value[1] === ":" && - isPathSeparator(value[2]) + value.at(1) === ":" && + isPathSeparator(value.at(2)) ) { return 3 } diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts index c27c77c1..45f04f9c 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts @@ -48,7 +48,7 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either" + // normal keypad mode - "\u001B[?1000l" + // disable mouse click tracking - "\u001B[?1002l" + // disable mouse drag tracking - "\u001B[?1003l" + // disable any-event mouse tracking - "\u001B[?1005l" + // disable UTF-8 mouse mode - "\u001B[?1006l" + // disable SGR mouse mode - "\u001B[?1015l" + // disable urxvt mouse mode - "\u001B[?1007l" + // disable alternate scroll mode - "\u001B[?1004l" + // disable focus reporting - "\u001B[?2004l" + // disable bracketed paste - "\u001B[>4;0m" + // disable xterm modifyOtherKeys - "\u001B[>4m" + // reset xterm modifyOtherKeys - "\u001B[" + // normal keypad mode + "\u{1B}[?1000l" + // disable mouse click tracking + "\u{1B}[?1002l" + // disable mouse drag tracking + "\u{1B}[?1003l" + // disable any-event mouse tracking + "\u{1B}[?1005l" + // disable UTF-8 mouse mode + "\u{1B}[?1006l" + // disable SGR mouse mode + "\u{1B}[?1015l" + // disable urxvt mouse mode + "\u{1B}[?1007l" + // disable alternate scroll mode + "\u{1B}[?1004l" + // disable focus reporting + "\u{1B}[?2004l" + // disable bracketed paste + "\u{1B}[>4;0m" + // disable xterm modifyOtherKeys + "\u{1B}[>4m" + // reset xterm modifyOtherKeys + "\u{1B}[ => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) - const wroteTty = yield* _(succeeds(fs.writeFileString(controllingTtyPath, terminalSaneEscape))) - if (wroteTty) { + const isWroteTty = yield* _(succeeds(fs.writeFileString(controllingTtyPath, terminalSaneEscape))) + if (isWroteTty) { return true } @@ -156,9 +156,9 @@ export const repairInteractiveTerminal = ( return Effect.gen(function*(_) { yield* _(disableRawMode()) - const sane = yield* _(runSttySane()) - const wroteReset = sane ? yield* _(writeTerminalReset(fallbackWrite)) : false - if (!wroteReset) { + const isSane = yield* _(runSttySane()) + const isWroteReset = isSane ? yield* _(writeTerminalReset(fallbackWrite)) : false + if (!isWroteReset) { yield* _(writeTerminalReset(fallbackWrite)) } }) @@ -173,8 +173,8 @@ const restoreTerminalState = ( return Effect.gen(function*(_) { yield* _(disableRawMode()) - const restored = snapshot === null ? false : yield* _(restoreSttySnapshot(snapshot)) - if (!restored) { + const isRestored = snapshot === null ? false : yield* _(restoreSttySnapshot(snapshot)) + if (!isRestored) { yield* _(runSttySane()) } yield* _(writeTerminalReset()) @@ -200,6 +200,7 @@ export const withPreservedTerminalState = ( Effect.gen(function*(_) { const snapshot = yield* _(snapshotTerminalState()) yield* _(ensureTerminalCursorVisible()) - return yield* _(use.pipe(Effect.ensuring(restoreTerminalState(snapshot)))) + const restore = restoreTerminalState(snapshot) + return yield* _(use.pipe(Effect.ensuring(restore))) }) /* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/frontend-lib/usecases/path-helpers.ts b/packages/app/src/docker-git/frontend-lib/usecases/path-helpers.ts index 277131a5..450c6070 100644 --- a/packages/app/src/docker-git/frontend-lib/usecases/path-helpers.ts +++ b/packages/app/src/docker-git/frontend-lib/usecases/path-helpers.ts @@ -35,7 +35,7 @@ const expandHome = (value: string, home: string | null): string => { const trimTrailingSlash = (value: string): string => { let end = value.length while (end > 0) { - if (end === 1 && value[0] === "/") { + if (end === 1 && value.at(0) === "/") { break } if (end === 3 && /^[a-z]:[\\/]/iu.test(value.slice(0, end))) { @@ -109,8 +109,8 @@ export const findExistingUpwards = ( for (let depth = 0; depth <= maxDepth; depth += 1) { const candidate = path.join(current, fileName) - const exists = yield* _(fs.exists(candidate)) - if (exists) { + const isExists = yield* _(fs.exists(candidate)) + if (isExists) { return candidate } diff --git a/packages/app/src/docker-git/host-ssh.ts b/packages/app/src/docker-git/host-ssh.ts index 5b1cbd93..43cb553c 100644 --- a/packages/app/src/docker-git/host-ssh.ts +++ b/packages/app/src/docker-git/host-ssh.ts @@ -22,13 +22,13 @@ export const autoOpenProjectSsh = ( project: ApiProjectDetails | null ) => Effect.gen(function*(_) { - const autoOpenSsh = yield* _( + const isAutoOpenSsh = yield* _( shouldAutoOpenSsh({ shouldOpen: shouldOpenSsh(command), runUp: command.runUp }) ) - if (!autoOpenSsh) { + if (!isAutoOpenSsh) { return } diff --git a/packages/app/src/docker-git/menu-actions.ts b/packages/app/src/docker-git/menu-actions.ts index efd79da2..930da9ed 100644 --- a/packages/app/src/docker-git/menu-actions.ts +++ b/packages/app/src/docker-git/menu-actions.ts @@ -28,11 +28,11 @@ import { type MenuAction, type MenuEnv, type MenuRunner, type MenuState, type Me // INVARIANT: menu selection runs exactly one action // COMPLEXITY: O(1) per keypress -export type MenuContext = { +export type MenuContext = MenuViewContext & { readonly state: MenuState readonly runner: MenuRunner readonly exit: () => void -} & MenuViewContext +} export type MenuSelectionContext = MenuContext & { readonly selected: number @@ -57,21 +57,20 @@ const runWithSuspendedTui = ( context: MenuContext, label: string ) => { + const announceStart = Effect.sync(() => { + context.setMessage(`${label}...`) + }) + const suspendedEffect = withSuspendedTui(effect, { + onError: (error) => writeErrorAndPause(renderMenuError(error)) + }) + const announceFinish = Effect.sync(() => { + context.setMessage(`${label} finished.`) + }) context.runner.runEffect( pipe( - Effect.sync(() => { - context.setMessage(`${label}...`) - }), - Effect.zipRight( - withSuspendedTui(effect, { - onError: (error) => writeErrorAndPause(renderMenuError(error)) - }) - ), - Effect.tap(() => - Effect.sync(() => { - context.setMessage(`${label} finished.`) - }) - ), + announceStart, + Effect.zipRight(suspendedEffect), + Effect.tap(() => announceFinish), Effect.asVoid ) ) diff --git a/packages/app/src/docker-git/menu-auth-effects.ts b/packages/app/src/docker-git/menu-auth-effects.ts index 3a3bf8a6..57110455 100644 --- a/packages/app/src/docker-git/menu-auth-effects.ts +++ b/packages/app/src/docker-git/menu-auth-effects.ts @@ -11,7 +11,7 @@ type AuthPromptView = Extract type AuthEffectContext = MenuViewContext & { readonly runner: MenuRunner - readonly setSshActive: (active: boolean) => void + readonly setSshActive: (isActive: boolean) => void readonly setSkipInputs: (update: (value: number) => number) => void readonly cwd: string } @@ -114,10 +114,12 @@ export const runAuthPromptEffect = ( ), Effect.ensuring( Effect.sync(() => { - if (options.suspendTui) { - context.setSshActive(false) - context.setSkipInputs(() => 2) + if (!options.suspendTui) { + return } + + context.setSshActive(false) + context.setSkipInputs(() => 2) }) ), Effect.asVoid diff --git a/packages/app/src/docker-git/menu-auth-helpers.ts b/packages/app/src/docker-git/menu-auth-helpers.ts index 515d8fe9..09a3f13a 100644 --- a/packages/app/src/docker-git/menu-auth-helpers.ts +++ b/packages/app/src/docker-git/menu-auth-helpers.ts @@ -19,7 +19,7 @@ type CredentialDirectoryCounterInput = { const ignoredAuthAccountEntries: ReadonlySet = new Set([".image"]) -const credentialCount = (connected: boolean): number => connected ? 1 : 0 +const credentialCount = (isConnected: boolean): number => isConnected ? 1 : 0 export const hasCodexAccountCredentials = ( fs: FileSystem.FileSystem, @@ -44,8 +44,8 @@ const countCredentialDirectories = ({ if (info === null || info.type !== "Directory") { continue } - const connected = yield* _(hasCredentials(fs, accountPath), Effect.orElseSucceed(() => false)) - if (connected) { + const isConnected = yield* _(hasCredentials(fs, accountPath), Effect.orElseSucceed(() => false)) + if (isConnected) { count += 1 } } diff --git a/packages/app/src/docker-git/menu-auth.ts b/packages/app/src/docker-git/menu-auth.ts index c8cd42fe..05ec8ddb 100644 --- a/packages/app/src/docker-git/menu-auth.ts +++ b/packages/app/src/docker-git/menu-auth.ts @@ -29,7 +29,7 @@ type AuthContext = MenuViewContext & { } type AuthInputContext = AuthContext & { - readonly setSshActive: (active: boolean) => void + readonly setSshActive: (isActive: boolean) => void readonly setSkipInputs: (update: (value: number) => number) => void } diff --git a/packages/app/src/docker-git/menu-create-choices.ts b/packages/app/src/docker-git/menu-create-choices.ts index 34121f2a..98807a56 100644 --- a/packages/app/src/docker-git/menu-create-choices.ts +++ b/packages/app/src/docker-git/menu-create-choices.ts @@ -3,7 +3,7 @@ import { Either } from "effect" import { type GpuMode, isGpuMode, type ParseError } from "./frontend-lib/core/domain.js" import { createParseError } from "./menu-create-errors.js" -export const renderExplicitBooleanChoice = (value: boolean): string => value ? "Y" : "N" +export const renderExplicitBooleanChoice = (isAffirmative: boolean): string => isAffirmative ? "Y" : "N" export const parseBooleanChoice = (input: string): boolean | null => { const normalized = input.trim().toLowerCase() @@ -54,4 +54,4 @@ export const parseGpuInput = ( return Either.left(createParseError("gpu must be one of: none, all, yes, no")) } -export const parseYesDefault = (input: string, fallback: boolean): boolean => parseBooleanChoice(input) ?? fallback +export const isYesDefault = (input: string, isFallback: boolean): boolean => parseBooleanChoice(input) ?? isFallback diff --git a/packages/app/src/docker-git/menu-create-command-parse.ts b/packages/app/src/docker-git/menu-create-command-parse.ts index 0574e0de..c056bdd5 100644 --- a/packages/app/src/docker-git/menu-create-command-parse.ts +++ b/packages/app/src/docker-git/menu-create-command-parse.ts @@ -223,8 +223,8 @@ export const parseRepoStepInput = ( const repoUrl = raw.repoUrl ?? positionalRepoUrl ?? "" const command = yield* _(buildCreateCommand({ ...raw, - ...(repoUrl.length > 0 ? { repoUrl } : {}), - ...(raw.outDir === undefined ? { outDir: resolveDefaultOutDir(context, repoUrl) } : {}) + ...((repoUrl.length > 0) && { repoUrl }), + ...((raw.outDir === undefined) && { outDir: resolveDefaultOutDir(context, repoUrl) }) })) return createInputsFromCommand(repoUrl, raw, command) diff --git a/packages/app/src/docker-git/menu-create-flow-types.ts b/packages/app/src/docker-git/menu-create-flow-types.ts index ff73b48a..5a3f17dd 100644 --- a/packages/app/src/docker-git/menu-create-flow-types.ts +++ b/packages/app/src/docker-git/menu-create-flow-types.ts @@ -44,7 +44,7 @@ export type AdvanceCreateFlowOptions = { export type CreateSettingsNavigationDirection = "up" | "down" export type CreateSettingsChoiceDirection = "left" | "right" -export const createSettingsHint = "↑ - up, ↓ - down, Enter - apply" +export const settingsHint = "↑ - up, ↓ - down, Enter - apply" export const firstCreateSettingsStepIndex = 1 /** diff --git a/packages/app/src/docker-git/menu-create-shared.ts b/packages/app/src/docker-git/menu-create-shared.ts index 5c1713f6..8eb9e8bd 100644 --- a/packages/app/src/docker-git/menu-create-shared.ts +++ b/packages/app/src/docker-git/menu-create-shared.ts @@ -13,12 +13,12 @@ export { type CreateFlowView, type CreateModeFlowView, type CreateSettingsChoiceDirection, - createSettingsHint, type CreateSettingsNavigationDirection, type DisplayModeFlowView, isCreateFlowRepoStep, isCreateModeFlowView, - isDisplayModeFlowView + isDisplayModeFlowView, + settingsHint } from "./menu-create-flow-types.js" export { resolveCreateInputs } from "./menu-create-inputs.js" export { renderCreateStepLabel, renderCreateStepLabelWithBufferPreview } from "./menu-create-labels.js" diff --git a/packages/app/src/docker-git/menu-create-step-apply.ts b/packages/app/src/docker-git/menu-create-step-apply.ts index ed1c84a2..66c5dad4 100644 --- a/packages/app/src/docker-git/menu-create-step-apply.ts +++ b/packages/app/src/docker-git/menu-create-step-apply.ts @@ -1,7 +1,7 @@ import { Either, Match } from "effect" import type { ParseError } from "./frontend-lib/core/domain.js" -import { parseGpuInput, parseYesDefault } from "./menu-create-choices.js" +import { isYesDefault, parseGpuInput } from "./menu-create-choices.js" import { parseRepoStepInput } from "./menu-create-command-parse.js" import type { CreateFlowContext, CreateFlowView, Mutable } from "./menu-create-flow-types.js" import { resolveCreateInputs } from "./menu-create-inputs.js" @@ -43,11 +43,11 @@ const applyBooleanStep = ( input: ApplyCreateStepInput, key: "runUp" | "enableMcpPlaywright" | "force" ): Either.Either>, ParseError> => { - const value = parseYesDefault(input.buffer, input.currentDefaults[key]) + const isValue = isYesDefault(input.buffer, input.currentDefaults[key]) return Match.value(key).pipe( - Match.when("runUp", () => Either.right({ runUp: value })), - Match.when("enableMcpPlaywright", () => Either.right({ enableMcpPlaywright: value })), - Match.when("force", () => Either.right({ force: value })), + Match.when("runUp", () => Either.right({ runUp: isValue })), + Match.when("enableMcpPlaywright", () => Either.right({ enableMcpPlaywright: isValue })), + Match.when("force", () => Either.right({ force: isValue })), Match.exhaustive ) } diff --git a/packages/app/src/docker-git/menu-create-steps.ts b/packages/app/src/docker-git/menu-create-steps.ts index b6a656d8..73083ce2 100644 --- a/packages/app/src/docker-git/menu-create-steps.ts +++ b/packages/app/src/docker-git/menu-create-steps.ts @@ -1,7 +1,7 @@ import { Match } from "effect" import type { CreateInputs, CreateStep } from "./menu-types.js" -import { createSteps } from "./menu-types.js" +import { orderedCreateSteps } from "./menu-types.js" const hasOwn = (values: Partial, key: keyof CreateInputs): boolean => Object.prototype.hasOwnProperty.call(values, key) @@ -44,7 +44,7 @@ export const resolveCreateFlowSteps = ( values: Partial ): ReadonlyArray => [ "repoUrl", - ...createSteps + ...orderedCreateSteps .filter((step) => step !== "repoUrl") .filter((step) => !isCreateStepSatisfied(step, values)) ] @@ -61,11 +61,11 @@ export const resolveCreateFlowSteps = ( // QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" // REF: issue-339 // SOURCE: n/a -// FORMAT THEOREM: forall v: displaySteps(v) = createSteps +// FORMAT THEOREM: forall v: displaySteps(v) = orderedCreateSteps // PURITY: CORE // EFFECT: n/a // INVARIANT: display mode never filters satisfied settings // COMPLEXITY: O(1) export const resolveCreateDisplaySteps = ( _values: Partial = {} -): ReadonlyArray => createSteps +): ReadonlyArray => orderedCreateSteps diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index 8de64caf..73bc858a 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -142,9 +142,9 @@ const finalizeCreateFlow = (input: { const handleCreateReturn = ( context: CreateReturnContext, - quickCreate = false + shouldQuickCreate = false ) => { - const next = advanceCreateFlow(context.state.cwd, context.view, { quickCreate }) + const next = advanceCreateFlow(context.state.cwd, context.view, { quickCreate: shouldQuickCreate }) handleAdvanceCreateFlowResult(next, { onComplete: (inputs) => { finalizeCreateFlow({ diff --git a/packages/app/src/docker-git/menu-gridland-runtime.tsx b/packages/app/src/docker-git/menu-gridland-runtime.tsx index ba031682..4ae48a02 100644 --- a/packages/app/src/docker-git/menu-gridland-runtime.tsx +++ b/packages/app/src/docker-git/menu-gridland-runtime.tsx @@ -101,13 +101,13 @@ const runEmbeddedGridlandMenu = (renderApp: GridlandAppFactory): Effect.Effect { - if (exiting) { + if (isExiting) { return } - exiting = true + isExiting = true root.unmount() renderer.destroy() } @@ -124,7 +124,7 @@ const runEmbeddedGridlandMenu = (renderApp: GridlandAppFactory): Effect.Effect { - if (!exiting) { + if (!isExiting) { root.unmount() } }) @@ -169,7 +169,7 @@ const consumeSkippedInput = (context: GridlandMenuRuntimeContext): void => { context.setSkipInputs((value) => (value > 0 ? value - 1 : 0)) } -const handleCtrlC = (event: GridlandKeyEvent, context: GridlandMenuRuntimeContext): boolean => { +const didHandleCtrlC = (event: GridlandKeyEvent, context: GridlandMenuRuntimeContext): boolean => { if (!(event.ctrl && event.name === "c")) { return false } @@ -181,7 +181,7 @@ const handleCtrlC = (event: GridlandKeyEvent, context: GridlandMenuRuntimeContex export const useGridlandMenuInput = (gridland: GridlandModule, context: GridlandMenuRuntimeContext): void => { gridland.useKeyboard((event) => { - if (handleCtrlC(event, context) || shouldIgnoreKeyEvent(context)) { + if (didHandleCtrlC(event, context) || shouldIgnoreKeyEvent(context)) { return } if (shouldConsumeSkippedInput(context)) { diff --git a/packages/app/src/docker-git/menu-input-handler.ts b/packages/app/src/docker-git/menu-input-handler.ts index 6bd6695a..e8cc137b 100644 --- a/packages/app/src/docker-git/menu-input-handler.ts +++ b/packages/app/src/docker-git/menu-input-handler.ts @@ -16,7 +16,7 @@ export type MenuInputContext = MenuViewContext & { readonly setSelected: (update: (value: number) => number) => void readonly setSkipInputs: (update: (value: number) => number) => void readonly sshActive: boolean - readonly setSshActive: (active: boolean) => void + readonly setSshActive: (isActive: boolean) => void readonly state: MenuState readonly runner: MenuRunner readonly exit: () => void diff --git a/packages/app/src/docker-git/menu-input-utils.ts b/packages/app/src/docker-git/menu-input-utils.ts index 3aa40c24..e614d35a 100644 --- a/packages/app/src/docker-git/menu-input-utils.ts +++ b/packages/app/src/docker-git/menu-input-utils.ts @@ -4,7 +4,7 @@ export const parseMenuIndex = (input: string): number | null => { return null } const parsed = Number(trimmed) - if (!Number.isInteger(parsed)) { + if (!Number.isSafeInteger(parsed)) { return null } const index = parsed - 1 diff --git a/packages/app/src/docker-git/menu-labeled-env.ts b/packages/app/src/docker-git/menu-labeled-env.ts index 1bb861ea..a22fa451 100644 --- a/packages/app/src/docker-git/menu-labeled-env.ts +++ b/packages/app/src/docker-git/menu-labeled-env.ts @@ -16,10 +16,10 @@ const parseEnvEntries = (input: string): ReadonlyArray => { continue } const key = normalized.slice(0, equalsIndex).trim() - const value = normalized.slice(equalsIndex + 1).trim() if (key.length === 0) { continue } + const value = normalized.slice(equalsIndex + 1).trim() entries.push({ key, value }) } return entries diff --git a/packages/app/src/docker-git/menu-menu.ts b/packages/app/src/docker-git/menu-menu.ts index 021ab4c2..62c8ecec 100644 --- a/packages/app/src/docker-git/menu-menu.ts +++ b/packages/app/src/docker-git/menu-menu.ts @@ -83,7 +83,7 @@ const handleMenuNavigation = ( setSelected: (update: (value: number) => number) => void ) => { if (key.upArrow) { - setSelected((prev) => (prev === 0 ? menuItems.length - 1 : prev - 1)) + setSelected((prev) => (prev === 0 ? menuItems.length : prev) - 1) return } if (key.downArrow) { @@ -99,7 +99,7 @@ const handleMenuEnter = (context: MenuSelectionContext) => { handleMenuActionSelection(action, context) } -const handleMenuTextInput = (input: string, context: MenuSelectionContext): boolean => { +const didHandleMenuTextInput = (input: string, context: MenuSelectionContext): boolean => { const trimmed = input.trim() if (trimmed.length > 0 && isRepoUrlInput(trimmed)) { context.setSkipInputs(() => 1) @@ -128,5 +128,5 @@ export const handleMenuInput = ( handleMenuEnter(context) return } - handleMenuTextInput(input, context) + didHandleMenuTextInput(input, context) } diff --git a/packages/app/src/docker-git/menu-project-auth-helpers.ts b/packages/app/src/docker-git/menu-project-auth-helpers.ts index c72d0594..7199b1c0 100644 --- a/packages/app/src/docker-git/menu-project-auth-helpers.ts +++ b/packages/app/src/docker-git/menu-project-auth-helpers.ts @@ -12,6 +12,22 @@ type AccountCredentialSpec = { readonly envKeys: ReadonlyArray } +// PURITY: CORE +// INVARIANT: true iff `trimmed` assigns a non-empty value to one of `keys` +const hasNonEmptyKeyValueInLine = (trimmed: string, keys: ReadonlyArray): boolean => { + for (const key of keys) { + const prefix = `${key}=` + if (!trimmed.startsWith(prefix)) { + continue + } + const value = trimmed.slice(prefix.length).replaceAll(/^['"]|['"]$/g, "").trim() + if (value.length > 0) { + return true + } + } + return false +} + export const hasNonEmptyEnvValue = ( fs: FileSystem.FileSystem, envFilePath: string, @@ -25,15 +41,8 @@ export const hasNonEmptyEnvValue = ( const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => "")) for (const line of envContent.split("\n")) { const trimmed = line.trim() - for (const key of keys) { - const prefix = `${key}=` - if (!trimmed.startsWith(prefix)) { - continue - } - const value = trimmed.slice(prefix.length).replaceAll(/^['"]|['"]$/g, "").trim() - if (value.length > 0) { - return true - } + if (hasNonEmptyKeyValueInLine(trimmed, keys)) { + return true } } return false diff --git a/packages/app/src/docker-git/menu-project-auth.ts b/packages/app/src/docker-git/menu-project-auth.ts index cce0bb74..a9bf2ef9 100644 --- a/packages/app/src/docker-git/menu-project-auth.ts +++ b/packages/app/src/docker-git/menu-project-auth.ts @@ -80,16 +80,17 @@ const runProjectAuthEffect = ( label: string, context: ProjectAuthContext ) => { + const snapshotEffect = readProjectAuthSnapshot(project) + const presentSnapshot = (snapshot: Effect.Effect.Success) => + Effect.sync(() => { + startProjectAuthMenu(project, snapshot, context) + context.setMessage(projectAuthSuccessMessage(flow, label)) + }) context.runner.runEffect( pipe( writeProjectAuthFlow(project, flow, values), - Effect.zipRight(readProjectAuthSnapshot(project)), - Effect.tap((snapshot) => - Effect.sync(() => { - startProjectAuthMenu(project, snapshot, context) - context.setMessage(projectAuthSuccessMessage(flow, label)) - }) - ), + Effect.zipRight(snapshotEffect), + Effect.tap(presentSnapshot), Effect.asVoid ) ) diff --git a/packages/app/src/docker-git/menu-render-project-auth.ts b/packages/app/src/docker-git/menu-render-project-auth.ts index 8b16c62e..3179be39 100644 --- a/packages/app/src/docker-git/menu-render-project-auth.ts +++ b/packages/app/src/docker-git/menu-render-project-auth.ts @@ -23,6 +23,17 @@ export const renderProjectAuthMenu = ( const el = React.createElement const list = renderSelectableMenuList(projectAuthMenuLabels(), selected) + const githubLabelLine = `GitHub label: ${renderActiveLabel(snapshot.activeGithubLabel)}` + const githubTokenLine = renderCountLine("Available GitHub tokens", snapshot.githubTokenEntries) + const gitLabelLine = `Git label: ${renderActiveLabel(snapshot.activeGitLabel)}` + const gitTokenLine = renderCountLine("Available Git tokens", snapshot.gitTokenEntries) + const claudeLabelLine = `Claude label: ${renderActiveLabel(snapshot.activeClaudeLabel)}` + const claudeLoginLine = renderCountLine("Available Claude logins", snapshot.claudeAuthEntries) + const geminiLabelLine = `Gemini label: ${renderActiveLabel(snapshot.activeGeminiLabel)}` + const geminiLoginLine = renderCountLine("Available Gemini logins", snapshot.geminiAuthEntries) + const grokLabelLine = `Grok label: ${renderActiveLabel(snapshot.activeGrokLabel)}` + const grokLoginLine = renderCountLine("Available Grok logins", snapshot.grokAuthEntries) + return renderLayout( "docker-git / Project auth", [ @@ -36,16 +47,16 @@ export const renderProjectAuthMenu = ( el( Box, { marginTop: 1, flexDirection: "column" }, - el(Text, { fg: "gray" }, `GitHub label: ${renderActiveLabel(snapshot.activeGithubLabel)}`), - el(Text, { fg: "gray" }, renderCountLine("Available GitHub tokens", snapshot.githubTokenEntries)), - el(Text, { fg: "gray" }, `Git label: ${renderActiveLabel(snapshot.activeGitLabel)}`), - el(Text, { fg: "gray" }, renderCountLine("Available Git tokens", snapshot.gitTokenEntries)), - el(Text, { fg: "gray" }, `Claude label: ${renderActiveLabel(snapshot.activeClaudeLabel)}`), - el(Text, { fg: "gray" }, renderCountLine("Available Claude logins", snapshot.claudeAuthEntries)), - el(Text, { fg: "gray" }, `Gemini label: ${renderActiveLabel(snapshot.activeGeminiLabel)}`), - el(Text, { fg: "gray" }, renderCountLine("Available Gemini logins", snapshot.geminiAuthEntries)), - el(Text, { fg: "gray" }, `Grok label: ${renderActiveLabel(snapshot.activeGrokLabel)}`), - el(Text, { fg: "gray" }, renderCountLine("Available Grok logins", snapshot.grokAuthEntries)) + el(Text, { fg: "gray" }, githubLabelLine), + el(Text, { fg: "gray" }, githubTokenLine), + el(Text, { fg: "gray" }, gitLabelLine), + el(Text, { fg: "gray" }, gitTokenLine), + el(Text, { fg: "gray" }, claudeLabelLine), + el(Text, { fg: "gray" }, claudeLoginLine), + el(Text, { fg: "gray" }, geminiLabelLine), + el(Text, { fg: "gray" }, geminiLoginLine), + el(Text, { fg: "gray" }, grokLabelLine), + el(Text, { fg: "gray" }, grokLoginLine) ), el(Box, { flexDirection: "column", marginTop: 1 }, ...list), renderMenuHelp("Use arrows + Enter, or type a number from the list.") diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index 03155b20..ae226dcb 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -1,7 +1,7 @@ import React from "react" import { Box, Text } from "../ui/primitives.js" -import { createSettingsHint, renderCreateStepLabel } from "./menu-create-shared.js" +import { renderCreateStepLabel, settingsHint } from "./menu-create-shared.js" import { renderLayout } from "./menu-render-layout.js" import { buildSelectLabels, @@ -80,7 +80,7 @@ type CreateRenderInput = { } export const renderMenu = (input: MenuRenderInput): React.ReactElement => { - const { activeDir, busy, cwd, message, runningDockerGitContainers, selected } = input + const { activeDir, busy: isBusy, cwd, message, runningDockerGitContainers, selected } = input const el = React.createElement const activeLabel = `Active: ${activeDir ?? "(none)"}` const runningLabel = `Running docker-git containers: ${runningDockerGitContainers}` @@ -95,7 +95,7 @@ export const renderMenu = (input: MenuRenderInput): React.ReactElement => { ) }) - const busyView = busy + const busyView = isBusy ? el(Box, { marginTop: 1 }, el(Text, { fg: "yellow" }, "Running...")) : null @@ -120,7 +120,7 @@ export const renderMenu = (input: MenuRenderInput): React.ReactElement => { export const renderCreate = (input: CreateRenderInput): React.ReactElement => { const { buffer, defaults, label, message, stepIndex, steps } = input const el = React.createElement - const hint = stepIndex > 0 ? createSettingsHint : null + const hint = stepIndex > 0 ? settingsHint : null const stepViews = steps.map((step, index) => el( Text, @@ -128,19 +128,15 @@ export const renderCreate = (input: CreateRenderInput): React.ReactElement => { `${index === stepIndex ? ">" : " "} ${renderCreateStepLabel(step, defaults)}` ) ) + const labelText = el(Text, null, `${label}: `) + const bufferText = el(Text, { fg: "green" }, buffer) + const hintText = hint === null ? null : el(Text, { fg: "gray" }, hint) return renderLayout( "docker-git / Create", compactElements([ el(Box, { flexDirection: "column", marginTop: 1 }, ...stepViews), - el( - Box, - { marginTop: 1 }, - el(Text, null, `${label}: `), - el(Text, { fg: "green" }, buffer) - ), - hint === null - ? null - : el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, hint)) + el(Box, { marginTop: 1 }, labelText, bufferText), + hintText === null ? null : el(Box, { marginTop: 1 }, hintText) ]), message ) @@ -238,16 +234,16 @@ type RenderSelectInput = { const selectConfirmHint = ( purpose: SelectPurpose, - confirmDelete: boolean, - connectEnableMcpPlaywright: boolean + isConfirmDelete: boolean, + shouldEnableMcpPlaywright: boolean ): string => { - if (purpose === "Delete" && confirmDelete) { + if (purpose === "Delete" && isConfirmDelete) { return "Confirm mode: Enter = delete now, Esc = cancel" } - if (purpose === "Down" && confirmDelete) { + if (purpose === "Down" && isConfirmDelete) { return "Confirm mode: Enter = stop now, Esc = cancel" } - return selectHint(purpose, connectEnableMcpPlaywright) + return selectHint(purpose, shouldEnableMcpPlaywright) } const renderSelectSearch = ( @@ -265,8 +261,16 @@ const renderSelectSearch = ( ) export const renderSelect = (input: RenderSelectInput): React.ReactElement => { - const { confirmDelete, connectEnableMcpPlaywright, items, message, purpose, query, runtimeByProject, selected } = - input + const { + confirmDelete: isConfirmDelete, + connectEnableMcpPlaywright: isConnectEnableMcpPlaywright, + items, + message, + purpose, + query, + runtimeByProject, + selected + } = input const el = React.createElement const listLabels = buildSelectLabels(items, selected, purpose, runtimeByProject) const { detailsWidth, listWidth } = computeSelectColumnWidths(listLabels) @@ -277,9 +281,9 @@ export const renderSelect = (input: RenderSelectInput): React.ReactElement => { items, selected, runtimeByProject, - connectEnableMcpPlaywright + connectEnableMcpPlaywright: isConnectEnableMcpPlaywright }) - const confirmHint = selectConfirmHint(purpose, confirmDelete, connectEnableMcpPlaywright) + const confirmHint = selectConfirmHint(purpose, isConfirmDelete, isConnectEnableMcpPlaywright) const hints = el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, confirmHint)) const search = renderSelectSearch(el, query) diff --git a/packages/app/src/docker-git/menu-select-actions.ts b/packages/app/src/docker-git/menu-select-actions.ts index b58bbbf8..91918650 100644 --- a/packages/app/src/docker-git/menu-select-actions.ts +++ b/packages/app/src/docker-git/menu-select-actions.ts @@ -14,16 +14,16 @@ import type { ProjectItem } from "./project-item.js" export type SelectContext = MenuViewContext & { readonly activeDir: string | null readonly runner: MenuRunner - readonly setSshActive: (active: boolean) => void + readonly setSshActive: (isActive: boolean) => void readonly setSkipInputs: (update: (value: number) => number) => void } export const runConnectSelection = ( selected: ProjectItem, context: SelectContext, - enableMcpPlaywright: boolean + shouldEnableMcpPlaywright: boolean ) => { - if (enableMcpPlaywright) { + if (shouldEnableMcpPlaywright) { context.setMessage( "Playwright MCP pre-connect toggle is not routed through the controller yet." ) @@ -32,23 +32,22 @@ export const runConnectSelection = ( context.setMessage(`Connecting to ${selected.displayName}...`) context.setSshActive(true) + const connectEffect = buildConnectEffect(selected, false, { + connectWithUp: (item) => openResolvedProjectSshViaControllerWithUp(item), + enableMcpPlaywright: () => Effect.void + }) + const onSessionEnded = Effect.sync(() => { + context.setMessage("SSH session ended. Press Esc to return to the menu.") + }) + const onConnectFinalized = Effect.sync(() => { + context.setSshActive(false) + context.setSkipInputs(() => 2) + }) context.runner.runInteractiveEffect( pipe( - buildConnectEffect(selected, false, { - connectWithUp: (item) => openResolvedProjectSshViaControllerWithUp(item), - enableMcpPlaywright: () => Effect.void - }), - Effect.tap(() => - Effect.sync(() => { - context.setMessage("SSH session ended. Press Esc to return to the menu.") - }) - ), - Effect.ensuring( - Effect.sync(() => { - context.setSshActive(false) - context.setSkipInputs(() => 2) - }) - ), + connectEffect, + Effect.tap(() => onSessionEnded), + Effect.ensuring(onConnectFinalized), Effect.asVoid ) ) @@ -56,35 +55,36 @@ export const runConnectSelection = ( export const runDownSelection = (selected: ProjectItem, context: SelectContext) => { context.setMessage(`Stopping ${selected.displayName}...`) - context.runner.runEffect( - withSuspendedTui( - pipe( - downMenuProject(selected), - Effect.zipRight(listMenuRunningProjectItems), - Effect.flatMap((items) => - pipe( - loadRuntimeByProject(items), - Effect.map((runtimeByProject) => ({ items, runtimeByProject })) - ) - ), - Effect.tap(({ items, runtimeByProject }) => - Effect.sync(() => { - if (items.length === 0) { - resetToMenu(context) - context.setMessage("No running docker-git containers.") - return - } - startSelectView(items, "Down", context, runtimeByProject) - context.setMessage("Container stopped. Select another to stop, or Esc to return.") - }) - ), - Effect.asVoid - ), - { - onError: pauseOnError(renderMenuError), - onResume: resumeWithSkipInputs(context) - } + const loadItemsWithRuntime = (items: ReadonlyArray) => + pipe( + loadRuntimeByProject(items), + Effect.map((runtimeByProject) => ({ items, runtimeByProject })) ) + const presentRemainingItems = ({ + items, + runtimeByProject + }: Effect.Effect.Success>) => + Effect.sync(() => { + if (items.length === 0) { + resetToMenu(context) + context.setMessage("No running docker-git containers.") + return + } + startSelectView(items, "Down", context, runtimeByProject) + context.setMessage("Container stopped. Select another to stop, or Esc to return.") + }) + const downEffect = pipe( + downMenuProject(selected), + Effect.zipRight(listMenuRunningProjectItems), + Effect.flatMap(loadItemsWithRuntime), + Effect.tap(presentRemainingItems), + Effect.asVoid + ) + context.runner.runEffect( + withSuspendedTui(downEffect, { + onError: pauseOnError(renderMenuError), + onResume: resumeWithSkipInputs(context) + }) ) } @@ -98,30 +98,27 @@ export const runAuthSelection = (selected: ProjectItem, context: SelectContext) export const runDeleteSelection = (selected: ProjectItem, context: SelectContext) => { context.setMessage(`Deleting ${selected.displayName}...`) + const onProjectRemoved = Effect.sync(() => { + if (context.activeDir === selected.projectDir) { + context.setActiveDir(null) + } + context.setView({ _tag: "Menu" }) + }) + const onDeleteSucceeded = Effect.sync(() => { + context.setMessage("Project deleted.") + }) + const projectRemovalEffect = deleteMenuProject(selected).pipe( + Effect.tap(() => onProjectRemoved), + Effect.asVoid + ) + const suspendedDelete = withSuspendedTui(projectRemovalEffect, { + onError: pauseOnError(renderMenuError), + onResume: resumeWithSkipInputs(context) + }) context.runner.runEffect( pipe( - withSuspendedTui( - deleteMenuProject(selected).pipe( - Effect.tap(() => - Effect.sync(() => { - if (context.activeDir === selected.projectDir) { - context.setActiveDir(null) - } - context.setView({ _tag: "Menu" }) - }) - ), - Effect.asVoid - ), - { - onError: pauseOnError(renderMenuError), - onResume: resumeWithSkipInputs(context) - } - ), - Effect.tap(() => - Effect.sync(() => { - context.setMessage("Project deleted.") - }) - ), + suspendedDelete, + Effect.tap(() => onDeleteSucceeded), Effect.asVoid ) ) diff --git a/packages/app/src/docker-git/menu-select-connect.ts b/packages/app/src/docker-git/menu-select-connect.ts index e0673237..5e5da30e 100644 --- a/packages/app/src/docker-git/menu-select-connect.ts +++ b/packages/app/src/docker-git/menu-select-connect.ts @@ -17,10 +17,10 @@ export const isConnectMcpToggleInput = (input: string): boolean => normalizedInp export const buildConnectEffect = ( selected: ProjectItem, - enableMcpPlaywright: boolean, + shouldEnableMcpPlaywright: boolean, deps: ConnectDeps ): Effect.Effect => - enableMcpPlaywright + shouldEnableMcpPlaywright ? deps.enableMcpPlaywright(selected.projectDir).pipe( Effect.zipRight(deps.connectWithUp(selected)) ) diff --git a/packages/app/src/docker-git/menu-select-order.ts b/packages/app/src/docker-git/menu-select-order.ts index 7a06a547..e82f7c48 100644 --- a/packages/app/src/docker-git/menu-select-order.ts +++ b/packages/app/src/docker-git/menu-select-order.ts @@ -9,8 +9,7 @@ const defaultRuntime = (): SelectProjectRuntime => ({ startedAtEpochMs: null }) -const startedAtEpochForSort = (runtime: SelectProjectRuntime): number => - runtime.startedAtEpochMs ?? Number.NEGATIVE_INFINITY +const startedAtEpochForSort = (runtime: SelectProjectRuntime): number => runtime.startedAtEpochMs ?? -Infinity type SelectOrderAccessors = { readonly displayName: (item: A) => string diff --git a/packages/app/src/docker-git/menu-select-presenter.ts b/packages/app/src/docker-git/menu-select-presenter.ts index 657b88bd..48340256 100644 --- a/packages/app/src/docker-git/menu-select-presenter.ts +++ b/packages/app/src/docker-git/menu-select-presenter.ts @@ -59,9 +59,9 @@ export const stoppedRuntime = (): SelectProjectRuntime => ({ const pad2 = (value: number): string => value.toString().padStart(2, "0") -const formatUtcTimestamp = (epochMs: number, withSeconds: boolean): string => { +const formatUtcTimestamp = (epochMs: number, shouldIncludeSeconds: boolean): string => { const date = new Date(epochMs) - const seconds = withSeconds ? `:${pad2(date.getUTCSeconds())}` : "" + const seconds = shouldIncludeSeconds ? `:${pad2(date.getUTCSeconds())}` : "" return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${ pad2( date.getUTCHours() @@ -94,7 +94,7 @@ export const selectTitle = (purpose: SelectPurpose): string => export const selectHint = ( purpose: SelectPurpose, - _connectEnableMcpPlaywright: boolean + _shouldEnableMcpPlaywright: boolean ): string => Match.value(purpose).pipe( Match.when("Connect", () => "Enter = start if needed + SSH, Esc = back"), @@ -222,7 +222,7 @@ export const buildSelectDetailsModel = ( purpose: SelectPurpose, item: SelectDetailProject | undefined, runtime: SelectProjectRuntime, - connectEnableMcpPlaywright: boolean + shouldEnableMcpPlaywright: boolean ): SelectDetailsModel => { if (item === undefined) { return { title: "No project selected", lines: ["No project selected."] } @@ -231,7 +231,7 @@ export const buildSelectDetailsModel = ( const context: SelectDetailsContext = { authSuffix: item.authorizedKeysExists ? "" : " (missing)", common: commonLines(item, runtime), - connectEnableMcpPlaywright, + connectEnableMcpPlaywright: shouldEnableMcpPlaywright, item, refLabel: formatRepoRef(item.repoRef), runtime diff --git a/packages/app/src/docker-git/menu-select-runtime.ts b/packages/app/src/docker-git/menu-select-runtime.ts index 8ec1753e..f7a89577 100644 --- a/packages/app/src/docker-git/menu-select-runtime.ts +++ b/packages/app/src/docker-git/menu-select-runtime.ts @@ -13,10 +13,7 @@ const stoppedRuntime = (): SelectProjectRuntime => ({ const toRuntimeMap = ( entries: ReadonlyArray ): Readonly> => { - const runtimeByProject: Record = {} - for (const [projectDir, runtime] of entries) { - runtimeByProject[projectDir] = runtime - } + const runtimeByProject: Record = Object.fromEntries(entries) return runtimeByProject } diff --git a/packages/app/src/docker-git/menu-select.ts b/packages/app/src/docker-git/menu-select.ts index 6c4c9b25..38f26eb0 100644 --- a/packages/app/src/docker-git/menu-select.ts +++ b/packages/app/src/docker-git/menu-select.ts @@ -55,7 +55,7 @@ const selectSearchMessage = ( ? null : `Search "${view.query}": ${view.items.length}/${view.allItems.length} project(s).` -const handleSelectSearchInput = ( +const didHandleSelectSearchInput = ( input: string, key: MenuKeyInput, view: Extract, @@ -81,17 +81,17 @@ export const handleSelectInput = ( resetToMenu(context) return } - if (handleConnectOptionToggle(input, view, context)) { + if (didHandleConnectOptionToggle(input, view, context)) { return } - if (handleSelectNavigation(key, view, context)) { + if (didHandleSelectNavigation(key, view, context)) { return } if (key.return) { handleSelectReturn(view, context) return } - if (handleSelectSearchInput(input, key, view, context)) { + if (didHandleSelectSearchInput(input, key, view, context)) { return } if (input.trim().length > 0) { @@ -99,7 +99,7 @@ export const handleSelectInput = ( } } -const handleConnectOptionToggle = ( +const didHandleConnectOptionToggle = ( input: string, view: Extract, context: Pick @@ -113,7 +113,7 @@ const handleConnectOptionToggle = ( return true } -const handleSelectNavigation = ( +const didHandleSelectNavigation = ( key: MenuKeyInput, view: Extract, context: SelectContext diff --git a/packages/app/src/docker-git/menu-shared.ts b/packages/app/src/docker-git/menu-shared.ts index 3357382f..42f86830 100644 --- a/packages/app/src/docker-git/menu-shared.ts +++ b/packages/app/src/docker-git/menu-shared.ts @@ -18,11 +18,20 @@ type MenuResetContext = Pick type OutputWrite = typeof process.stdout.write -let stdoutPatched = false -let stdoutMuted = false -let baseStdoutWrite: OutputWrite | null = null -let baseStderrWrite: OutputWrite | null = null -const primaryScreenEscape = "\u001B[?1049l\r\u001B[2K" +type StdoutPatchState = { + isStdoutPatched: boolean + isStdoutMuted: boolean + baseStdoutWrite: OutputWrite | null + baseStderrWrite: OutputWrite | null +} + +const stdoutPatchState: StdoutPatchState = { + isStdoutPatched: false, + isStdoutMuted: false, + baseStdoutWrite: null, + baseStderrWrite: null +} +const primaryScreenEscape = "\u{1B}[?1049l\r\u{1B}[2K" const wrapWrite = (baseWrite: OutputWrite): OutputWrite => ( @@ -30,7 +39,7 @@ const wrapWrite = (baseWrite: OutputWrite): OutputWrite => encoding?: BufferEncoding | ((err?: Error | null) => void), cb?: (err?: Error | null) => void ) => { - if (stdoutMuted) { + if (stdoutPatchState.isStdoutMuted) { const callback = typeof encoding === "function" ? encoding : cb if (typeof callback === "function") { callback() @@ -45,20 +54,20 @@ const wrapWrite = (baseWrite: OutputWrite): OutputWrite => const writeTerminalControl = (text: string): void => { ensureStdoutPatched() - const write = baseStdoutWrite ?? process.stdout.write.bind(process.stdout) + const write = stdoutPatchState.baseStdoutWrite ?? process.stdout.write.bind(process.stdout) write(text) } const disableTerminalInputModes = (): void => { // Disable mouse/input modes that can leak across TUI <-> SSH transitions. writeTerminalControl( - "\u001B[0m" + - "\u001B[?25h" + - "\u001B[?1l" + - "\u001B>" + - "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l" + - "\u001B[?1004l\u001B[?2004l" + - "\u001B[>4;0m\u001B[>4m\u001B[" + + "\u{1B}[?1000l\u{1B}[?1002l\u{1B}[?1003l\u{1B}[?1005l\u{1B}[?1006l\u{1B}[?1015l\u{1B}[?1007l" + + "\u{1B}[?1004l\u{1B}[?2004l" + + "\u{1B}[>4;0m\u{1B}[>4m\u{1B}[ { // INVARIANT: wrapper preserves original stdout write when not muted // COMPLEXITY: O(1) const ensureStdoutPatched = (): void => { - if (stdoutPatched) { + if (stdoutPatchState.isStdoutPatched) { return } - baseStdoutWrite = process.stdout.write.bind(process.stdout) - baseStderrWrite = process.stderr.write.bind(process.stderr) + const baseStdoutWrite = process.stdout.write.bind(process.stdout) + const baseStderrWrite = process.stderr.write.bind(process.stderr) + stdoutPatchState.baseStdoutWrite = baseStdoutWrite + stdoutPatchState.baseStderrWrite = baseStderrWrite process.stdout.write = wrapWrite(baseStdoutWrite) process.stderr.write = wrapWrite(baseStderrWrite) - stdoutPatched = true + stdoutPatchState.isStdoutPatched = true } // CHANGE: allow writing to the terminal even while stdout is muted @@ -151,19 +162,15 @@ export const withSuspendedTui = ( ? pipe(effect, Effect.tapError((error) => Effect.ignore(options.onError?.(error) ?? Effect.void))) : effect + const notifyResume = Effect.sync(() => { + options?.onResume?.() + }) + const resumeAndNotify = pipe(resumeTui(), Effect.zipRight(notifyResume)) + return pipe( suspendTui(), Effect.zipRight(withError), - Effect.ensuring( - pipe( - resumeTui(), - Effect.zipRight( - Effect.sync(() => { - options?.onResume?.() - }) - ) - ) - ) + Effect.ensuring(resumeAndNotify) ) } @@ -172,7 +179,7 @@ export type SkipInputsContext = { } export type SshActiveContext = { - readonly setSshActive: (active: boolean) => void + readonly setSshActive: (isActive: boolean) => void } export const resumeWithSkipInputs = (context: SkipInputsContext, extra?: () => void) => () => { @@ -198,14 +205,14 @@ export const pauseOnError = (render: (error: E) => string) => (error: E): Eff // EFFECT: n/a // INVARIANT: stdout wrapper is installed at most once // COMPLEXITY: O(1) -const setStdoutMuted = (muted: boolean): void => { +const setStdoutMuted = (isMuted: boolean): void => { ensureStdoutPatched() - stdoutMuted = muted + stdoutPatchState.isStdoutMuted = isMuted } -const setStdoutMutedEffect = (muted: boolean): Effect.Effect => +const setStdoutMutedEffect = (isMuted: boolean): Effect.Effect => Effect.sync(() => { - setStdoutMuted(muted) + setStdoutMuted(isMuted) }) const writeTerminalControlEffect = (text: string): Effect.Effect => @@ -213,11 +220,11 @@ const writeTerminalControlEffect = (text: string): Effect.Effect => writeTerminalControl(text) }) -const setRawModeEffect = (enabled: boolean): Effect.Effect => +const setRawModeEffect = (isEnabled: boolean): Effect.Effect => process.stdin.isTTY && typeof process.stdin.setRawMode === "function" ? pipe( Effect.try(() => { - process.stdin.setRawMode(enabled) + process.stdin.setRawMode(isEnabled) }), Effect.ignore ) @@ -267,7 +274,7 @@ export const resumeTui = (): Effect.Effect = Effect.gen(function*(_) { yield* _(repairInteractiveTerminal(writeTerminalControl)) // Return to the alternate screen for Ink rendering. - yield* _(writeTerminalControlEffect("\u001B[?1049h\u001B[2J\u001B[H")) + yield* _(writeTerminalControlEffect("\u{1B}[?1049h\u{1B}[2J\u{1B}[H")) yield* _(setRawModeEffect(true)) yield* _(Effect.sync(() => { disableTerminalInputModes() diff --git a/packages/app/src/docker-git/menu-state.ts b/packages/app/src/docker-git/menu-state.ts index 2e3834af..8aca309e 100644 --- a/packages/app/src/docker-git/menu-state.ts +++ b/packages/app/src/docker-git/menu-state.ts @@ -58,7 +58,7 @@ export const defaultMenuSnapshot = (): MenuSnapshot => ({ const withBusyCleanup = ( effect: Effect.Effect, - setBusy: (busy: boolean) => void + setBusy: (isBusy: boolean) => void ): Effect.Effect => pipe( effect, @@ -85,16 +85,17 @@ const handleInteractiveMenuError = (setMessage: (message: string | null) => void } const useRunEffect = ( - setBusy: (busy: boolean) => void, + setBusy: (isBusy: boolean) => void, setMessage: (message: string | null) => void ) => function(effect: Effect.Effect) { setBusy(true) + const onFailure = handleMenuError(setMessage) const program = withBusyCleanup( pipe( effect, Effect.matchEffect({ - onFailure: handleMenuError(setMessage), + onFailure, onSuccess: () => Effect.void }) ), @@ -104,28 +105,25 @@ const useRunEffect = ( } const useRunInteractiveEffect = ( - setBusy: (busy: boolean) => void, + setBusy: (isBusy: boolean) => void, setMessage: (message: string | null) => void, queueInteractiveEffect: QueueInteractiveEffect ) => function(effect: Effect.Effect) { setBusy(true) - queueInteractiveEffect( - withBusyCleanup( - pipe( - effect, - Effect.matchEffect({ - onFailure: handleInteractiveMenuError(setMessage), - onSuccess: () => Effect.void - }) - ), - setBusy - ) + const onFailure = handleInteractiveMenuError(setMessage) + const handledEffect = pipe( + effect, + Effect.matchEffect({ + onFailure, + onSuccess: () => Effect.void + }) ) + queueInteractiveEffect(withBusyCleanup(handledEffect, setBusy)) } const useRunner = ( - setBusy: (busy: boolean) => void, + setBusy: (isBusy: boolean) => void, setMessage: (message: string | null) => void, queueInteractiveEffect: QueueInteractiveEffect ) => { @@ -167,8 +165,8 @@ const useMenuSetters = ( setActiveDir: (value: string | null) => { commit((snapshot) => ({ ...snapshot, activeDir: value })) }, - setBusy: (value: boolean) => { - commit((snapshot) => ({ ...snapshot, busy: value })) + setBusy: (isBusy: boolean) => { + commit((snapshot) => ({ ...snapshot, busy: isBusy })) }, setInputStage: (value: InputStage) => { commit((snapshot) => ({ ...snapshot, inputStage: value })) @@ -176,8 +174,8 @@ const useMenuSetters = ( setMessage: (value: string | null) => { commit((snapshot) => ({ ...snapshot, message: value })) }, - setReady: (value: boolean) => { - commit((snapshot) => ({ ...snapshot, ready: value })) + setReady: (isReady: boolean) => { + commit((snapshot) => ({ ...snapshot, ready: isReady })) }, setRunningDockerGitContainers: (value: number) => { commit((snapshot) => ({ ...snapshot, runningDockerGitContainers: value })) @@ -188,8 +186,8 @@ const useMenuSetters = ( setSkipInputs: (update: (value: number) => number) => { commit((snapshot) => ({ ...snapshot, skipInputs: update(snapshot.skipInputs) })) }, - setSshActive: (value: boolean) => { - commit((snapshot) => ({ ...snapshot, sshActive: value })) + setSshActive: (isActive: boolean) => { + commit((snapshot) => ({ ...snapshot, sshActive: isActive })) }, setView: (value: ViewState) => { commit((snapshot) => ({ ...snapshot, view: value })) @@ -207,7 +205,7 @@ export const useMenuState = (store: MenuSnapshotStore, queueInteractiveEffect: Q return { ...snapshot, ...setters, ignoreUntil, state, runner } } -export const useReadyGate = (setReady: (ready: boolean) => void) => { +export const useReadyGate = (setReady: (isReady: boolean) => void) => { useEffect(() => { const timer = setTimeout(() => { setReady(true) @@ -228,7 +226,7 @@ export const useStartupSnapshot = ( if (store.current.startupLoaded) { return } - let cancelled = false + let isCancelled = false const startup = pipe( listMenuProjectItems, Effect.map((items) => resolveMenuStartupSnapshot(items)), @@ -238,8 +236,8 @@ export const useStartupSnapshot = ( }), Effect.provide(NodeContext.layer) ) - void Effect.runPromise(startup).then((snapshot) => { - if (cancelled) { + const applyStartupSnapshot = (snapshot: Effect.Effect.Success): void => { + if (isCancelled) { return } store.current = { ...store.current, startupLoaded: true } @@ -248,17 +246,27 @@ export const useStartupSnapshot = ( if (snapshot.activeDir !== null) { setActiveDir(snapshot.activeDir) } - }) + } + void Effect.runPromise( + pipe( + startup, + Effect.tap((snapshot) => + Effect.sync(() => { + applyStartupSnapshot(snapshot) + }) + ) + ) + ) return () => { - cancelled = true + isCancelled = true } }, [setActiveDir, setMessage, setRunningDockerGitContainers, store]) } -export const useSigintGuard = (exit: () => void, sshActive: boolean) => { +export const useSigintGuard = (exit: () => void, isSshActive: boolean) => { useEffect(() => { const handleSigint = () => { - if (!sshActive) { + if (!isSshActive) { exit() } } @@ -266,5 +274,5 @@ export const useSigintGuard = (exit: () => void, sshActive: boolean) => { return () => { process.off("SIGINT", handleSigint) } - }, [exit, sshActive]) + }, [exit, isSshActive]) } diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 72d98164..17c7eff1 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -15,7 +15,7 @@ import type { ProjectItem } from "./project-item.js" // FORMAT THEOREM: forall s: state(s) -> wellTyped(s) // PURITY: CORE // EFFECT: n/a -// INVARIANT: createSteps is ordered and total over CreateStep +// INVARIANT: orderedCreateSteps is ordered and total over CreateStep // COMPLEXITY: O(1) export type MenuState = { @@ -70,7 +70,7 @@ export type CreateStep = | "mcpPlaywright" | "force" -export const createSteps: ReadonlyArray = [ +export const orderedCreateSteps: ReadonlyArray = [ "repoUrl", "cpuLimit", "ramLimit", diff --git a/packages/app/src/docker-git/menu.ts b/packages/app/src/docker-git/menu.ts index a6f67d91..9b8fcbd9 100644 --- a/packages/app/src/docker-git/menu.ts +++ b/packages/app/src/docker-git/menu.ts @@ -189,9 +189,9 @@ const runInteractiveMenu = (): Effect.Effect => Effect.gen(function*(_) { const store: MenuSnapshotStore = { current: defaultMenuSnapshot() } const queuedInteractiveEffect: { current: InteractiveMenuEffect | null } = { current: null } - let keepRunning = true + let isKeepRunning = true - while (keepRunning) { + while (isKeepRunning) { yield* _( runGridlandMenuOnce(store, (effect) => { queuedInteractiveEffect.current = effect @@ -200,23 +200,20 @@ const runInteractiveMenu = (): Effect.Effect => const nextInteractiveEffect = queuedInteractiveEffect.current if (nextInteractiveEffect === null) { - keepRunning = false + isKeepRunning = false continue } queuedInteractiveEffect.current = null + const restoreMenu = Effect.sync(() => { + restoreMenuAfterInteractiveEffect(store) + }) + const restoreAfterInteractive = pipe(restoreMenu, Effect.zipRight(leaveTui())) yield* _( pipe( leaveTui(), Effect.zipRight(nextInteractiveEffect), - Effect.ensuring( - pipe( - Effect.sync(() => { - restoreMenuAfterInteractiveEffect(store) - }), - Effect.zipRight(leaveTui()) - ) - ) + Effect.ensuring(restoreAfterInteractive) ) ) } @@ -224,7 +221,7 @@ const runInteractiveMenu = (): Effect.Effect => export const runMenu: Effect.Effect = pipe( Effect.sync(() => process.stdin.isTTY && process.stdout.isTTY), - Effect.flatMap((hasTty) => (hasTty ? runInteractiveMenu() : renderMenuProjectSummaries())) + Effect.flatMap((hasTty) => (hasTty ? runInteractiveMenu : renderMenuProjectSummaries)()) ) export type MenuRuntimeError = MenuError diff --git a/packages/app/src/docker-git/open-project-ssh.ts b/packages/app/src/docker-git/open-project-ssh.ts index 174c55f4..1ee70607 100644 --- a/packages/app/src/docker-git/open-project-ssh.ts +++ b/packages/app/src/docker-git/open-project-ssh.ts @@ -92,7 +92,7 @@ const resolveSshPort = (sshCommand: string, fallback: number): number => { return fallback } - const parsed = Number.parseInt(match[1] ?? "", 10) + const parsed = Math.trunc(Number(match[1] ?? "")) return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback } diff --git a/packages/app/src/docker-git/open-project.ts b/packages/app/src/docker-git/open-project.ts index 64b476a0..3513b7e4 100644 --- a/packages/app/src/docker-git/open-project.ts +++ b/packages/app/src/docker-git/open-project.ts @@ -66,7 +66,7 @@ const resolveRepoSelector = (value: string) => { } } -const matchesProjectPath = (selector: string, project: ApiProjectDetails): boolean => { +const isProjectPathMatch = (selector: string, project: ApiProjectDetails): boolean => { const normalizedSelector = normalizePath(selector) if (normalizedSelector.length === 0) { return false @@ -81,7 +81,7 @@ const matchesProjectPath = (selector: string, project: ApiProjectDetails): boole normalizedProjectDir.endsWith(`/${normalizedSelector}`) } -const matchesProjectText = (selector: string, project: ApiProjectDetails): boolean => { +const isProjectTextMatch = (selector: string, project: ApiProjectDetails): boolean => { const normalizedSelector = normalizeText(selector) if (normalizedSelector.length === 0) { return false @@ -92,7 +92,7 @@ const matchesProjectText = (selector: string, project: ApiProjectDetails): boole normalizedSelector === normalizeText(project.displayName) } -const matchesProjectRepo = (selector: string, project: ApiProjectDetails): boolean => { +const isProjectRepoMatch = (selector: string, project: ApiProjectDetails): boolean => { if (!hasGithubSelector(selector)) { return false } @@ -204,7 +204,7 @@ export const selectOpenProject = ( } const directMatches = projects.filter((project) => - matchesProjectPath(trimmed, project) || matchesProjectText(trimmed, project) + isProjectPathMatch(trimmed, project) || isProjectTextMatch(trimmed, project) ) if (directMatches.length > 0) { @@ -220,7 +220,7 @@ export const selectOpenProject = ( ) } - const repoMatches = projects.filter((project) => matchesProjectRepo(trimmed, project)) + const repoMatches = projects.filter((project) => isProjectRepoMatch(trimmed, project)) return resolveUniqueProject( repoMatches, `No docker-git project matched '${trimmed}'.`, diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index ee3a3d19..0dc64901 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -100,10 +100,10 @@ const handleGithubStatusCommand = (command: Extract -) => - withControllerReady( - pipe(githubLogout(command), Effect.zipRight(Effect.log("GitHub auth removed from controller state."))) - ) +) => { + const logRemoved = Effect.log("GitHub auth removed from controller state.") + return withControllerReady(pipe(githubLogout(command), Effect.zipRight(logRemoved))) +} const handleGitlabLoginCommand = (command: Extract) => withControllerReady(pipe(gitlabLogin(command), Effect.flatMap((payload) => renderAuthPayload(payload)))) @@ -113,10 +113,10 @@ const handleGitlabStatusCommand = (command: Extract -) => - withControllerReady( - pipe(gitlabLogout(command), Effect.zipRight(Effect.log("GitLab auth removed from controller state."))) - ) +) => { + const logRemoved = Effect.log("GitLab auth removed from controller state.") + return withControllerReady(pipe(gitlabLogout(command), Effect.zipRight(logRemoved))) +} const handleGitLoginCommand = (command: Extract) => withControllerReady(pipe(gitLogin(command), Effect.flatMap((payload) => renderAuthPayload(payload)))) @@ -126,10 +126,10 @@ const handleGitStatusCommand = (command: Extract -) => - withControllerReady( - pipe(gitLogout(command), Effect.zipRight(Effect.log(`Git auth removed from controller state (${command.host}).`))) - ) +) => { + const logRemoved = Effect.log(`Git auth removed from controller state (${command.host}).`) + return withControllerReady(pipe(gitLogout(command), Effect.zipRight(logRemoved))) +} const handleCodexLoginCommand = ( command: Extract @@ -188,17 +188,17 @@ const handleGrokStatusCommand = ( const handleGrokLogoutCommand = ( command: Extract -) => - withControllerReady( - pipe(grokLogout(command), Effect.zipRight(Effect.log("Grok auth removed from controller state."))) - ) +) => { + const logRemoved = Effect.log("Grok auth removed from controller state.") + return withControllerReady(pipe(grokLogout(command), Effect.zipRight(logRemoved))) +} const handleCodexLogoutCommand = ( command: Extract -) => - withControllerReady( - pipe(codexLogout(command), Effect.zipRight(Effect.log("Codex auth removed from controller state."))) - ) +) => { + const logRemoved = Effect.log("Codex auth removed from controller state.") + return withControllerReady(pipe(codexLogout(command), Effect.zipRight(logRemoved))) +} export const dispatchRoutedAuthCommand = ( command: RoutedAuthCommand diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 139433fb..4473788a 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -106,22 +106,21 @@ const handleOpenCommand = (command: Extract withControllerReady(pipe(listProjects(), Effect.flatMap((projects) => renderProjectList(projects)))) -const handleDownAllCommand = () => - withControllerReady(pipe(downAllProjects(), Effect.zipRight(Effect.log("All docker-git projects were stopped.")))) +const handleDownAllCommand = () => { + const logStopped = Effect.log("All docker-git projects were stopped.") + return withControllerReady(pipe(downAllProjects(), Effect.zipRight(logStopped))) +} -const handleApplyAllCommand = (command: Extract) => - withControllerReady( - pipe( - applyAllProjects(command.activeOnly), - Effect.zipRight( - Effect.log( - command.activeOnly - ? "Applied docker-git config to running projects." - : "Applied docker-git config to all projects." - ) - ) - ) +const handleApplyAllCommand = (command: Extract) => { + const logApplied = Effect.log( + command.activeOnly + ? "Applied docker-git config to running projects." + : "Applied docker-git config to all projects." ) + return withControllerReady( + pipe(applyAllProjects(command.activeOnly), Effect.zipRight(logApplied)) + ) +} const logOutput = (output: string) => Effect.log(output) @@ -158,13 +157,12 @@ const handleSessionsListCommand = (command: Extract) => - withControllerReady( - pipe( - stopContainerTask(command.projectDir, command.pid), - Effect.zipRight(Effect.log(`Sent SIGTERM to PID ${command.pid}`)) - ) +const handleSessionsKillCommand = (command: Extract) => { + const logSignalled = Effect.log(`Sent SIGTERM to PID ${command.pid}`) + return withControllerReady( + pipe(stopContainerTask(command.projectDir, command.pid), Effect.zipRight(logSignalled)) ) +} const handleSessionsLogsCommand = (command: Extract) => withControllerReady( diff --git a/packages/app/src/docker-git/project-event-lines.ts b/packages/app/src/docker-git/project-event-lines.ts index 586c97a3..37c72069 100644 --- a/packages/app/src/docker-git/project-event-lines.ts +++ b/packages/app/src/docker-git/project-event-lines.ts @@ -24,10 +24,10 @@ const formatProjectStatusLine = (payload: JsonValue | undefined): string | null const formatProjectSshLine = (payload: JsonValue | undefined): string | null => { const phase = readProjectEventPayloadField(payload, "phase") - const sessionId = readProjectEventPayloadField(payload, "sessionId") if (phase === null) { return null } + const sessionId = readProjectEventPayloadField(payload, "sessionId") return sessionId === null ? `[ssh] ${phase}` : `[ssh] ${phase} (${sessionId})` } diff --git a/packages/app/src/docker-git/terminal-session-client.ts b/packages/app/src/docker-git/terminal-session-client.ts index bf69eefc..97a3b225 100644 --- a/packages/app/src/docker-git/terminal-session-client.ts +++ b/packages/app/src/docker-git/terminal-session-client.ts @@ -72,7 +72,7 @@ const resolveTerminalWebSocketUrl = (websocketPath: string): string => { apiBaseUrl.pathname = `${apiBaseUrl.pathname.replace(/\/$/u, "")}${websocketPath}` apiBaseUrl.searchParams.set("cols", String(cols)) apiBaseUrl.searchParams.set("rows", String(rows)) - return apiBaseUrl.toString() + return apiBaseUrl.href } const sendResize = (socket: WebSocket): void => { @@ -84,9 +84,9 @@ const sendResize = (socket: WebSocket): void => { })) } -const setRawMode = (enabled: boolean): void => { +const setRawMode = (isEnabled: boolean): void => { if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") { - process.stdin.setRawMode(enabled) + process.stdin.setRawMode(isEnabled) } } @@ -313,10 +313,12 @@ const registerTerminalSocketHandlers = (socket: WebSocket, handlers: TerminalHan const createTerminalCancel = (lifecycle: TerminalLifecycle, cleanup: () => void): Effect.Effect => Effect.sync(() => { - if (!lifecycle.settled) { - lifecycle.settled = true - cleanup() + if (lifecycle.settled) { + return } + + lifecycle.settled = true + cleanup() }) export const attachTerminalSession = ( diff --git a/packages/app/src/shared/auth-stream-markers.ts b/packages/app/src/shared/auth-stream-markers.ts index 085f4246..e2a44af6 100644 --- a/packages/app/src/shared/auth-stream-markers.ts +++ b/packages/app/src/shared/auth-stream-markers.ts @@ -40,7 +40,7 @@ export const authStreamMarkerExitCode = (output: string, markers: AuthStreamMark : failureLine.slice(markers.errorPrefix.length) } -export const authStreamSucceeded = (output: string, markers: AuthStreamMarkers): boolean => +export const didAuthStreamSucceed = (output: string, markers: AuthStreamMarkers): boolean => output.includes(markers.success) const providerLoginFailureMessage = ( diff --git a/packages/app/src/ui/primitives-gridland.tsx b/packages/app/src/ui/primitives-gridland.tsx index 3393086f..49432271 100644 --- a/packages/app/src/ui/primitives-gridland.tsx +++ b/packages/app/src/ui/primitives-gridland.tsx @@ -52,8 +52,8 @@ const boxProps = ({ children, ...props }: UiBoxProps): GridlandBoxHostProps => ( const textProps = ({ backgroundColor, children, wrap, ...props }: UiTextProps): GridlandTextHostProps => ({ ...props, - ...(backgroundColor === undefined ? {} : { bg: backgroundColor }), - ...(wrap === "truncate" ? { truncate: true } : {}), + ...(backgroundColor !== undefined && { bg: backgroundColor }), + ...((wrap === "truncate") && { truncate: true }), children }) diff --git a/packages/app/src/ui/primitives-web.tsx b/packages/app/src/ui/primitives-web.tsx index dd520453..4d7b9412 100644 --- a/packages/app/src/ui/primitives-web.tsx +++ b/packages/app/src/ui/primitives-web.tsx @@ -95,7 +95,7 @@ export const webPrimitives = { onClick, style: { ...baseStyle(props), - ...(onClick === undefined ? {} : interactiveStyle(props.width)) + ...(onClick !== undefined && interactiveStyle(props.width)) }, type: onClick === undefined ? undefined : "button" }), @@ -130,7 +130,7 @@ const horizontalArrowAction = ( type TextInputKeyboardHandlers = { readonly onArrowLeft: (() => void) | undefined readonly onArrowRight: (() => void) | undefined - readonly onEnter: ((shift: boolean) => void) | undefined + readonly onEnter: ((isShift: boolean) => void) | undefined readonly onEscape: (() => void) | undefined } diff --git a/packages/app/src/ui/primitives.tsx b/packages/app/src/ui/primitives.tsx index c7e48118..cf4f1234 100644 --- a/packages/app/src/ui/primitives.tsx +++ b/packages/app/src/ui/primitives.tsx @@ -58,7 +58,7 @@ export type UiTextInputProps = { readonly onChange: (value: string) => void readonly onArrowLeft?: () => void readonly onArrowRight?: () => void - readonly onEnter?: (shift: boolean) => void + readonly onEnter?: (isShift: boolean) => void readonly onEscape?: () => void readonly placeholder?: string readonly secret?: boolean diff --git a/packages/app/src/web/actions-auth.ts b/packages/app/src/web/actions-auth.ts index 111c0322..f2e179c4 100644 --- a/packages/app/src/web/actions-auth.ts +++ b/packages/app/src/web/actions-auth.ts @@ -208,13 +208,13 @@ const handleBrowserMenuAction = ( action: "Back" | "Refresh", context: BrowserActionContext, refresh: (context: BrowserActionContext) => void, - returnToProjectPicker: boolean + shouldReturnToProjectPicker: boolean ): void => { if (action === "Refresh") { refresh(context) return } - if (returnToProjectPicker) { + if (shouldReturnToProjectPicker) { context.setActionPrompt(null) context.setActiveScreen(projectPickerScreen()) context.setMessage("Returned to project selection.") diff --git a/packages/app/src/web/actions-codex-oauth.ts b/packages/app/src/web/actions-codex-oauth.ts index cb46a359..72b1fe55 100644 --- a/packages/app/src/web/actions-codex-oauth.ts +++ b/packages/app/src/web/actions-codex-oauth.ts @@ -32,13 +32,14 @@ export const runCodexLogoutMutation = ( context: BrowserActionContext ) => { const label = defaultLabel(values["label"]) + const loadAuthState = Effect.all({ + githubStatus: loadGithubStatus(), + snapshot: loadAuthSnapshot() + }) withBusy({ context, effect: logoutCodex(nullableValue(values["label"])).pipe( - Effect.zipRight(Effect.all({ - githubStatus: loadGithubStatus(), - snapshot: loadAuthSnapshot() - })) + Effect.zipRight(loadAuthState) ), label: "CodexLogout", onSuccess: ({ githubStatus, snapshot }) => { diff --git a/packages/app/src/web/actions-databases.ts b/packages/app/src/web/actions-databases.ts index f5b3510d..c9d6f0f2 100644 --- a/packages/app/src/web/actions-databases.ts +++ b/packages/app/src/web/actions-databases.ts @@ -2,7 +2,7 @@ import { Effect } from "effect" import { type BrowserActionContext, - confirmAction, + isActionConfirmed, nullableValue, requireSelectedProjectId, withBusy, @@ -24,7 +24,7 @@ import { restartProjectDatabaseEditor, saveProjectDatabaseProfile } from "./api.js" -import { openUrl } from "./open-url.js" +import { didOpenUrl } from "./open-url.js" const requireSelectedProjectIdForDatabases = (context: BrowserActionContext): string | null => { const projectId = requireSelectedProjectId(context) @@ -105,7 +105,7 @@ export const deleteSelectedDatabaseProfile = ( profile: ProjectDatabaseProfile ) => { const projectId = requireSelectedProjectId(context) - if (projectId === null || !confirmAction(`Delete database profile ${profile.label}?`)) { + if (projectId === null || !isActionConfirmed(`Delete database profile ${profile.label}?`)) { return } withBusy({ @@ -149,7 +149,7 @@ export const closeSelectedDatabaseForward = ( profile: ProjectDatabaseProfile ) => { const projectId = requireSelectedProjectId(context) - if (projectId === null || !confirmAction(`Close external access for ${profile.label}?`)) { + if (projectId === null || !isActionConfirmed(`Close external access for ${profile.label}?`)) { return } withBusy({ @@ -173,7 +173,7 @@ export const openSelectedProjectDatabaseEditor = (context: BrowserActionContext) context.setDatabaseSession(session) const editorUrl = projectDatabaseEditorUrl(session) context.setMessage( - openUrl(editorUrl) + didOpenUrl(editorUrl) ? `SQL editor opened: ${editorUrl}.` : `Popup was blocked. Open ${editorUrl} manually.` ) diff --git a/packages/app/src/web/actions-port-forwards.ts b/packages/app/src/web/actions-port-forwards.ts index d0bc44be..ca92a0b1 100644 --- a/packages/app/src/web/actions-port-forwards.ts +++ b/packages/app/src/web/actions-port-forwards.ts @@ -11,7 +11,7 @@ const parsePortInput = (value: string): number | null => { if (!/^\d+$/u.test(trimmed)) { return null } - const port = Number.parseInt(trimmed, 10) + const port = Number(trimmed) return port > 0 && port <= 65_535 ? port : null } diff --git a/packages/app/src/web/actions-project-menu-commands.ts b/packages/app/src/web/actions-project-menu-commands.ts index 40f61e73..56fb9f23 100644 --- a/packages/app/src/web/actions-project-menu-commands.ts +++ b/packages/app/src/web/actions-project-menu-commands.ts @@ -1,6 +1,6 @@ import { type BrowserActionContext, - confirmAction, + isActionConfirmed, projectActionLabel, requireSelectedProjectId, withBusy @@ -33,7 +33,7 @@ const runProjectOutputAction = ( const runDownProject = (context: BrowserActionContext) => { const projectId = requireSelectedProjectId(context) - if (projectId === null || !confirmAction(`Stop ${projectActionLabel(context)}?`)) { + if (projectId === null || !isActionConfirmed(`Stop ${projectActionLabel(context)}?`)) { return } withBusy({ @@ -49,7 +49,7 @@ const runDownProject = (context: BrowserActionContext) => { const runDeleteProject = (context: BrowserActionContext) => { const projectId = requireSelectedProjectId(context) - if (projectId === null || !confirmAction(`Delete ${projectActionLabel(context)}?`)) { + if (projectId === null || !isActionConfirmed(`Delete ${projectActionLabel(context)}?`)) { return } withBusy({ @@ -68,7 +68,7 @@ const runDeleteProject = (context: BrowserActionContext) => { } const runDownAllProjects = (context: BrowserActionContext) => { - if (!confirmAction("Stop all docker-git projects?")) { + if (!isActionConfirmed("Stop all docker-git projects?")) { return } withBusy({ @@ -83,7 +83,7 @@ const runDownAllProjects = (context: BrowserActionContext) => { } export const runApplyAllProjects = (context: BrowserActionContext) => { - if (!confirmAction("Apply docker-git config to all projects?")) { + if (!isActionConfirmed("Apply docker-git config to all projects?")) { return } withBusy({ @@ -135,6 +135,6 @@ export const runProjectMenuCommand = ( runDeleteProject(context) return } - globalThis.close() + close() context.setMessage("Quit requested. If the browser blocked window.close(), close the tab manually.") } diff --git a/packages/app/src/web/actions-project-terminal.ts b/packages/app/src/web/actions-project-terminal.ts index fbb4f4fe..0e5aa899 100644 --- a/packages/app/src/web/actions-project-terminal.ts +++ b/packages/app/src/web/actions-project-terminal.ts @@ -46,7 +46,7 @@ const resolveProjectTerminalKey = ( } const randomHex = (bytes: number): string => { - const webCrypto = "crypto" in globalThis ? globalThis.crypto : null + const webCrypto = "crypto" in globalThis ? crypto : null if (webCrypto !== null && typeof webCrypto.getRandomValues === "function") { const values = new Uint8Array(bytes) webCrypto.getRandomValues(values) @@ -72,7 +72,7 @@ const formatUuidV4 = (hex: string): string => { } const createPendingTerminalSessionId = (): string => { - const webCrypto = "crypto" in globalThis ? globalThis.crypto : null + const webCrypto = "crypto" in globalThis ? crypto : null if (webCrypto !== null && typeof webCrypto.randomUUID === "function") { return webCrypto.randomUUID() } @@ -162,7 +162,7 @@ const renderPendingTerminalSession = ( projectDisplayName: runtime.projectDisplayName, projectId: runtime.projectId, projectKey: runtime.projectKey, - ...(message === undefined ? {} : { message }) + ...(message !== undefined && { message }) }) const closeStream = (runtime: ConnectProjectRuntime): void => { @@ -170,7 +170,7 @@ const closeStream = (runtime: ConnectProjectRuntime): void => { runtime.stream = null } -const showPendingTerminalError = ( +const didShowPendingTerminalError = ( context: BrowserActionContext, runtime: ConnectProjectRuntime, error: string @@ -200,7 +200,7 @@ const attachCreatedSession = ( effect: loadProjectTerminalSession(runtime.projectKey, sessionId), label: "Attaching SSH terminal", onFailure: (error) => { - showPendingTerminalError(context, runtime, error) + didShowPendingTerminalError(context, runtime, error) closeStream(runtime) }, onSuccess: (session) => { @@ -226,7 +226,7 @@ const handleProjectEvent = ( ): void => { const failure = readTerminalStartupFailure(event, requestId) if (failure !== null) { - if (showPendingTerminalError(context, runtime, failure)) { + if (didShowPendingTerminalError(context, runtime, failure)) { context.setMessage(failure) } closeStream(runtime) @@ -268,7 +268,7 @@ const startTerminalSession = (context: BrowserActionContext, runtime: ConnectPro effect: startProjectTerminalSession(runtime.projectKey, runtime.pendingSessionId), label: "Opening SSH terminal", onFailure: (error) => { - showPendingTerminalError(context, runtime, error) + didShowPendingTerminalError(context, runtime, error) }, onSuccess: (accepted) => { appendOutputLine(context, `[ssh.prepare] SSH terminal request accepted (${accepted.requestId})`) diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index 58a9f2d9..2f093358 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -6,7 +6,7 @@ import { connectProjectById } from "./actions-project-terminal.js" import { loadSelectedProjectPrompts } from "./actions-prompts.js" import { type BrowserActionContext, - confirmAction, + isActionConfirmed, projectActionLabel, requireSelectedProjectId, requireSelectedProjectKey, @@ -73,7 +73,7 @@ export const applyProjectById = ( const label = context.selectedProjectId === projectId ? projectActionLabel(context) : projectId - if (!confirmAction(applyProjectConfirmMessage(label, gpu))) { + if (!isActionConfirmed(applyProjectConfirmMessage(label, gpu))) { return } context.setSelectedProjectId(projectId) diff --git a/packages/app/src/web/actions-share.ts b/packages/app/src/web/actions-share.ts index 314d0b4c..9b6a8a1d 100644 --- a/packages/app/src/web/actions-share.ts +++ b/packages/app/src/web/actions-share.ts @@ -10,7 +10,7 @@ import { const tryCloudflareSuffix = ".trycloudflare.com" -const currentPanelUrl = (): string => `${globalThis.location.origin}/` +const currentPanelUrl = (): string => `${location.origin}/` const isTryCloudflareOrigin = (panelUrl: string): boolean => { const url = new URL(panelUrl) @@ -82,7 +82,7 @@ export const copyPanelShareTunnelUrl = ( withBusy({ context, effect: Effect.tryPromise({ - try: () => globalThis.navigator.clipboard.writeText(publicUrl), + try: () => navigator.clipboard.writeText(publicUrl), catch: () => "Failed to copy tunnel URL." }), label: "Copying tunnel URL", diff --git a/packages/app/src/web/actions-shared.ts b/packages/app/src/web/actions-shared.ts index 48c486dd..fab9edaa 100644 --- a/packages/app/src/web/actions-shared.ts +++ b/packages/app/src/web/actions-shared.ts @@ -77,8 +77,8 @@ export type BrowserActionContext = { readonly setSelectedProjectId: Setter } -export const confirmAction = (label: string): boolean => { - const dialog = globalThis.confirm +export const isActionConfirmed = (label: string): boolean => { + const dialog = confirm return typeof dialog === "function" && dialog(label) } @@ -94,20 +94,21 @@ export const nullableValue = (value: string | undefined): string | null => { export const withBusy = ({ context, effect, label, onFailure, onFinally, onSuccess }: BusyAction) => { context.setBusyLabel(label) - void Effect.runPromise( - effect.pipe( - Effect.match({ - onFailure: (error) => { - context.setMessage(error) - onFailure?.(error) - }, - onSuccess - }) - ) - ).finally(() => { + const finalizeBusy = Effect.sync(() => { context.setBusyLabel(null) onFinally?.() }) + const busyEffect = effect.pipe( + Effect.match({ + onFailure: (error) => { + context.setMessage(error) + onFailure?.(error) + }, + onSuccess + }), + Effect.ensuring(finalizeBusy) + ) + void Effect.runPromise(busyEffect) } export const appendOutputChunk = (context: BrowserActionContext, chunk: string) => { @@ -164,7 +165,7 @@ export const withSelectedProjectBusy = ( }) } -export const requireGithubAuthConfigured = (context: BrowserActionContext): boolean => { +export const isGithubAuthConfigured = (context: BrowserActionContext): boolean => { if (context.githubStatus === null) { context.setSelectedMenuIndex(browserMenuIndex("Auth")) context.setActiveScreen({ tag: "Auth" }) diff --git a/packages/app/src/web/actions-skiller.ts b/packages/app/src/web/actions-skiller.ts index 7fd4690d..5b337476 100644 --- a/packages/app/src/web/actions-skiller.ts +++ b/packages/app/src/web/actions-skiller.ts @@ -16,7 +16,7 @@ export type SkillerLaunch = { readonly trpcBasePath: string } -export const skillerLaunchMessage = (launch: SkillerLaunch, openedPath: string, opened: boolean): string => { +export const skillerLaunchMessage = (launch: SkillerLaunch, openedPath: string, wasOpened: boolean): string => { const pid = launch.pid === null ? "unknown pid" : `pid ${launch.pid}` const state = launch.alreadyRunning ? `Skiller is already running (${pid}). Log: ${launch.logPath}` @@ -24,23 +24,23 @@ export const skillerLaunchMessage = (launch: SkillerLaunch, openedPath: string, const scope = launch.scope === null ? "" : ` Container FS: ${launch.scope.containerName}:${launch.scope.containerProjectPath}.` - return opened + return wasOpened ? `${state}.${scope} Opened ${openedPath}.` : `${state}.${scope} Popup was blocked. Open ${openedPath} manually.` } export const openPreparedSkillerLaunch = (launch: SkillerLaunch, preparedUrl: PreparedOpenUrl): string => { const openedPath = launch.appPath - const opened = preparedUrl.navigate(openedPath) + const isOpened = preparedUrl.navigate(openedPath) if (launch.mode === "external") { const scope = launch.scope === null ? "" : ` Container FS: ${launch.scope.containerName}:${launch.scope.containerProjectPath}.` - return opened + return isOpened ? `Skiller Web opened.${scope} Opened ${openedPath}.` : `Skiller Web popup was blocked.${scope} Open ${openedPath} manually.` } - return skillerLaunchMessage(launch, openedPath, opened) + return skillerLaunchMessage(launch, openedPath, isOpened) } export const openSkillerApp = ( diff --git a/packages/app/src/web/actions-tasks.ts b/packages/app/src/web/actions-tasks.ts index 37038be0..625b91a2 100644 --- a/packages/app/src/web/actions-tasks.ts +++ b/packages/app/src/web/actions-tasks.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" -import { type BrowserActionContext, confirmAction, requireSelectedProjectId, withBusy } from "./actions-shared.js" +import { type BrowserActionContext, isActionConfirmed, requireSelectedProjectId, withBusy } from "./actions-shared.js" import { loadProjectTaskLogs, loadProjectTasks, stopProjectTask } from "./api.js" import type { ContainerTaskSnapshot } from "./api.js" @@ -101,11 +101,11 @@ export const loadProjectTasksById = ( projectId: string, options?: LoadSelectedProjectTasksOptions ) => { - const includeDefault = options?.includeDefault ?? context.projectTasksIncludeDefault + const isIncludeDefault = options?.includeDefault ?? context.projectTasksIncludeDefault withBusy({ context, - effect: loadProjectTasks(projectId, includeDefault), - label: includeDefault ? "Loading all container tasks" : "Loading container tasks", + effect: loadProjectTasks(projectId, isIncludeDefault), + label: isIncludeDefault ? "Loading all container tasks" : "Loading container tasks", onSuccess: (snapshot) => { context.setProjectTasks(snapshot) if (options?.silent !== true) { @@ -128,15 +128,15 @@ export const loadSelectedProjectTasks = ( export const setSelectedProjectTasksIncludeDefault = ( context: BrowserActionContext, - includeDefault: boolean + shouldIncludeDefault: boolean ) => { - context.setProjectTasksIncludeDefault(includeDefault) + context.setProjectTasksIncludeDefault(shouldIncludeDefault) context.setProjectTaskLogs("") const projectId = requireProjectIdForTasks(context) if (projectId === null) { return } - loadProjectTasksById(context, projectId, { includeDefault }) + loadProjectTasksById(context, projectId, { includeDefault: shouldIncludeDefault }) } export const stopSelectedProjectTask = ( @@ -144,7 +144,7 @@ export const stopSelectedProjectTask = ( pid: number ) => { withSelectedProjectTask(context, pid, (selected) => { - if (!confirmAction(`Stop PID ${selected.pid}?`)) { + if (!isActionConfirmed(`Stop PID ${selected.pid}?`)) { return } withBusy({ diff --git a/packages/app/src/web/actions-visible-auth-stream.ts b/packages/app/src/web/actions-visible-auth-stream.ts index 82a8a155..d9b88ef9 100644 --- a/packages/app/src/web/actions-visible-auth-stream.ts +++ b/packages/app/src/web/actions-visible-auth-stream.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { authStreamMarkerExitCode, type AuthStreamMarkers, - authStreamSucceeded, + didAuthStreamSucceed, makeVisibleAuthStreamWriter } from "../shared/auth-stream-markers.js" import { @@ -40,12 +40,13 @@ export const runVisibleAuthStreamMutation = (config: VisibleAuthStreamMutationCo }) config.context.setOutput("") config.context.setMessage(config.startMessage) + const flushVisiblePending = Effect.sync(writer.flushVisiblePending) withBusy({ context: config.context, effect: config.runStream(nullableValue(config.values["label"]), writer.writeChunk).pipe( - Effect.ensuring(Effect.sync(writer.flushVisiblePending)), + Effect.ensuring(flushVisiblePending), Effect.flatMap((output) => - authStreamSucceeded(output, config.markers) + didAuthStreamSucceed(output, config.markers) ? Effect.all({ githubStatus: loadGithubStatus(), snapshot: loadAuthSnapshot() diff --git a/packages/app/src/web/actions.ts b/packages/app/src/web/actions.ts index eb0ac13f..de488a06 100644 --- a/packages/app/src/web/actions.ts +++ b/packages/app/src/web/actions.ts @@ -1,7 +1,7 @@ import { refreshAuthPanel, refreshProjectAuthPanel } from "./actions-auth.js" import { runProjectMenuAction } from "./actions-projects.js" import { startPanelShareTunnel } from "./actions-share.js" -import { type BrowserActionContext, requireGithubAuthConfigured } from "./actions-shared.js" +import { type BrowserActionContext, isGithubAuthConfigured } from "./actions-shared.js" import { shouldBlockMenuForGithubAuth } from "./github-auth-gate.js" import type { BrowserMenuTag } from "./menu.js" @@ -64,7 +64,7 @@ export const runBrowserMenuAction = ( currentMenu: BrowserMenuTag, context: BrowserActionContext ) => { - if (shouldBlockMenuForGithubAuth(context.githubStatus, currentMenu) && !requireGithubAuthConfigured(context)) { + if (shouldBlockMenuForGithubAuth(context.githubStatus, currentMenu) && !isGithubAuthConfigured(context)) { return } if (currentMenu === "Auth") { diff --git a/packages/app/src/web/api-skiller-schema.ts b/packages/app/src/web/api-skiller-schema.ts index ed834156..48eeb035 100644 --- a/packages/app/src/web/api-skiller-schema.ts +++ b/packages/app/src/web/api-skiller-schema.ts @@ -14,13 +14,16 @@ export const SkillerScopeResponseSchema = Schema.Struct({ sshUser: Schema.String }) +const bundledModeLiteral = Schema.Literal("bundled") +const externalModeLiteral = Schema.Literal("external") + export const SkillerLaunchResponseSchema = Schema.Struct({ alreadyRunning: Schema.Boolean, appPath: Schema.String, backendUrl: Schema.NullOr(Schema.String), logPath: Schema.String, mode: Schema.optionalWith( - Schema.Union(Schema.Literal("bundled"), Schema.Literal("external")), + Schema.Union(bundledModeLiteral, externalModeLiteral), { default: () => "bundled" } ), ok: Schema.Boolean, diff --git a/packages/app/src/web/api-tasks.ts b/packages/app/src/web/api-tasks.ts index 6e87e87d..c7fc7357 100644 --- a/packages/app/src/web/api-tasks.ts +++ b/packages/app/src/web/api-tasks.ts @@ -3,13 +3,13 @@ import { Effect } from "effect" import { requestJson, requestText } from "./api-http.js" import { ContainerTaskSnapshotResponseSchema, OutputResponseSchema } from "./api-schema.js" -const projectTasksPath = (projectId: string, includeDefault: boolean): string => - `/projects/${encodeURIComponent(projectId)}/tasks${includeDefault ? "?includeDefault=true" : ""}` +const projectTasksPath = (projectId: string, shouldIncludeDefault: boolean): string => + `/projects/${encodeURIComponent(projectId)}/tasks${shouldIncludeDefault ? "?includeDefault=true" : ""}` -export const loadProjectTasks = (projectId: string, includeDefault = false) => +export const loadProjectTasks = (projectId: string, shouldIncludeDefault = false) => requestJson( "GET", - projectTasksPath(projectId, includeDefault), + projectTasksPath(projectId, shouldIncludeDefault), ContainerTaskSnapshotResponseSchema ).pipe( Effect.map((response) => response.snapshot) diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index 9154d24b..73ca2dfc 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -271,8 +271,8 @@ export const deleteProject = (projectId: string) => export const downAllProjects = () => requestText("POST", "/projects/down-all").pipe(Effect.asVoid) -export const applyAllProjects = (activeOnly: boolean) => - requestText("POST", "/projects/apply-all", { activeOnly }).pipe(Effect.asVoid) +export const applyAllProjects = (shouldApplyActiveOnly: boolean) => + requestText("POST", "/projects/apply-all", { activeOnly: shouldApplyActiveOnly }).pipe(Effect.asVoid) export const loadGithubStatus = () => requestJson("GET", "/auth/github/status", GithubStatusResponseSchema).pipe( diff --git a/packages/app/src/web/app-ready-browser-shortcuts-hook.ts b/packages/app/src/web/app-ready-browser-shortcuts-hook.ts index f7108fdc..1770ceac 100644 --- a/packages/app/src/web/app-ready-browser-shortcuts-hook.ts +++ b/packages/app/src/web/app-ready-browser-shortcuts-hook.ts @@ -11,9 +11,9 @@ export const useBrowserShortcuts = ({ ...args }: BrowserShortcutArgs) => { const handleKeyDown = (event: KeyboardEvent) => { onKeyDown(event) } - globalThis.addEventListener("keydown", handleKeyDown) + addEventListener("keydown", handleKeyDown) return () => { - globalThis.removeEventListener("keydown", handleKeyDown) + removeEventListener("keydown", handleKeyDown) } }, [onKeyDown]) } diff --git a/packages/app/src/web/app-ready-content-screen.tsx b/packages/app/src/web/app-ready-content-screen.tsx index 5ca81e9d..4c8bc270 100644 --- a/packages/app/src/web/app-ready-content-screen.tsx +++ b/packages/app/src/web/app-ready-content-screen.tsx @@ -28,7 +28,7 @@ export const ContentScreen = ({ props, title }: ContentScreenProps): JSX.Element authSnapshot={props.authSnapshot} compact={props.viewportLayout.compact} controllerCwd={props.controllerCwd} - createView={props.createView} + creationView={props.creationView} currentMenu={props.currentMenu} dashboardRefreshTick={props.dashboardRefreshTick} githubStatus={props.githubStatus} diff --git a/packages/app/src/web/app-ready-controller.ts b/packages/app/src/web/app-ready-controller.ts index cbca0ae3..d3a76e49 100644 --- a/packages/app/src/web/app-ready-controller.ts +++ b/packages/app/src/web/app-ready-controller.ts @@ -79,7 +79,7 @@ const useProjectSyncEffects = (args: ReadySideEffectsArgs) => { } const useReadyResetEffects = (args: ReadySideEffectsArgs) => { - useCreateMenuReset(args.currentMenu, args.state.setCreateView) + useCreateMenuReset(args.currentMenu, args.state.setCreationView) useActionPromptReset(args.state.actionPrompt, args.currentMenu, args.state.setActionPrompt) useGithubAuthGate({ actionPrompt: args.state.actionPrompt, @@ -148,12 +148,12 @@ const useReadyShortcutEffects = (args: ReadySideEffectsArgs) => { context: args.actionContext, controllerCwd: args.dashboard.health.cwd, projectsRoot: args.dashboard.health.projectsRoot, - createView: args.state.createView, + creationView: args.state.creationView, currentMenu: args.currentMenu, dashboard: args.navigationDashboard, projectBrowser: args.state.projectBrowser, selectedProjectId: args.state.selectedProjectId, - setCreateView: args.state.setCreateView, + setCreationView: args.state.setCreationView, setActiveScreen: args.state.setActiveScreen, setProjectNavigationArmed: args.state.setProjectNavigationArmed, setSelectedMenuIndex: args.state.setSelectedMenuIndex, @@ -199,19 +199,19 @@ const bindCreateActions = ( state: ReturnType ) => ({ onCreateBufferChange: (buffer: string) => { - setCreateBuffer(state.createView, state.setCreateView, buffer) + setCreateBuffer(state.creationView, state.setCreationView, buffer) }, onCreateCancel: () => { - cancelCreate(actionContext, state.setCreateView) + cancelCreate(actionContext, state.setCreationView) }, onCreateSubmit: (mode: CreateSubmitMode) => { submitCreateView({ context: actionContext, controllerCwd: dashboard.health.cwd, projectsRoot: dashboard.health.projectsRoot, - createView: state.createView, + creationView: state.creationView, mode, - setCreateView: state.setCreateView + setCreationView: state.setCreationView }) } }) diff --git a/packages/app/src/web/app-ready-create.ts b/packages/app/src/web/app-ready-create.ts index eb50ac3f..358c4914 100644 --- a/packages/app/src/web/app-ready-create.ts +++ b/packages/app/src/web/app-ready-create.ts @@ -17,7 +17,7 @@ import { resolveCreateSettingsChoiceBuffer } from "../docker-git/menu-create-shared.js" import { submitCreateInputs } from "./actions-projects.js" -import { requireGithubAuthConfigured } from "./actions-shared.js" +import { isGithubAuthConfigured } from "./actions-shared.js" import type { BrowserActionContext } from "./actions.js" import type { BrowserMenuTag } from "./menu.js" import { menuScreen } from "./screen.js" @@ -32,8 +32,8 @@ type CreateKeyArgs = { readonly context: BrowserActionContext readonly controllerCwd: string readonly projectsRoot: string - readonly createView: CreateFlowView - readonly setCreateView: Setter + readonly creationView: CreateFlowView + readonly setCreationView: Setter } type CreateSubmitArgs = CreateKeyArgs & { @@ -53,32 +53,33 @@ export const resetCreateView = (): CreateFlowView => createInitialFlowView() export const cancelCreate = ( context: BrowserActionContext, - setCreateView: Setter + setCreationView: Setter ) => { - setCreateView(resetCreateView()) + setCreationView(resetCreateView()) context.setActiveScreen(menuScreen()) context.setMessage("Create cancelled.") } export const setCreateBuffer = ( - createView: CreateFlowView, - setCreateView: Setter, + creationView: CreateFlowView, + setCreationView: Setter, buffer: string ) => { - setCreateView({ ...createView, buffer, inputError: null }) + setCreationView({ ...creationView, buffer, inputError: null }) } const resolveCreateSubmitResult = ( - createContext: { readonly cwd: string; readonly projectsRoot: string }, - createView: CreateFlowView, + creationContext: { readonly cwd: string; readonly projectsRoot: string }, + creationView: CreateFlowView, mode: CreateSubmitMode ): ReturnType => { - if (isDisplayModeFlowView(createView)) { - return mode === "advance" - ? advanceCreateDisplaySettingsStep(createContext, createView) - : completeCreateDisplaySettingsFlow(createContext, createView) + if (isDisplayModeFlowView(creationView)) { + const applyDisplaySettingsStep = mode === "advance" + ? advanceCreateDisplaySettingsStep + : completeCreateDisplaySettingsFlow + return applyDisplaySettingsStep(creationContext, creationView) } - const next = advanceCreateFlow(createContext, createView, { quickCreate: mode === "quick-create" }) + const next = advanceCreateFlow(creationContext, creationView, { quickCreate: mode === "quick-create" }) return next?._tag === "Continue" ? { ...next, view: createDisplayFlowView(next.view) } : next } @@ -86,152 +87,152 @@ export const submitCreateView = ( { context, controllerCwd, - createView, + creationView, mode, projectsRoot, - setCreateView + setCreationView }: CreateSubmitArgs ): void => { - if (isCreateFlowRepoStep(createView) && createView.buffer.trim().length === 0) { - setCreateView({ ...createView, inputError: emptyRepoUrlInputError }) + if (isCreateFlowRepoStep(creationView) && creationView.buffer.trim().length === 0) { + setCreationView({ ...creationView, inputError: emptyRepoUrlInputError }) return } - if (!requireGithubAuthConfigured(context)) { + if (!isGithubAuthConfigured(context)) { return } - const createContext = { cwd: controllerCwd, projectsRoot } - const next = resolveCreateSubmitResult(createContext, createView, mode) + const creationContext = { cwd: controllerCwd, projectsRoot } + const next = resolveCreateSubmitResult(creationContext, creationView, mode) handleAdvanceCreateFlowResult(next, { onError: (error) => { context.setMessage(formatParseError(error)) }, onContinue: (view) => { - setCreateView(view) + setCreationView(view) context.setMessage(null) }, onComplete: (inputs) => { submitCreateInputs(inputs, context) - setCreateView(resetCreateView()) + setCreationView(resetCreateView()) } }) } export const useCreateMenuReset = ( currentMenu: BrowserMenuTag, - setCreateView: Setter + setCreationView: Setter ) => { useEffect(() => { if (currentMenu !== "Create") { - setCreateView(resetCreateView()) + setCreationView(resetCreateView()) } - }, [currentMenu, setCreateView]) + }, [currentMenu, setCreationView]) } -const handleCreateVerticalArrow = ( +const didHandleCreateVerticalArrow = ( event: CreateKeyboardEvent, - createView: DisplayModeFlowView, - setCreateView: Setter, + creationView: DisplayModeFlowView, + setCreationView: Setter, context: BrowserActionContext ): boolean => { - const nextView = moveCreateDisplaySettingsStep(createView, event.key === "ArrowUp" ? "up" : "down") + const nextView = moveCreateDisplaySettingsStep(creationView, event.key === "ArrowUp" ? "up" : "down") if (nextView === null) { return false } event.preventDefault() - setCreateView(nextView) + setCreationView(nextView) context.setMessage(null) return true } -const handleCreateHorizontalArrow = ( +const didHandleCreateHorizontalArrow = ( event: CreateKeyboardEvent, - createView: DisplayModeFlowView, - setCreateView: Setter, + creationView: DisplayModeFlowView, + setCreationView: Setter, context: BrowserActionContext ): boolean => { const nextBuffer = resolveCreateSettingsChoiceBuffer( - createView, + creationView, event.key === "ArrowLeft" ? "left" : "right" ) if (nextBuffer === null) { return false } event.preventDefault() - setCreateBuffer(createView, setCreateView, nextBuffer) + setCreateBuffer(creationView, setCreationView, nextBuffer) context.setMessage(null) return true } const submitCreateFromKeyboard = ( event: CreateKeyboardEvent, - { context, controllerCwd, createView, projectsRoot, setCreateView }: CreateKeyArgs + { context, controllerCwd, creationView, projectsRoot, setCreationView }: CreateKeyArgs ): void => { event.preventDefault() submitCreateView({ context, controllerCwd, projectsRoot, - createView, - mode: event.shiftKey && isCreateFlowRepoStep(createView) ? "quick-create" : "advance", - setCreateView + creationView, + mode: event.shiftKey && isCreateFlowRepoStep(creationView) ? "quick-create" : "advance", + setCreationView }) } const handleCreateArrowKey = ( event: CreateKeyboardEvent, - createView: CreateFlowView, - setCreateView: Setter, + creationView: CreateFlowView, + setCreationView: Setter, context: BrowserActionContext ): boolean | null => { if (event.key === "ArrowUp" || event.key === "ArrowDown") { - return isDisplayModeFlowView(createView) - ? handleCreateVerticalArrow(event, createView, setCreateView, context) + return isDisplayModeFlowView(creationView) + ? didHandleCreateVerticalArrow(event, creationView, setCreationView, context) : false } if (event.key === "ArrowLeft" || event.key === "ArrowRight") { - return isDisplayModeFlowView(createView) - ? handleCreateHorizontalArrow(event, createView, setCreateView, context) + return isDisplayModeFlowView(creationView) + ? didHandleCreateHorizontalArrow(event, creationView, setCreationView, context) : false } return null } -const handleCreateTextKey = ( +const didHandleCreateTextKey = ( event: CreateKeyboardEvent, - createView: CreateFlowView, - setCreateView: Setter + creationView: CreateFlowView, + setCreationView: Setter ): boolean => { const nextBuffer = nextBufferValue( createCharacterInput(event), { backspace: event.key === "Backspace", delete: event.key === "Delete" }, - createView.buffer + creationView.buffer ) if (nextBuffer === null) { return false } event.preventDefault() - setCreateBuffer(createView, setCreateView, nextBuffer) + setCreateBuffer(creationView, setCreationView, nextBuffer) return true } -export const handleCreateKey = ( +export const didHandleCreateKey = ( event: CreateKeyboardEvent, - { context, controllerCwd, createView, projectsRoot, setCreateView }: CreateKeyArgs + { context, controllerCwd, creationView, projectsRoot, setCreationView }: CreateKeyArgs ): boolean => { if (event.key === "Escape") { event.preventDefault() - cancelCreate(context, setCreateView) + cancelCreate(context, setCreationView) return true } - const arrowHandled = handleCreateArrowKey(event, createView, setCreateView, context) + const arrowHandled = handleCreateArrowKey(event, creationView, setCreationView, context) if (arrowHandled !== null) { return arrowHandled } if (event.key === "Enter") { - submitCreateFromKeyboard(event, { context, controllerCwd, createView, projectsRoot, setCreateView }) + submitCreateFromKeyboard(event, { context, controllerCwd, creationView, projectsRoot, setCreationView }) return true } - return handleCreateTextKey(event, createView, setCreateView) + return didHandleCreateTextKey(event, creationView, setCreationView) } diff --git a/packages/app/src/web/app-ready-hooks.ts b/packages/app/src/web/app-ready-hooks.ts index 4d1164e8..4f0b187a 100644 --- a/packages/app/src/web/app-ready-hooks.ts +++ b/packages/app/src/web/app-ready-hooks.ts @@ -120,7 +120,7 @@ export const useProjectNavigationReset = ( }, [currentMenu, setProjectNavigationArmed]) } -const maybeRefreshGithubStatus = ({ context, githubStatus }: PanelAutoloadArgs): boolean => { +const didRefreshGithubStatus = ({ context, githubStatus }: PanelAutoloadArgs): boolean => { if (githubStatus !== null) { return false } @@ -162,7 +162,7 @@ const maybeLoadProjectPickerInfo = ( } const loadReadyPanel = (args: PanelAutoloadArgs): void => { - if (maybeRefreshGithubStatus(args) && args.currentMenu !== "Share") { + if (didRefreshGithubStatus(args) && args.currentMenu !== "Share") { return } maybeRefreshAuthScreen(args) diff --git a/packages/app/src/web/app-ready-layout.tsx b/packages/app/src/web/app-ready-layout.tsx index 4611bc86..88373e65 100644 --- a/packages/app/src/web/app-ready-layout.tsx +++ b/packages/app/src/web/app-ready-layout.tsx @@ -39,7 +39,7 @@ export type ReadyLayoutProps = { readonly controllerCwd: string readonly dashboardRefreshTick: number readonly projectsRoot: string - readonly createView: CreateFlowView + readonly creationView: CreateFlowView readonly currentMenu: BrowserMenuTag readonly dashboard: DashboardData readonly databaseConnectionInput: string @@ -94,7 +94,7 @@ export type ReadyLayoutProps = { readonly onRefreshProjectSkills: () => void readonly onRefreshProjectTasks: () => void readonly onRefreshPanelShareTunnel: () => void - readonly onProjectTasksIncludeDefaultChange: (includeDefault: boolean) => void + readonly onProjectTasksIncludeDefaultChange: (shouldIncludeDefault: boolean) => void readonly onRestartProjectDatabaseEditor: () => void readonly onSaveDatabaseProfile: () => void readonly onSaveProjectPrompt: (kind: ProjectPromptKind, content: string) => void diff --git a/packages/app/src/web/app-ready-menu-screen.tsx b/packages/app/src/web/app-ready-menu-screen.tsx index 8d18b355..ae1a0437 100644 --- a/packages/app/src/web/app-ready-menu-screen.tsx +++ b/packages/app/src/web/app-ready-menu-screen.tsx @@ -15,11 +15,11 @@ type MainMenuScreenProps = { readonly viewportLayout: ViewportLayout } -const menuGap = (compact: boolean): number | string => compact ? "4px" : 1 +const menuGap = (isCompact: boolean): number | string => isCompact ? "4px" : 1 -const menuPadding = (compact: boolean): number | string => compact ? "8px" : 2 +const menuPadding = (isCompact: boolean): number | string => isCompact ? "8px" : 2 -const menuMaxWidth = (compact: boolean): string => compact ? "100%" : "720px" +const menuMaxWidth = (isCompact: boolean): string => isCompact ? "100%" : "720px" const MenuStatusLines = ( { @@ -46,7 +46,7 @@ const MainMenuItem = ( } ): JSX.Element => { const item = browserMenuItems[index] - const selected = index === selectedMenuIndex + const isSelected = index === selectedMenuIndex return ( { @@ -54,8 +54,8 @@ const MainMenuItem = ( onOpenMenuScreen(index) }} > - - {selected ? "> " : " "} + + {isSelected ? "> " : " "} {index + 1}. {item?.label ?? index + 1} diff --git a/packages/app/src/web/app-ready-project-action-bar.tsx b/packages/app/src/web/app-ready-project-action-bar.tsx index f6a81c61..53f357f4 100644 --- a/packages/app/src/web/app-ready-project-action-bar.tsx +++ b/packages/app/src/web/app-ready-project-action-bar.tsx @@ -41,14 +41,14 @@ const ProjectSelectionSummary = ( props: Pick ): JSX.Element => { const selectedGpu = selectedProjectGpu(props) - const showGpu = props.currentMenu === "Select" && props.selectedProjectSummary !== undefined + const isShowGpu = props.currentMenu === "Select" && props.selectedProjectSummary !== undefined return ( {props.selectedProjectSummary === undefined ? "No project selected." : props.selectedProjectSummary.displayName} - {showGpu ? GPU: {selectedGpu ?? "unknown"} : null} + {isShowGpu ? GPU: {selectedGpu ?? "unknown"} : null} ) } @@ -116,10 +116,10 @@ const PrimaryMenuAction = ( > ): JSX.Element => { const label = actionLabel(props.currentMenu) - const browserUnavailable = props.currentMenu === "Browser" && + const isBrowserUnavailable = props.currentMenu === "Browser" && !canOpenProjectBrowser(props.projectBrowser, props.selectedProjectSummary?.id ?? null) - return browserUnavailable + return isBrowserUnavailable ? {label} : } diff --git a/packages/app/src/web/app-ready-project-picker-screen.tsx b/packages/app/src/web/app-ready-project-picker-screen.tsx index 3a5a29d0..80d42c3c 100644 --- a/packages/app/src/web/app-ready-project-picker-screen.tsx +++ b/packages/app/src/web/app-ready-project-picker-screen.tsx @@ -108,7 +108,7 @@ const ProjectContentDetails = (props: MainPanelsProps): JSX.Element => ( authSnapshot={props.authSnapshot} compact={props.viewportLayout.compact} controllerCwd={props.controllerCwd} - createView={props.createView} + creationView={props.creationView} currentMenu={props.currentMenu} dashboardRefreshTick={props.dashboardRefreshTick} githubStatus={props.githubStatus} diff --git a/packages/app/src/web/app-ready-screen-actions.ts b/packages/app/src/web/app-ready-screen-actions.ts index 5c0b9070..7c5e3076 100644 --- a/packages/app/src/web/app-ready-screen-actions.ts +++ b/packages/app/src/web/app-ready-screen-actions.ts @@ -36,13 +36,14 @@ export const bindScreenActions = ( ) => ({ onBackScreen: () => { if (state.activeScreen.tag === "Create") { - cancelCreate(actionContext, state.setCreateView) + cancelCreate(actionContext, state.setCreationView) return } if (state.activeScreen.tag === "ProjectAuth" || state.activeScreen.tag === "Output") { - state.setActiveScreen( - isProjectMenu(resolveCurrentMenu(state.selectedMenuIndex)) ? projectPickerScreen() : menuScreen() - ) + const backScreen = isProjectMenu(resolveCurrentMenu(state.selectedMenuIndex)) + ? projectPickerScreen + : menuScreen + state.setActiveScreen(backScreen()) return } state.setProjectNavigationArmed(false) diff --git a/packages/app/src/web/app-ready-screen-frame.tsx b/packages/app/src/web/app-ready-screen-frame.tsx index 2f1a15fe..0f8f5604 100644 --- a/packages/app/src/web/app-ready-screen-frame.tsx +++ b/packages/app/src/web/app-ready-screen-frame.tsx @@ -9,7 +9,7 @@ type ScreenFrameProps = { readonly title: string } -export const screenPadding = (compact: boolean): number | string => compact ? "8px" : 2 +export const screenPadding = (isCompact: boolean): number | string => isCompact ? "8px" : 2 const ScreenHeader = ({ hint, onBack, title }: Omit): JSX.Element => ( + readonly setCreationView: Setter readonly setActiveScreen: Setter readonly setProjectNavigationArmed: Setter readonly setSelectedMenuIndex: Setter @@ -79,7 +79,7 @@ const openSelectedMenuScreen = ({ } } -const handleMenuScreenKey = ( +const didHandleMenuScreenKey = ( event: ShortcutKeyboardEvent, { context, @@ -130,7 +130,7 @@ const runProjectPickerAction = ( runBrowserMenuAction(currentMenu, context) } -const handleRefreshShortcut = ( +const didHandleRefreshShortcut = ( event: ShortcutKeyboardEvent, { context, currentMenu }: Pick ): boolean => { @@ -142,7 +142,7 @@ const handleRefreshShortcut = ( return true } -const handleProjectPickerShortcut = ( +const didHandleProjectPickerShortcut = ( event: ShortcutKeyboardEvent, args: Pick< BrowserShortcutArgs, @@ -164,10 +164,10 @@ const handleProjectPickerShortcut = ( return true } if ( - handleProjectNavigationKey(event, { + didHandleProjectNavigationKey(event, { currentMenu: args.currentMenu, dashboard: args.dashboard, - projectNavigationArmed: true, + isProjectNavigationArmed: true, selectedProjectId: args.selectedProjectId, setSelectedProjectId: args.setSelectedProjectId }) @@ -179,21 +179,22 @@ const handleProjectPickerShortcut = ( runProjectPickerAction(args.currentMenu, args.context, args.projectBrowser, args.setActiveScreen) return true } - return handleRefreshShortcut(event, args) + return didHandleRefreshShortcut(event, args) } -const handleBackToMenuShortcut = ( +const didHandleBackToMenuShortcut = ( event: ShortcutKeyboardEvent, context: BrowserActionContext, setActiveScreen: Setter, - returnToProjectPicker: boolean + shouldReturnToProjectPicker: boolean ): boolean => { if (event.key !== "Escape" && event.key !== "ArrowLeft") { return false } event.preventDefault() - setActiveScreen(returnToProjectPicker ? projectPickerScreen() : menuScreen()) - context.setMessage(returnToProjectPicker ? "Returned to project selection." : "Returned to main menu.") + const backScreen = shouldReturnToProjectPicker ? projectPickerScreen : menuScreen + setActiveScreen(backScreen()) + context.setMessage(shouldReturnToProjectPicker ? "Returned to project selection." : "Returned to main menu.") return true } @@ -201,18 +202,18 @@ const handleOutputShortcut = ( event: ShortcutKeyboardEvent, args: Pick ): void => { - if (handleBackToMenuShortcut(event, args.context, args.setActiveScreen, isProjectMenu(args.currentMenu))) { + if (didHandleBackToMenuShortcut(event, args.context, args.setActiveScreen, isProjectMenu(args.currentMenu))) { return } - handleRefreshShortcut(event, args) + didHandleRefreshShortcut(event, args) } const handleContentShortcut = ( event: ShortcutKeyboardEvent, args: Pick ): void => { - handleBackToMenuShortcut(event, args.context, args.setActiveScreen, false) - handleRefreshShortcut(event, args) + didHandleBackToMenuShortcut(event, args.context, args.setActiveScreen, false) + didHandleRefreshShortcut(event, args) } const handleMenuShortcut = ( @@ -229,27 +230,27 @@ const handleMenuShortcut = ( | "setSelectedProjectId" > ): void => { - if (handleMenuNavigationKey(event, args.currentMenu, false, args.setSelectedMenuIndex)) { + if (didHandleMenuNavigationKey(event, args.currentMenu, false, args.setSelectedMenuIndex)) { return } - handleMenuScreenKey(event, args) + didHandleMenuScreenKey(event, args) } const handleCreateShortcut = ( event: ShortcutKeyboardEvent, args: Pick< BrowserShortcutArgs, - "context" | "controllerCwd" | "createView" | "projectsRoot" | "setActiveScreen" | "setCreateView" + "context" | "controllerCwd" | "creationView" | "projectsRoot" | "setActiveScreen" | "setCreationView" > ): void => { - const handled = handleCreateKey(event, { + const isHandled = didHandleCreateKey(event, { context: args.context, controllerCwd: args.controllerCwd, projectsRoot: args.projectsRoot, - createView: args.createView, - setCreateView: args.setCreateView + creationView: args.creationView, + setCreationView: args.setCreationView }) - if (handled && event.key === "Escape") { + if (isHandled && event.key === "Escape") { args.setActiveScreen(menuScreen()) } } @@ -266,7 +267,7 @@ const dispatchActiveScreenShortcut = (event: ShortcutKeyboardEvent, args: Browse } if (args.activeScreen.tag === "ProjectPicker") { - handleProjectPickerShortcut(event, args) + didHandleProjectPickerShortcut(event, args) return } @@ -276,7 +277,7 @@ const dispatchActiveScreenShortcut = (event: ShortcutKeyboardEvent, args: Browse } if (args.activeScreen.tag === "ProjectAuth") { - handleBackToMenuShortcut(event, args.context, args.setActiveScreen, true) + didHandleBackToMenuShortcut(event, args.context, args.setActiveScreen, true) return } diff --git a/packages/app/src/web/app-ready-shortcuts.ts b/packages/app/src/web/app-ready-shortcuts.ts index 296bf72c..6c75671e 100644 --- a/packages/app/src/web/app-ready-shortcuts.ts +++ b/packages/app/src/web/app-ready-shortcuts.ts @@ -35,7 +35,7 @@ export type ShortcutKeyboardEvent = { type ProjectNavigationArgs = { readonly currentMenu: BrowserMenuTag readonly dashboard: DashboardData - readonly projectNavigationArmed: boolean + readonly isProjectNavigationArmed: boolean readonly selectedProjectId: string | null readonly setSelectedProjectId: Setter } @@ -136,8 +136,8 @@ export const normalizeSelectedProjectId = ( if (selectedProjectId === null) { return null } - const exists = dashboard.projects.some((project) => project.id === selectedProjectId) - return exists ? selectedProjectId : dashboard.projects[0]?.id ?? null + const isExists = dashboard.projects.some((project) => project.id === selectedProjectId) + return isExists ? selectedProjectId : dashboard.projects[0]?.id ?? null } export const refreshCurrentMenu = ( @@ -165,33 +165,33 @@ export const shouldLoadProjectDetails = (currentMenu: BrowserMenuTag): boolean = Match.orElse(() => false) ) -export const usesProjectPrimaryNavigation = (currentMenu: BrowserMenuTag): boolean => +export const isProjectPrimaryNavigationMenu = (currentMenu: BrowserMenuTag): boolean => projectPrimaryNavigationMenus.has(currentMenu) const menuNavigationDelta = ( currentMenu: BrowserMenuTag, - projectNavigationArmed: boolean, + isProjectNavigationArmed: boolean, key: string ): number | null => { - if (usesProjectPrimaryNavigation(currentMenu) && projectNavigationArmed) { + if (isProjectPrimaryNavigationMenu(currentMenu) && isProjectNavigationArmed) { return null } return resolveVerticalArrowDelta(key) } -export const handleMenuNavigationKey = ( +export const didHandleMenuNavigationKey = ( event: ShortcutKeyboardEvent, currentMenu: BrowserMenuTag, - projectNavigationArmed: boolean, + isProjectNavigationArmed: boolean, setSelectedMenuIndex: Setter ): boolean => { - const delta = menuNavigationDelta(currentMenu, projectNavigationArmed, event.key) + const delta = menuNavigationDelta(currentMenu, isProjectNavigationArmed, event.key) if (delta === null) { return false } event.preventDefault() if (delta < 0) { - setSelectedMenuIndex((index) => (index > 0 ? index - 1 : browserMenuItems.length - 1)) + setSelectedMenuIndex((index) => (index > 0 ? index : browserMenuItems.length) - 1) return true } setSelectedMenuIndex((index) => (index + 1) % browserMenuItems.length) @@ -200,26 +200,26 @@ export const handleMenuNavigationKey = ( const projectNavigationDelta = ( currentMenu: BrowserMenuTag, - projectNavigationArmed: boolean, + isProjectNavigationArmed: boolean, key: string ): number | null => { - if (!usesProjectPrimaryNavigation(currentMenu) || !projectNavigationArmed) { + if (!isProjectPrimaryNavigationMenu(currentMenu) || !isProjectNavigationArmed) { return null } return resolveVerticalArrowDelta(key) } -export const handleProjectNavigationKey = ( +export const didHandleProjectNavigationKey = ( event: ShortcutKeyboardEvent, { currentMenu, dashboard, - projectNavigationArmed, + isProjectNavigationArmed, selectedProjectId, setSelectedProjectId }: ProjectNavigationArgs ): boolean => { - const delta = projectNavigationDelta(currentMenu, projectNavigationArmed, event.key) + const delta = projectNavigationDelta(currentMenu, isProjectNavigationArmed, event.key) if (delta === null) { return false } @@ -228,16 +228,16 @@ export const handleProjectNavigationKey = ( return true } -export const handleActionKey = ( +export const didHandleActionKey = ( event: ShortcutKeyboardEvent, currentMenu: BrowserMenuTag, - projectNavigationArmed: boolean, + isProjectNavigationArmed: boolean, context: BrowserActionContext ): boolean => { if (event.key === "Enter") { if ( isNativeActionTarget(event.target) && - !(usesProjectPrimaryNavigation(currentMenu) && projectNavigationArmed) + !(isProjectPrimaryNavigationMenu(currentMenu) && isProjectNavigationArmed) ) { return false } @@ -265,7 +265,7 @@ export const shouldRefreshProjectAuthPanel = ( export const shouldRefreshProjectDetails = ( currentMenu: BrowserMenuTag, - _projectNavigationArmed: boolean, + _isProjectNavigationArmed: boolean, selectedProjectId: string | null, loadedProject: LoadedProjectDetails ): boolean => @@ -275,10 +275,10 @@ export const shouldRefreshProjectDetails = ( export const shortcutHintText = ( currentMenu: BrowserMenuTag, - projectNavigationArmed: boolean + isProjectNavigationArmed: boolean ): string => { - if (usesProjectPrimaryNavigation(currentMenu)) { - return projectNavigationArmed + if (isProjectPrimaryNavigationMenu(currentMenu)) { + return isProjectNavigationArmed ? "↑/↓ project, Enter run, Esc/← back" : "↑/↓ menu, Enter/→ choose project" } diff --git a/packages/app/src/web/app-ready-ssh-link-core.ts b/packages/app/src/web/app-ready-ssh-link-core.ts index ac3bb146..77c52688 100644 --- a/packages/app/src/web/app-ready-ssh-link-core.ts +++ b/packages/app/src/web/app-ready-ssh-link-core.ts @@ -11,8 +11,8 @@ export type DashboardProject = DashboardData["projects"][number] type SessionLookupResult = { readonly sessionId: string } export type ProjectLookupResult = { readonly terminalId?: string | undefined; readonly token: string } export type SshLinkRequest = - | ({ readonly kind: "project" } & ProjectLookupResult) - | ({ readonly kind: "session" } & SessionLookupResult) + | (ProjectLookupResult & { readonly kind: "project" }) + | (SessionLookupResult & { readonly kind: "session" }) const safeDecodeURIComponent = (value: string): string | null => Either.getOrNull(Either.try({ try: () => decodeURIComponent(value), catch: () => null })) diff --git a/packages/app/src/web/app-ready-ssh-link-hook.ts b/packages/app/src/web/app-ready-ssh-link-hook.ts index 3240edbb..fb58e3dc 100644 --- a/packages/app/src/web/app-ready-ssh-link-hook.ts +++ b/packages/app/src/web/app-ready-ssh-link-hook.ts @@ -55,13 +55,15 @@ type SshLinkEffectArgs = Omit & { } const clearConnectTimer = (connectTimerRef: ConnectTimerRef): void => { - if (connectTimerRef.current !== null) { - globalThis.clearTimeout(connectTimerRef.current) - connectTimerRef.current = null + if (connectTimerRef.current === null) { + return } + + clearTimeout(connectTimerRef.current) + connectTimerRef.current = null } -const readSshLinkRequest = (): SshLinkRequest | null => readSshLinkRequestFromHref(globalThis.location.href) +const readSshLinkRequest = (): SshLinkRequest | null => readSshLinkRequestFromHref(location.href) const clearPendingSshLink = (args: SshLinkEffectArgs, requestKey: string): void => { if (args.pendingTokenRef.current === requestKey) { @@ -89,14 +91,14 @@ const handleTerminalSessionAttachFailure = ( if (!isPendingSshLink(args, requestKey)) { return } - const fallbackPath = resolveMissingSshSessionFallbackPath(globalThis.location.href, sessionId, error) + const fallbackPath = resolveMissingSshSessionFallbackPath(location.href, sessionId, error) if (fallbackPath !== null) { clearPendingSshLink(args, requestKey) args.handledTokenRef.current = null args.deactivateTerminalWorkspace() args.actionContext.setSelectedMenuIndex(browserMenuIndex("Select")) args.actionContext.setActiveScreen(projectPickerScreen()) - globalThis.history.replaceState(globalThis.history.state, "", fallbackPath) + history.replaceState(history.state, "", fallbackPath) args.actionContext.setMessage(`SSH terminal is no longer available: ${sessionId}.`) return } @@ -122,7 +124,7 @@ const scheduleTerminalSessionAttach = ( sessionId: string ): void => { clearConnectTimer(args.connectTimerRef) - args.connectTimerRef.current = globalThis.setTimeout(() => { + args.connectTimerRef.current = setTimeout(() => { args.connectTimerRef.current = null void Effect.runPromise( loadTerminalSessionById(sessionId).pipe( @@ -139,7 +141,7 @@ const scheduleTerminalSessionAttach = ( }, 0) } -const attachExistingProjectLink = ( +const didAttachExistingProjectLink = ( args: SshLinkEffectArgs, project: DashboardProject, request: { readonly terminalId?: string | undefined } @@ -168,7 +170,7 @@ const scheduleProjectTerminalAttach = ( ): void => { clearConnectTimer(args.connectTimerRef) showProjectTerminalScreen(args.actionContext, project.id) - args.connectTimerRef.current = globalThis.setTimeout(() => { + args.connectTimerRef.current = setTimeout(() => { args.connectTimerRef.current = null void Effect.runPromise( loadProjectTerminalWorkspace(project.projectKey).pipe( @@ -218,7 +220,7 @@ const handleProjectSshLink = (args: SshLinkEffectArgs, requestKey: string, reque args.actionContext.setMessage(`Project link was not found: ${request.token}.`) return } - if (attachExistingProjectLink(args, project, request)) { + if (didAttachExistingProjectLink(args, project, request)) { markSshLinkHandled(args, requestKey) return } @@ -303,7 +305,7 @@ export const useSshLink = ({ const connectTimerRef = useRef | null>(null) const handledTokenRef = useRef(null) const pendingTokenRef = useRef(null) - const locationSignature = `${globalThis.location.pathname}${globalThis.location.search}` + const locationSignature = `${location.pathname}${location.search}` useEffect(() => () => { clearConnectTimer(connectTimerRef) diff --git a/packages/app/src/web/app-ready-ssh-link-terminal.ts b/packages/app/src/web/app-ready-ssh-link-terminal.ts index cf5790a8..cc5eeeec 100644 --- a/packages/app/src/web/app-ready-ssh-link-terminal.ts +++ b/packages/app/src/web/app-ready-ssh-link-terminal.ts @@ -91,7 +91,7 @@ export const attachLoadedSshSessionLink = ( args: LoadedSshSessionAttachArgs, { projectDisplayName, projectKey, session }: LoadedSshSessionLink ): void => { - globalThis.history.replaceState(globalThis.history.state, "", projectSshRoutePath(projectKey, session.id)) + history.replaceState(history.state, "", projectSshRoutePath(projectKey, session.id)) showProjectTerminalScreen(args.actionContext, session.projectId) args.addTerminalSession(buildProjectActiveTerminalSession({ onExit: args.actionContext.reloadDashboard, diff --git a/packages/app/src/web/app-ready-state.ts b/packages/app/src/web/app-ready-state.ts index 84a7426d..1401a174 100644 --- a/packages/app/src/web/app-ready-state.ts +++ b/packages/app/src/web/app-ready-state.ts @@ -61,7 +61,7 @@ export type ReadyState = ReadyStateSetters & TerminalWorkspaceReadyState & { readonly activeScreen: BrowserScreen readonly authSnapshot: AuthSnapshot | null readonly busyLabel: string | null - readonly createView: CreateFlowView + readonly creationView: CreateFlowView readonly databaseConnectionInput: string readonly databaseForwards: ReadonlyArray readonly databaseLabelInput: string @@ -84,7 +84,7 @@ export type ReadyState = ReadyStateSetters & TerminalWorkspaceReadyState & { readonly projectTasksIncludeDefault: boolean readonly setActionPrompt: Setter readonly setActiveScreen: Setter - readonly setCreateView: Setter + readonly setCreationView: Setter readonly setProjectNavigationArmed: Setter readonly setProjectSearchQuery: Setter readonly selectedMenuIndex: number @@ -115,14 +115,14 @@ const useReadyPanelState = () => { const [authSnapshot, setAuthSnapshot] = useState(null) const [githubStatus, setGithubStatus] = useState(null) const [panelCloudflareTunnel, setPanelCloudflareTunnel] = useState(null) - const [createView, setCreateView] = useState(resetCreateView()) + const [creationView, setCreationView] = useState(resetCreateView()) const terminalWorkspaceState = useTerminalWorkspaceState() return { actionPrompt, authSnapshot, busyLabel, - createView, + creationView, githubStatus, message, output, @@ -130,7 +130,7 @@ const useReadyPanelState = () => { setActionPrompt, setAuthSnapshot, setBusyLabel, - setCreateView, + setCreationView, setGithubStatus, setMessage, setOutput, @@ -141,14 +141,14 @@ const useReadyPanelState = () => { const useReadyProjectState = () => { const [project, setSelectedProject] = useState(null) - const [projectNavigationArmed, setProjectNavigationArmed] = useState(false) + const [isProjectNavigationArmed, setProjectNavigationArmed] = useState(false) const [projectSearchQuery, setProjectSearchQuery] = useState("") const [projectAuthSnapshot, setProjectAuthSnapshot] = useState(null) const [projectBrowser, setProjectBrowser] = useState(null) return { project, - projectNavigationArmed, + projectNavigationArmed: isProjectNavigationArmed, projectSearchQuery, projectAuthSnapshot, projectBrowser, diff --git a/packages/app/src/web/app-ready-task-actions.ts b/packages/app/src/web/app-ready-task-actions.ts index 4d108a3e..3316baf4 100644 --- a/packages/app/src/web/app-ready-task-actions.ts +++ b/packages/app/src/web/app-ready-task-actions.ts @@ -15,8 +15,8 @@ export const bindTaskActions = ( onRefreshProjectTasks: () => { loadSelectedProjectTasks(actionContext) }, - onProjectTasksIncludeDefaultChange: (includeDefault: boolean) => { - setSelectedProjectTasksIncludeDefault(actionContext, includeDefault) + onProjectTasksIncludeDefaultChange: (shouldIncludeDefault: boolean) => { + setSelectedProjectTasksIncludeDefault(actionContext, shouldIncludeDefault) }, onStopProjectTask: (pid: number) => { stopSelectedProjectTask(actionContext, pid) diff --git a/packages/app/src/web/app-ready-tasks-hook.ts b/packages/app/src/web/app-ready-tasks-hook.ts index 401a1484..d7ea26e9 100644 --- a/packages/app/src/web/app-ready-tasks-hook.ts +++ b/packages/app/src/web/app-ready-tasks-hook.ts @@ -16,12 +16,12 @@ type TasksPanelAutoloadArgs = { export const useProjectTasksState = () => { const [projectTasks, setProjectTasks] = useState(null) const [projectTaskLogs, setProjectTaskLogs] = useState("") - const [projectTasksIncludeDefault, setProjectTasksIncludeDefault] = useState(false) + const [isProjectTasksIncludeDefault, setProjectTasksIncludeDefault] = useState(false) return { projectTaskLogs, projectTasks, - projectTasksIncludeDefault, + projectTasksIncludeDefault: isProjectTasksIncludeDefault, setProjectTaskLogs, setProjectTasks, setProjectTasksIncludeDefault @@ -32,7 +32,7 @@ export const useProjectTasksReset = ( selectedProjectId: string | null, setProjectTaskLogs: (value: string) => void, setProjectTasks: (value: ContainerTaskSnapshot | null) => void, - setProjectTasksIncludeDefault: (value: boolean) => void + setProjectTasksIncludeDefault: (shouldIncludeDefault: boolean) => void ) => { useEffect(() => { setProjectTaskLogs("") diff --git a/packages/app/src/web/app-ready-terminal-screen.tsx b/packages/app/src/web/app-ready-terminal-screen.tsx index 34ad771b..7931c8b6 100644 --- a/packages/app/src/web/app-ready-terminal-screen.tsx +++ b/packages/app/src/web/app-ready-terminal-screen.tsx @@ -113,7 +113,7 @@ const TerminalScreenLayout = ( export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null => { const [terminalView, setTerminalView] = useState("terminal") - const mobileMode = props.viewportLayout.mode === "mobile" + const isMobileMode = props.viewportLayout.mode === "mobile" const activeSessionId = resolveActiveTerminalSessionId(props.terminalSessions, props.activeTerminalSessionId) const activeSession = props.terminalSessions.find((session) => terminalSessionId(session) === activeSessionId) useEffect(() => { @@ -126,7 +126,7 @@ export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null = {...props} activeSession={activeSession} activeSessionId={activeSessionId} - mobileMode={mobileMode} + mobileMode={isMobileMode} setTerminalView={setTerminalView} terminalView={terminalView} /> diff --git a/packages/app/src/web/app-ready-terminal-storage.ts b/packages/app/src/web/app-ready-terminal-storage.ts index c18bd740..7a3d8fd8 100644 --- a/packages/app/src/web/app-ready-terminal-storage.ts +++ b/packages/app/src/web/app-ready-terminal-storage.ts @@ -172,14 +172,12 @@ const decodeStoredActiveTerminalSession = (value: JsonValue | undefined): Active closePath: fields.closePath, exitMessage: fields.exitMessage, header: fields.header, - ...(fields.pendingConnectionMessage !== null && fields.pendingConnectionPhase !== null - ? { - pendingConnection: { - message: fields.pendingConnectionMessage, - phase: fields.pendingConnectionPhase - } + ...(fields.pendingConnectionMessage !== null && fields.pendingConnectionPhase !== null && { + pendingConnection: { + message: fields.pendingConnectionMessage, + phase: fields.pendingConnectionPhase } - : {}), + }), pendingDeleteMessage: fields.pendingDeleteMessage, readyMessage: fields.readyMessage, session: fields.session, @@ -212,7 +210,7 @@ const decodeStoredTerminalWorkspace = (value: JsonValue | undefined): TerminalWo export const readStoredTerminalWorkspace = (): TerminalWorkspaceState => { const read = Effect.try({ - try: () => globalThis.sessionStorage.getItem(terminalWorkspaceStorageKey), + try: () => sessionStorage.getItem(terminalWorkspaceStorageKey), catch: () => null }).pipe( Effect.either, @@ -254,7 +252,7 @@ export const writeStoredTerminalWorkspace = (state: TerminalWorkspaceState): voi const write = Effect.try({ try: () => { if (state.terminalSessions.length === 0) { - globalThis.sessionStorage.removeItem(terminalWorkspaceStorageKey) + sessionStorage.removeItem(terminalWorkspaceStorageKey) return } const payload: StoredTerminalWorkspaceState = { @@ -262,7 +260,7 @@ export const writeStoredTerminalWorkspace = (state: TerminalWorkspaceState): voi savedAt: Date.now(), terminalSessions: state.terminalSessions.map((session) => toStoredActiveTerminalSession(session)) } - globalThis.sessionStorage.setItem(terminalWorkspaceStorageKey, JSON.stringify(payload)) + sessionStorage.setItem(terminalWorkspaceStorageKey, JSON.stringify(payload)) }, catch: () => null }).pipe( diff --git a/packages/app/src/web/app-ready-url.ts b/packages/app/src/web/app-ready-url.ts index 75f230ca..633cf0c3 100644 --- a/packages/app/src/web/app-ready-url.ts +++ b/packages/app/src/web/app-ready-url.ts @@ -122,8 +122,8 @@ const resolveProjectId = ( return project?.id ?? null } -export const activeScreenFromMenu = (menu: BrowserMenuTag, outputRequested: boolean): BrowserScreen => { - if (outputRequested && (menu === "Logs" || menu === "Status")) { +export const activeScreenFromMenu = (menu: BrowserMenuTag, isOutputRequested: boolean): BrowserScreen => { + if (isOutputRequested && (menu === "Logs" || menu === "Status")) { return outputScreen() } if (menu === "ProjectAuth") { @@ -154,11 +154,11 @@ const parseMenuActionUrl = ( return null } - const outputRequested = rest.at(-1) === "output" - const projectSegments = outputRequested ? rest.slice(0, -1) : rest + const isOutputRequested = rest.at(-1) === "output" + const projectSegments = isOutputRequested ? rest.slice(0, -1) : rest const selectedProjectId = isProjectMenu(menu) ? resolveProjectId(projects, decodePathTail(projectSegments)) : null return { - activeScreen: activeScreenFromMenu(menu, outputRequested), + activeScreen: activeScreenFromMenu(menu, isOutputRequested), menu, projectNavigationArmed: false, selectedProjectId @@ -215,7 +215,7 @@ const applyReadyUrlNavigation = ( args: ReadyUrlSyncArgs, skipNextWriteRef: { current: boolean } ): void => { - const next = parseReadyUrlNavigation(globalThis.location.href, args.dashboard.projects) + const next = parseReadyUrlNavigation(location.href, args.dashboard.projects) if (next === null) { return } @@ -240,7 +240,7 @@ const writeReadyUrl = ( return } - const currentUrl = new URL(globalThis.location.href) + const currentUrl = new URL(location.href) if (isSshLinkUrl(currentUrl) && args.state.activeTerminalSession === null) { return } @@ -255,7 +255,7 @@ const writeReadyUrl = ( if (path === null || `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}` === path) { return } - globalThis.history.replaceState(globalThis.history.state, "", path) + history.replaceState(history.state, "", path) } export const useReadyUrlSync = (args: ReadyUrlSyncArgs) => { @@ -274,9 +274,9 @@ export const useReadyUrlSync = (args: ReadyUrlSyncArgs) => { applyCurrentLocation() const onPopState = applyCurrentLocation - globalThis.addEventListener("popstate", onPopState) + addEventListener("popstate", onPopState) return () => { - globalThis.removeEventListener("popstate", onPopState) + removeEventListener("popstate", onPopState) } }, []) diff --git a/packages/app/src/web/app-ready.tsx b/packages/app/src/web/app-ready.tsx index 14697262..57b43553 100644 --- a/packages/app/src/web/app-ready.tsx +++ b/packages/app/src/web/app-ready.tsx @@ -58,7 +58,7 @@ type ReadyLayoutRenderArgs = { readonly onRefreshProjectSkills: () => void readonly onRefreshProjectTasks: () => void readonly onRefreshPanelShareTunnel: () => void - readonly onProjectTasksIncludeDefaultChange: (includeDefault: boolean) => void + readonly onProjectTasksIncludeDefaultChange: (shouldIncludeDefault: boolean) => void readonly onRestartProjectDatabaseEditor: () => void readonly onRunAuthAction: (index: number) => void readonly onRunCurrentMenuAction: () => void @@ -143,7 +143,7 @@ const readyStateProps = (state: ReadyLayoutRenderArgs["state"]) => ({ activeTerminalSessionId: state.activeTerminalSessionId, authSnapshot: state.authSnapshot, busyLabel: state.busyLabel, - createView: state.createView, + creationView: state.creationView, databaseConnectionInput: state.databaseConnectionInput, databaseForwards: state.databaseForwards, databaseLabelInput: state.databaseLabelInput, diff --git a/packages/app/src/web/app-terminal-session-handlers.ts b/packages/app/src/web/app-terminal-session-handlers.ts index 6788bddb..67c675c8 100644 --- a/packages/app/src/web/app-terminal-session-handlers.ts +++ b/packages/app/src/web/app-terminal-session-handlers.ts @@ -15,7 +15,7 @@ import { startProjectBrowser, stopProjectTask } from "./api.js" -import { openUrl, prepareOpenUrl } from "./open-url.js" +import { didOpenUrl, prepareOpenUrl } from "./open-url.js" import { projectSshRoutePath } from "./terminal.js" export type StateMessageUpdater = (message: string | null) => void @@ -30,29 +30,29 @@ export type ProjectHandlers = { export type TaskHandlers = { readonly logs: string - readonly onIncludeDefaultChange: (include: boolean) => void + readonly onIncludeDefaultChange: (shouldIncludeDefault: boolean) => void readonly onLoadLogs: (pid: number) => void readonly onRefresh: () => void readonly onStopTask: (pid: number) => void - readonly refreshTasks: (include: boolean) => void + readonly refreshTasks: (shouldIncludeDefault: boolean) => void readonly snapshot: ContainerTaskSnapshot | null readonly taskIncludeDefault: boolean } -const confirmApplyProject = (label: string): boolean => { - const dialog = globalThis.confirm +const didConfirmApplyProject = (label: string): boolean => { + const dialog = confirm return typeof dialog === "function" && dialog( `Apply docker-git config to ${label}? This restarts the container and ends active SSH sessions and in-container browsers.` ) } -const browserStatusMessage = (browser: ProjectBrowserSession, opened: boolean): string => { +const browserStatusMessage = (browser: ProjectBrowserSession, isOpened: boolean): string => { if (browser.status !== "running") { return `Browser runtime is ${browser.status}. Enable Playwright MCP and start the project first.` } const noVncUrl = projectBrowserNoVncUrl(browser) - return opened + return isOpened ? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.` : `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.` } @@ -72,7 +72,8 @@ const runOpenBrowser = (projectId: string, setMessage: StateMessageUpdater): voi setMessage(browserStatusMessage(browser, false)) return } - setMessage(browserStatusMessage(browser, preparedUrl.navigate(projectBrowserNoVncUrl(browser)))) + const noVncUrl = projectBrowserNoVncUrl(browser) + setMessage(browserStatusMessage(browser, preparedUrl.navigate(noVncUrl))) } }) ) @@ -84,7 +85,7 @@ const runApplyProject = ( projectLabel: string, setMessage: StateMessageUpdater ): void => { - if (!confirmApplyProject(projectLabel)) { + if (!didConfirmApplyProject(projectLabel)) { return } void Effect.runPromise( @@ -105,8 +106,8 @@ export const newProjectTerminalUrl = (origin: string, projectKey: string, sessio `${origin}${projectSshRoutePath(projectKey, sessionId)}` const handleTerminalCreated = (projectKey: string, sessionId: string, setMessage: StateMessageUpdater): void => { - const targetUrl = newProjectTerminalUrl(globalThis.location.origin, projectKey, sessionId) - if (!openUrl(targetUrl)) { + const targetUrl = newProjectTerminalUrl(location.origin, projectKey, sessionId) + if (!didOpenUrl(targetUrl)) { setMessage(`New terminal popup was blocked. Open ${targetUrl} manually.`) } } @@ -215,12 +216,12 @@ export const useProjectActionHandlers = ( const runRefreshTasks = ( projectId: string, - include: boolean, + shouldIncludeDefault: boolean, setSnapshot: Dispatch>, setMessage: StateMessageUpdater ): void => { void Effect.runPromise( - loadProjectTasks(projectId, include).pipe( + loadProjectTasks(projectId, shouldIncludeDefault).pipe( Effect.match({ onFailure: (error) => { setMessage(`Failed to load tasks: ${error}`) @@ -283,21 +284,21 @@ export const useTaskManagerHandlers = ( ): TaskHandlers => { const [snapshot, setSnapshot] = useState(null) const [logs, setLogs] = useState("") - const [taskIncludeDefault, setTaskIncludeDefault] = useState(false) + const [isTaskIncludeDefault, setTaskIncludeDefault] = useState(false) - const refreshTasks = useCallback((include: boolean) => { + const refreshTasks = useCallback((shouldIncludeDefault: boolean) => { if (projectId !== undefined) { - runRefreshTasks(projectId, include, setSnapshot, setMessage) + runRefreshTasks(projectId, shouldIncludeDefault, setSnapshot, setMessage) } }, [projectId, setMessage]) const onStopTask = useCallback((pid: number) => { if (projectId !== undefined) { runStopTask(projectId, pid, setMessage, () => { - refreshTasks(taskIncludeDefault) + refreshTasks(isTaskIncludeDefault) }) } - }, [projectId, refreshTasks, setMessage, taskIncludeDefault]) + }, [projectId, refreshTasks, setMessage, isTaskIncludeDefault]) const onLoadLogs = useCallback((pid: number) => { if (projectId !== undefined) { @@ -305,14 +306,14 @@ export const useTaskManagerHandlers = ( } }, [projectId, setMessage]) - const onIncludeDefaultChange = useCallback((include: boolean) => { - setTaskIncludeDefault(include) - refreshTasks(include) + const onIncludeDefaultChange = useCallback((shouldIncludeDefault: boolean) => { + setTaskIncludeDefault(shouldIncludeDefault) + refreshTasks(shouldIncludeDefault) }, [refreshTasks]) const onRefresh = useCallback(() => { - refreshTasks(taskIncludeDefault) - }, [refreshTasks, taskIncludeDefault]) + refreshTasks(isTaskIncludeDefault) + }, [refreshTasks, isTaskIncludeDefault]) return { logs, @@ -322,6 +323,6 @@ export const useTaskManagerHandlers = ( onStopTask, refreshTasks, snapshot, - taskIncludeDefault + taskIncludeDefault: isTaskIncludeDefault } } diff --git a/packages/app/src/web/app-terminal-session.tsx b/packages/app/src/web/app-terminal-session.tsx index c75faecf..0fcbc4ca 100644 --- a/packages/app/src/web/app-terminal-session.tsx +++ b/packages/app/src/web/app-terminal-session.tsx @@ -82,12 +82,12 @@ const useLoadedProjectDetails = (projectId: string | undefined): ProjectDetails if (projectId === undefined) { return } - let cancelled = false + let isCancelled = false void Effect.runPromise( loadProjectDetails(projectId).pipe( Effect.tap((details) => Effect.sync(() => { - if (!cancelled) { + if (!isCancelled) { setProject(details) } }) @@ -97,7 +97,7 @@ const useLoadedProjectDetails = (projectId: string | undefined): ProjectDetails ) ) return () => { - cancelled = true + isCancelled = true } }, [projectId]) return project @@ -125,7 +125,7 @@ const useTerminalOnlyReadyState = ( const projectKey = session.browserProjectKey const projectLabel = session.browserProjectName ?? projectId ?? "this project" const project = useLoadedProjectDetails(projectId) - const [taskManagerOpen, setTaskManagerOpen] = useState(false) + const [isTaskManagerOpen, setTaskManagerOpen] = useState(false) const setMessage = useCallback( (message: string | null) => { updateReadyMessage(setState, message) @@ -144,23 +144,24 @@ const useTerminalOnlyReadyState = ( setMessage, terminalSessionId: session.session.id }) - return { handlers, project, setMessage, setTaskManagerOpen, taskManagerOpen, tasks } + return { handlers, project, setMessage, setTaskManagerOpen, taskManagerOpen: isTaskManagerOpen, tasks } } const TerminalOnlyReady = ( { session, setState, state, viewportLayout }: TerminalOnlyReadyArgs ): JSX.Element => { - const { handlers, project, setMessage, setTaskManagerOpen, taskManagerOpen, tasks } = useTerminalOnlyReadyState( - session, - setState - ) + const { handlers, project, setMessage, setTaskManagerOpen, taskManagerOpen: isTaskManagerOpen, tasks } = + useTerminalOnlyReadyState( + session, + setState + ) const bodyContent = renderTaskManagerBody({ onCloseTaskManager: () => { setTaskManagerOpen(false) }, project, projectId: session.browserProjectId, - taskManagerOpen, + taskManagerOpen: isTaskManagerOpen, tasks }) return ( @@ -216,13 +217,13 @@ export const AppTerminalSession = ({ sessionId, viewportLayout }: AppTerminalSes const [state, setState] = useState(() => terminalOnlyLoadingState(sessionId)) useEffect(() => { - let cancelled = false + let isCancelled = false setState(terminalOnlyLoadingState(sessionId)) void Effect.runPromise( loadTerminalOnlyState(sessionId).pipe( Effect.tap((nextState) => Effect.sync(() => { - if (!cancelled) { + if (!isCancelled) { setState(nextState) } }) @@ -231,7 +232,7 @@ export const AppTerminalSession = ({ sessionId, viewportLayout }: AppTerminalSes ) ) return () => { - cancelled = true + isCancelled = true } }, [sessionId]) diff --git a/packages/app/src/web/app.tsx b/packages/app/src/web/app.tsx index bac000d7..17d4df06 100644 --- a/packages/app/src/web/app.tsx +++ b/packages/app/src/web/app.tsx @@ -1,5 +1,14 @@ import { Effect, Match } from "effect" -import { type JSX, startTransition, useEffect, useEffectEvent, useRef, useState } from "react" +import { + type Dispatch, + type JSX, + type SetStateAction, + startTransition, + useEffect, + useEffectEvent, + useRef, + useState +} from "react" import { webPrimitives } from "../ui/primitives-web.js" import { UiProvider } from "../ui/primitives.js" @@ -20,8 +29,8 @@ const readVisualViewport = (global: OptionalVisualViewportGlobal): VisualViewpor global.visualViewport ?? null const resolveViewportSize = (): ViewportSize => { - const layoutHeight = typeof globalThis.innerHeight === "number" ? globalThis.innerHeight : 900 - const layoutWidth = typeof globalThis.innerWidth === "number" ? globalThis.innerWidth : 1280 + const layoutHeight = typeof innerHeight === "number" ? innerHeight : 900 + const layoutWidth = typeof innerWidth === "number" ? innerWidth : 1280 const visualViewport = readVisualViewport(globalThis) if (visualViewport === null) { @@ -66,6 +75,15 @@ const loadDashboardState = () => }) ) +const applyDashboardRefresh = ( + setState: Dispatch>, + nextState: DashboardState +): void => { + startTransition(() => { + setState(createDashboardRefreshReducer(nextState)) + }) +} + const isDocumentVisible = (): boolean => document.visibilityState === "visible" const useDashboardRefreshTriggers = (refresh: () => void) => { @@ -79,14 +97,14 @@ const useDashboardRefreshTriggers = (refresh: () => void) => { const onRefreshTrigger = () => { refreshWhenVisible() } - globalThis.addEventListener("focus", onRefreshTrigger) - globalThis.addEventListener("online", onRefreshTrigger) - globalThis.addEventListener("pageshow", onRefreshTrigger) + addEventListener("focus", onRefreshTrigger) + addEventListener("online", onRefreshTrigger) + addEventListener("pageshow", onRefreshTrigger) document.addEventListener("visibilitychange", onRefreshTrigger) return () => { - globalThis.removeEventListener("focus", onRefreshTrigger) - globalThis.removeEventListener("online", onRefreshTrigger) - globalThis.removeEventListener("pageshow", onRefreshTrigger) + removeEventListener("focus", onRefreshTrigger) + removeEventListener("online", onRefreshTrigger) + removeEventListener("pageshow", onRefreshTrigger) document.removeEventListener("visibilitychange", onRefreshTrigger) } }, [refreshWhenVisible]) @@ -101,15 +119,18 @@ const useDashboardController = () => { return } refreshInFlightRef.current = true - void Effect.runPromise(loadDashboardState()) - .then((nextState) => { - startTransition(() => { - setState(createDashboardRefreshReducer(nextState)) - }) - }) - .finally(() => { - refreshInFlightRef.current = false + const applyRefreshedState = (nextState: DashboardState) => + Effect.sync(() => { + applyDashboardRefresh(setState, nextState) }) + const clearRefreshFlag = Effect.sync(() => { + refreshInFlightRef.current = false + }) + const refreshEffect = loadDashboardState().pipe( + Effect.tap(applyRefreshedState), + Effect.ensuring(clearRefreshFlag) + ) + void Effect.runPromise(refreshEffect) }) useEffect(() => { @@ -133,11 +154,11 @@ const useViewportMode = () => { setViewportSize(resolveViewportSize()) } - globalThis.addEventListener("resize", onResize) + addEventListener("resize", onResize) globalThis.visualViewport?.addEventListener("resize", onResize) globalThis.visualViewport?.addEventListener("scroll", onResize) return () => { - globalThis.removeEventListener("resize", onResize) + removeEventListener("resize", onResize) globalThis.visualViewport?.removeEventListener("resize", onResize) globalThis.visualViewport?.removeEventListener("scroll", onResize) } @@ -200,7 +221,7 @@ const AppDashboard = ({ viewport }: { readonly viewport: ViewportLayout }): JSX. export const App = (): JSX.Element => { const viewport = useViewportMode() - const [route] = useState(() => resolveWebAppRoute(globalThis.location.pathname)) + const [route] = useState(() => resolveWebAppRoute(location.pathname)) return ( diff --git a/packages/app/src/web/main.tsx b/packages/app/src/web/main.tsx index 97ba7096..169c2d4a 100644 --- a/packages/app/src/web/main.tsx +++ b/packages/app/src/web/main.tsx @@ -1,6 +1,9 @@ import { createRoot } from "react-dom/client" import { App } from "./app.js" +import { configureTerminalApiBaseUrlResolver } from "./terminal.js" + +configureTerminalApiBaseUrlResolver() const rootElement = document.querySelector("#root") diff --git a/packages/app/src/web/open-url.ts b/packages/app/src/web/open-url.ts index d4ab5582..a1f8e941 100644 --- a/packages/app/src/web/open-url.ts +++ b/packages/app/src/web/open-url.ts @@ -3,9 +3,9 @@ export type PreparedOpenUrl = { readonly navigate: (url: string) => boolean } -export const openUrl = (url: string): boolean => { - if (typeof globalThis.open === "function") { - const openedWindow = globalThis.open(url, "_blank", "noopener") +export const didOpenUrl = (url: string): boolean => { + if (typeof open === "function") { + const openedWindow = open(url, "_blank", "noopener") return openedWindow !== null } return false @@ -13,14 +13,14 @@ export const openUrl = (url: string): boolean => { const blockedPreparedOpenUrl = (): PreparedOpenUrl => ({ close: () => {}, - navigate: openUrl + navigate: didOpenUrl }) export const prepareOpenUrl = (): PreparedOpenUrl => { - if (typeof globalThis.open !== "function") { + if (typeof open !== "function") { return blockedPreparedOpenUrl() } - const openedWindow = globalThis.open("about:blank", "_blank") + const openedWindow = open("about:blank", "_blank") if (openedWindow === null) { return blockedPreparedOpenUrl() } diff --git a/packages/app/src/web/panel-browser.tsx b/packages/app/src/web/panel-browser.tsx index 92cec7bf..635a537a 100644 --- a/packages/app/src/web/panel-browser.tsx +++ b/packages/app/src/web/panel-browser.tsx @@ -25,8 +25,8 @@ const statusColor = (status: ProjectBrowserSession["status"]): string => { } const openUrl = (url: string): void => { - if (typeof globalThis.open === "function") { - globalThis.open(url, "_blank", "noopener") + if (typeof open === "function") { + open(url, "_blank", "noopener") } } @@ -65,14 +65,14 @@ const BrowserStatusDetails = ( if (browser === null || browser.projectId !== selectedProjectId) { return Browser status is not loaded. } - const browserRunning = browser.status === "running" + const isBrowserRunning = browser.status === "running" return ( Container: {browser.containerName} {browser.status} - {browserRunning + {isBrowserRunning ? : ( diff --git a/packages/app/src/web/panel-content-renderers.tsx b/packages/app/src/web/panel-content-renderers.tsx index 7b2dd384..fe5ee696 100644 --- a/packages/app/src/web/panel-content-renderers.tsx +++ b/packages/app/src/web/panel-content-renderers.tsx @@ -65,7 +65,7 @@ const renderCreateContent = (props: ContentPanelProps): JSX.Element => ( compact={props.compact} controllerCwd={props.controllerCwd} projectsRoot={props.projectsRoot} - createView={props.createView} + creationView={props.creationView} onBufferChange={props.onCreateBufferChange} onCancel={props.onCreateCancel} onSubmit={props.onCreateSubmit} diff --git a/packages/app/src/web/panel-content-types.ts b/packages/app/src/web/panel-content-types.ts index 57de5f3a..6dbdcd58 100644 --- a/packages/app/src/web/panel-content-types.ts +++ b/packages/app/src/web/panel-content-types.ts @@ -11,7 +11,7 @@ export type ContentPanelProps = { readonly controllerCwd: string readonly dashboardRefreshTick: number readonly projectsRoot: string - readonly createView: CreateFlowView + readonly creationView: CreateFlowView readonly currentMenu: BrowserMenuTag readonly githubStatus: GithubAuthStatus | null readonly onActionPromptCancel: () => void diff --git a/packages/app/src/web/panel-create-select.tsx b/packages/app/src/web/panel-create-select.tsx index 17b1249b..c9aac7e1 100644 --- a/packages/app/src/web/panel-create-select.tsx +++ b/packages/app/src/web/panel-create-select.tsx @@ -20,7 +20,7 @@ import type { CreateSubmitMode } from "./app-ready-create.js" type CreatePanelProps = { readonly compact: boolean readonly controllerCwd: string - readonly createView: CreateFlowView + readonly creationView: CreateFlowView readonly projectsRoot: string readonly onBufferChange: (buffer: string) => void readonly onCancel: () => void @@ -36,27 +36,27 @@ type CreatePanelModel = { readonly visibleSteps: ReadonlyArray } -const renderStepColor = (active: boolean): string => active ? "#56f39a" : "#8fa6c4" +const renderStepColor = (isActive: boolean): string => isActive ? "#56f39a" : "#8fa6c4" const webCreateSettingsNavigationHint = "↑ - up, ↓ - down, Enter - apply + down" const webCreateSettingsChoiceHint = "←/→ - choose yes/no or GPU" const createPrompt = ( - createContext: CreateFlowContext, - createView: CreateFlowView + flowContext: CreateFlowContext, + creationView: CreateFlowView ): { readonly label: string; readonly defaults: ReturnType } => { - const defaults = resolveCreateInputs(createContext, createView.values) + const defaults = resolveCreateInputs(flowContext, creationView.values) const steps = resolveCreateDisplaySteps() - const step = steps[createView.step] ?? steps[0] ?? "repoUrl" + const step = steps[creationView.step] ?? steps[0] ?? "repoUrl" return { - label: renderCreateStepLabelWithBufferPreview(step, defaults, createView.buffer), + label: renderCreateStepLabelWithBufferPreview(step, defaults, creationView.buffer), defaults } } const CreatePromptInput = ( { - createView, + creationView, isRepoStep, onArrowLeft, onArrowRight, @@ -65,7 +65,7 @@ const CreatePromptInput = ( onSubmit, promptLabel }: { - readonly createView: CreateFlowView + readonly creationView: CreateFlowView readonly isRepoStep: boolean readonly onArrowLeft?: () => void readonly onArrowRight?: () => void @@ -89,49 +89,49 @@ const CreatePromptInput = ( }} onEscape={onCancel} placeholder={isRepoStep ? "https://github.com/org/repo/tree/branch --force --mcp-playwright" : promptLabel} - value={createView.buffer} + value={creationView.buffer} /> - {createView.inputError === null || !isRepoStep + {creationView.inputError === null || !isRepoStep ? null - : {createView.inputError}} + : {creationView.inputError}} ) const resolveCreatePanelModel = ( - { compact, controllerCwd, createView, projectsRoot }: Pick< + { compact, controllerCwd, creationView, projectsRoot }: Pick< CreatePanelProps, - "compact" | "controllerCwd" | "createView" | "projectsRoot" + "compact" | "controllerCwd" | "creationView" | "projectsRoot" > ): CreatePanelModel => { - const prompt = createPrompt({ cwd: controllerCwd, projectsRoot }, createView) + const prompt = createPrompt({ cwd: controllerCwd, projectsRoot }, creationView) const steps = resolveCreateDisplaySteps() - const activeStep = isDisplayModeFlowView(createView) ? steps[createView.step] ?? "repoUrl" : "repoUrl" - const isRepoStep = isCreateFlowRepoStep(createView) + const activeStep = isDisplayModeFlowView(creationView) ? steps[creationView.step] ?? "repoUrl" : "repoUrl" + const isRepoStep = isCreateFlowRepoStep(creationView) return { activeStep, isRepoStep, - leftChoiceBuffer: isDisplayModeFlowView(createView) - ? resolveCreateSettingsChoiceBuffer(createView, "left") + leftChoiceBuffer: isDisplayModeFlowView(creationView) + ? resolveCreateSettingsChoiceBuffer(creationView, "left") : null, prompt, - rightChoiceBuffer: isDisplayModeFlowView(createView) - ? resolveCreateSettingsChoiceBuffer(createView, "right") + rightChoiceBuffer: isDisplayModeFlowView(creationView) + ? resolveCreateSettingsChoiceBuffer(creationView, "right") : null, visibleSteps: compact && isRepoStep ? [activeStep] : steps } } const createChoiceHandler = ( - createView: CreateFlowView, + creationView: CreateFlowView, direction: CreateSettingsChoiceDirection, onBufferChange: (buffer: string) => void ): () => void => () => { - if (!isDisplayModeFlowView(createView)) { + if (!isDisplayModeFlowView(creationView)) { return } - const nextBuffer = resolveCreateSettingsChoiceBuffer(createView, direction) + const nextBuffer = resolveCreateSettingsChoiceBuffer(creationView, direction) if (nextBuffer !== null) { onBufferChange(nextBuffer) } @@ -178,28 +178,28 @@ const CreateSubmitButtons = ( export const CreatePanel = ( props: CreatePanelProps ): JSX.Element => { - const { compact, controllerCwd, createView, onBufferChange, onCancel, onSubmit } = props + const { compact: isCompact, controllerCwd, creationView, onBufferChange, onCancel, onSubmit } = props const model = resolveCreatePanelModel(props) const leftChoiceAction = model.leftChoiceBuffer === null ? undefined - : createChoiceHandler(createView, "left", onBufferChange) + : createChoiceHandler(creationView, "left", onBufferChange) const rightChoiceAction = model.rightChoiceBuffer === null ? undefined - : createChoiceHandler(createView, "right", onBufferChange) + : createChoiceHandler(creationView, "right", onBufferChange) return ( docker-git / Create {model.prompt.label}: - + ) } @@ -230,11 +230,11 @@ const CreateStepsList = ( ): JSX.Element => ( {visibleSteps.map((step) => { - const active = step === activeStep + const isActive = step === activeStep return ( - - {active ? "> " : " "} - {active + + {isActive ? "> " : " "} + {isActive ? renderCreateStepLabelWithBufferPreview(step, defaults, activeBuffer) : renderCreateStepLabel(step, defaults)} diff --git a/packages/app/src/web/panel-databases.tsx b/packages/app/src/web/panel-databases.tsx index 6ed75931..93b48f68 100644 --- a/packages/app/src/web/panel-databases.tsx +++ b/packages/app/src/web/panel-databases.tsx @@ -9,7 +9,7 @@ import { type ProjectDetails, type ProjectSummary } from "./api.js" -import { openUrl } from "./open-url.js" +import { didOpenUrl } from "./open-url.js" import { DatabaseProfilesList } from "./panel-database-profiles.js" type DatabasePanelProps = { @@ -77,7 +77,7 @@ const SessionStatus = ( ? ( { - openUrl(editorUrl) + didOpenUrl(editorUrl) }} > {editorUrl} diff --git a/packages/app/src/web/panel-layout.tsx b/packages/app/src/web/panel-layout.tsx index 2ec190a4..de9b5418 100644 --- a/packages/app/src/web/panel-layout.tsx +++ b/packages/app/src/web/panel-layout.tsx @@ -40,9 +40,9 @@ const compactMenuLabels: Readonly> = { Tasks: "Tasks" } -const menuPanelMaxHeight = (compact: boolean): string => compact ? "154px" : "100%" -const menuListDirection = (compact: boolean): "column" | "row" => compact ? "row" : "column" -const menuListTopMargin = (compact: boolean): number | string => compact ? "6px" : 1 +const menuPanelMaxHeight = (isCompact: boolean): string => isCompact ? "154px" : "100%" +const menuListDirection = (isCompact: boolean): "column" | "row" => isCompact ? "row" : "column" +const menuListTopMargin = (isCompact: boolean): number | string => isCompact ? "6px" : 1 const MenuHeader = ({ compact }: Pick): JSX.Element => ( diff --git a/packages/app/src/web/panel-port-forwards.tsx b/packages/app/src/web/panel-port-forwards.tsx index e62211e1..ce4a45e6 100644 --- a/packages/app/src/web/panel-port-forwards.tsx +++ b/packages/app/src/web/panel-port-forwards.tsx @@ -25,8 +25,8 @@ const statusColor = (status: ProjectPortForward["status"]): string => { } const openUrl = (url: string): void => { - if (typeof globalThis.open === "function") { - globalThis.open(url, "_blank", "noopener") + if (typeof open === "function") { + open(url, "_blank", "noopener") } } diff --git a/packages/app/src/web/panel-project-details.tsx b/packages/app/src/web/panel-project-details.tsx index 8684d972..77901fc8 100644 --- a/packages/app/src/web/panel-project-details.tsx +++ b/packages/app/src/web/panel-project-details.tsx @@ -141,8 +141,6 @@ export const SelectPanel = ( selectedProjectSummary }: SelectPanelProps ): JSX.Element | null => { - const selectedProjectKey = selectedProjectKeyForLiveSessions(project, selectedProjectSummary) - if (currentMenu !== "Select") { return null } @@ -152,6 +150,7 @@ export const SelectPanel = ( if (project === null || (selectedProjectSummary !== undefined && project.id !== selectedProjectSummary.id)) { return renderPendingSelection("Connect", selectedProjectSummary) } + const selectedProjectKey = selectedProjectKeyForLiveSessions(project, selectedProjectSummary) return ( {renderDetailsPanel("Connect", project, selectedProjectSummary)} diff --git a/packages/app/src/web/panel-project-list.tsx b/packages/app/src/web/panel-project-list.tsx index eb755a29..14f8e649 100644 --- a/packages/app/src/web/panel-project-list.tsx +++ b/packages/app/src/web/panel-project-list.tsx @@ -27,11 +27,11 @@ type ProjectListModel = { readonly noProjectLabel: string } -export const showsProjectPanel = (currentMenu: BrowserMenuTag): boolean => currentMenu === "Select" +export const isProjectPanelShown = (currentMenu: BrowserMenuTag): boolean => currentMenu === "Select" const renderListPurpose = (currentMenu: BrowserMenuTag): SelectPurpose => selectPurposeForMenu(currentMenu) ?? "Connect" -const projectPanelMaxHeight = (compact: boolean): string => compact ? "30%" : "100%" +const projectPanelMaxHeight = (isCompact: boolean): string => isCompact ? "30%" : "100%" const runtimeByProject = (dashboard: DashboardData) => Object.fromEntries( @@ -51,10 +51,10 @@ const stripSelectionPrefix = (label: string): string => label.slice(2) const resolveProjectListSelectionIndex = ( currentMenu: BrowserMenuTag, dashboard: DashboardData, - projectNavigationArmed: boolean, + isProjectNavigationArmed: boolean, selectedProjectId: string | null ): number => - !showsProjectPanel(currentMenu) || projectNavigationArmed + !isProjectPanelShown(currentMenu) || isProjectNavigationArmed ? dashboard.projects.findIndex((project) => project.id === selectedProjectId) : -1 diff --git a/packages/app/src/web/panel-project-prompts.tsx b/packages/app/src/web/panel-project-prompts.tsx index 4773aeb5..78826567 100644 --- a/packages/app/src/web/panel-project-prompts.tsx +++ b/packages/app/src/web/panel-project-prompts.tsx @@ -64,7 +64,7 @@ const PromptEditorActions = ( const usePromptDraft = (prompt: ProjectPromptFile) => { const [draft, setDraft] = useState(prompt.content) - const [dirty, setDirty] = useState(false) + const [isDirty, setDirty] = useState(false) useEffect(() => { setDraft(prompt.content) @@ -76,7 +76,7 @@ const usePromptDraft = (prompt: ProjectPromptFile) => { setDirty(value !== prompt.content) } - return { dirty, draft, handleChange } + return { dirty: isDirty, draft, handleChange } } const PromptEditor = ( @@ -90,7 +90,7 @@ const PromptEditor = ( readonly prompt: ProjectPromptFile } ): JSX.Element => { - const { dirty, draft, handleChange } = usePromptDraft(prompt) + const { dirty: isDirty, draft, handleChange } = usePromptDraft(prompt) const handleSave = () => { onSave(draft) } @@ -108,7 +108,7 @@ const PromptEditor = ( placeholder={`Write your ${prompt.fileName} content here…`} value={draft} /> - + ) } diff --git a/packages/app/src/web/panel-project-skills.tsx b/packages/app/src/web/panel-project-skills.tsx index 9b6d1822..0210478e 100644 --- a/packages/app/src/web/panel-project-skills.tsx +++ b/packages/app/src/web/panel-project-skills.tsx @@ -70,7 +70,7 @@ const SkillEditorActions = ( const useSkillDraft = (skill: ProjectSkillFile) => { const [draft, setDraft] = useState(skill.content) - const [dirty, setDirty] = useState(false) + const [isDirty, setDirty] = useState(false) useEffect(() => { setDraft(skill.content) @@ -82,7 +82,7 @@ const useSkillDraft = (skill: ProjectSkillFile) => { setDirty(value !== skill.content) } - return { dirty, draft, handleChange } + return { dirty: isDirty, draft, handleChange } } const SkillEditor = ( @@ -96,7 +96,7 @@ const SkillEditor = ( readonly skill: ProjectSkillFile } ): JSX.Element => { - const { dirty, draft, handleChange } = useSkillDraft(skill) + const { dirty: isDirty, draft, handleChange } = useSkillDraft(skill) const handleSave = () => { onSave(draft) } @@ -113,7 +113,7 @@ const SkillEditor = ( placeholder="# Skill\n\nDescribe what this skill does…" value={draft} /> - + ) } diff --git a/packages/app/src/web/panel-project-terminal-sessions.tsx b/packages/app/src/web/panel-project-terminal-sessions.tsx index c32da3c8..99c3d376 100644 --- a/packages/app/src/web/panel-project-terminal-sessions.tsx +++ b/packages/app/src/web/panel-project-terminal-sessions.tsx @@ -68,18 +68,18 @@ const useProjectTerminalSessions = ( setSessionsState(emptyProjectTerminalSessionsState) return } - let cancelled = false + let isCancelled = false setSessionsState({ error: null, loading: true, sessions: [] }) void Effect.runPromise( loadProjectTerminalSessions(args.selectedProjectKey).pipe( Effect.match({ onFailure: (error) => { - if (!cancelled) { + if (!isCancelled) { setSessionsState({ error, loading: false, sessions: [] }) } }, onSuccess: (sessions) => { - if (!cancelled) { + if (!isCancelled) { setSessionsState({ error: null, loading: false, sessions }) } } @@ -87,7 +87,7 @@ const useProjectTerminalSessions = ( ) ) return () => { - cancelled = true + isCancelled = true } }, [args.currentMenu, args.dashboardRefreshTick, args.projectNavigationArmed, refreshNonce, args.selectedProjectKey]) diff --git a/packages/app/src/web/panel-share.tsx b/packages/app/src/web/panel-share.tsx index 2e3705ec..8c2bc832 100644 --- a/packages/app/src/web/panel-share.tsx +++ b/packages/app/src/web/panel-share.tsx @@ -25,12 +25,12 @@ const statusColor = (status: PanelCloudflareTunnelSession["status"] | "none"): s } const openUrl = (url: string): void => { - if (typeof globalThis.open !== "function" || !URL.canParse(url)) { + if (typeof open !== "function" || !URL.canParse(url)) { return } const parsed = new URL(url) if (parsed.protocol === "http:" || parsed.protocol === "https:") { - globalThis.open(parsed.toString(), "_blank", "noopener") + open(parsed.href, "_blank", "noopener") } } diff --git a/packages/app/src/web/panel-tasks.tsx b/packages/app/src/web/panel-tasks.tsx index 8792742e..af517295 100644 --- a/packages/app/src/web/panel-tasks.tsx +++ b/packages/app/src/web/panel-tasks.tsx @@ -6,7 +6,7 @@ import type { ContainerTask, ContainerTaskSnapshot, ProjectDetails, ProjectSumma type TaskPanelProps = { readonly includeDefault: boolean readonly logs: string - readonly onIncludeDefaultChange: (includeDefault: boolean) => void + readonly onIncludeDefaultChange: (shouldIncludeDefault: boolean) => void readonly onLoadLogs: (pid: number) => void readonly onRefreshTasks: () => void readonly onStopTask: (pid: number) => void diff --git a/packages/app/src/web/panels.tsx b/packages/app/src/web/panels.tsx index e80b3f7d..7090d0fc 100644 --- a/packages/app/src/web/panels.tsx +++ b/packages/app/src/web/panels.tsx @@ -1,3 +1,3 @@ export { ContentPanel } from "./panel-content.js" export { ErrorScreen, LoadingScreen, MenuSidebar, OutputPanel, projectSelectionLabel } from "./panel-layout.js" -export { ProjectListPanel, showsProjectPanel } from "./panel-project-list.js" +export { isProjectPanelShown, ProjectListPanel } from "./panel-project-list.js" diff --git a/packages/app/src/web/project-events.ts b/packages/app/src/web/project-events.ts index 857ab24c..c08594bf 100644 --- a/packages/app/src/web/project-events.ts +++ b/packages/app/src/web/project-events.ts @@ -31,7 +31,7 @@ const schedulePoll = ( runPoll: () => void, delayMs: number ): void => { - state.timeout = globalThis.setTimeout(runPoll, delayMs) + state.timeout = setTimeout(runPoll, delayMs) } const handlePollFailure = ( @@ -116,7 +116,7 @@ export const openProjectEventStream = ( close: () => { state.closed = true if (state.timeout !== null) { - globalThis.clearTimeout(state.timeout) + clearTimeout(state.timeout) } } } diff --git a/packages/app/src/web/terminal.ts b/packages/app/src/web/terminal.ts index 953e74dd..415c6ce5 100644 --- a/packages/app/src/web/terminal.ts +++ b/packages/app/src/web/terminal.ts @@ -13,6 +13,8 @@ const resolveConfiguredTerminalApiBaseUrl = (): string | null => { return null } -setTerminalApiBaseUrlResolver(() => resolveConfiguredTerminalApiBaseUrl() ?? resolveApiBaseUrl()) +export const configureTerminalApiBaseUrlResolver = (): void => { + setTerminalApiBaseUrlResolver(() => resolveConfiguredTerminalApiBaseUrl() ?? resolveApiBaseUrl()) +} export * from "@prover-coder-ai/docker-git-terminal/web/terminal" diff --git a/packages/app/src/web/viewport-layout.ts b/packages/app/src/web/viewport-layout.ts index ca04be73..5aa83516 100644 --- a/packages/app/src/web/viewport-layout.ts +++ b/packages/app/src/web/viewport-layout.ts @@ -43,7 +43,7 @@ export const resolveViewportLayout = (size: ViewportSize): ViewportLayout => { const mode = resolveViewportLayoutMode(size) const layoutHeight = size.layoutHeight ?? size.height const visibleRatio = layoutHeight <= 0 ? 1 : size.height / layoutHeight - const keyboardOpen = mode === "mobile" && + const isKeyboardOpen = mode === "mobile" && layoutHeight - size.height >= keyboardViewportLossThresholdPx && visibleRatio <= keyboardVisibleRatioThreshold @@ -51,7 +51,7 @@ export const resolveViewportLayout = (size: ViewportSize): ViewportLayout => { compact: mode !== "desktop", dense: size.height <= denseMaxHeight, fontSize: stableWebFontSize, - keyboardOpen, + keyboardOpen: isKeyboardOpen, mode, viewportHeight: size.height, viewportOffsetLeft: size.offsetLeft ?? 0, diff --git a/packages/app/tests/docker-git/actions-browser.test.ts b/packages/app/tests/docker-git/actions-browser.test.ts index d7815b73..0e7e5304 100644 --- a/packages/app/tests/docker-git/actions-browser.test.ts +++ b/packages/app/tests/docker-git/actions-browser.test.ts @@ -19,6 +19,10 @@ vi.mock("../../src/web/api.js", () => ({ startProjectBrowser: startProjectBrowserMock })) +const globalsCleanup = Effect.sync(() => { + vi.unstubAllGlobals() +}) + const runningBrowser: ProjectBrowserSession = { cdpPath: "/api/projects/project-1/browser/cdp", cdpUrl: "ws://172.17.0.2:9222/devtools/browser/session", @@ -174,9 +178,7 @@ describe("web browser actions", () => { expect(openedWindow.focus).toHaveBeenCalledOnce() } }).pipe( - Effect.ensuring(Effect.sync(() => { - vi.unstubAllGlobals() - })) + Effect.ensuring(globalsCleanup) ) ) ), diff --git a/packages/app/tests/docker-git/actions-project-create.test.ts b/packages/app/tests/docker-git/actions-project-create.test.ts index 5032d24a..39ac709f 100644 --- a/packages/app/tests/docker-git/actions-project-create.test.ts +++ b/packages/app/tests/docker-git/actions-project-create.test.ts @@ -23,7 +23,7 @@ vi.mock("../../src/web/project-events.js", () => ({ openProjectEventStream: openProjectEventStreamMock })) -const createInputConfig = { +const inputConfig = { cpuLimit: "75%", enableMcpPlaywright: true, force: false, @@ -35,14 +35,14 @@ const createInputConfig = { repoUrl: "https://github.com/octocat/Hello-World.git" } satisfies Omit -const createInputs: CreateInputs = { - ...createInputConfig, +const inputs: CreateInputs = { + ...inputConfig, runUp: true } const expectedCreateDraft = { - ...createInputConfig, - up: createInputs.runUp + ...inputConfig, + up: inputs.runUp } const project = { @@ -124,7 +124,7 @@ const runCreateFlow = ( Effect.gen(function*(_) { const { context, output, reloadDashboard, setMessage } = makeBrowserActionContext() - submitCreateInputs(createInputs, context) + submitCreateInputs(inputs, context) yield* _(waitForAssertion(() => { expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) diff --git a/packages/app/tests/docker-git/actions-share.test.ts b/packages/app/tests/docker-git/actions-share.test.ts index 04c2bb8c..a781c100 100644 --- a/packages/app/tests/docker-git/actions-share.test.ts +++ b/packages/app/tests/docker-git/actions-share.test.ts @@ -55,8 +55,8 @@ const stoppedAtArbitrary = fc.integer({ max: 86_399_999, min: 0 }).map((millisec new Date(Date.UTC(2026, 4, 18, 0, 0, 0, milliseconds)).toISOString() ) -const clipboardImplementation = (succeeds: boolean): ClipboardWriteText => () => - succeeds ? Effect.runPromise(Effect.void) : Effect.runPromise(Effect.fail(new Error("denied"))) +const clipboardImplementation = (willSucceed: boolean): ClipboardWriteText => () => + willSucceed ? Effect.runPromise(Effect.void) : Effect.runPromise(Effect.fail(new Error("denied"))) const copyTunnelWithClipboard = (implementation: ClipboardWriteText) => { const writeText = vi.fn(implementation) @@ -185,11 +185,11 @@ describe("web share actions", () => { it.effect("reports generated clipboard copy outcomes", () => assertAsyncFastCheck( - fc.asyncProperty(fc.boolean(), (succeeds) => + fc.asyncProperty(fc.boolean(), (willSucceed) => Effect.runPromise( expectCopyTunnelMessage( - clipboardImplementation(succeeds), - succeeds ? "Tunnel URL copied." : "Failed to copy tunnel URL." + clipboardImplementation(willSucceed), + willSucceed ? "Tunnel URL copied." : "Failed to copy tunnel URL." ) )), { numRuns: 10 } diff --git a/packages/app/tests/docker-git/actions-skiller.test.ts b/packages/app/tests/docker-git/actions-skiller.test.ts index c62011c4..ba249ac2 100644 --- a/packages/app/tests/docker-git/actions-skiller.test.ts +++ b/packages/app/tests/docker-git/actions-skiller.test.ts @@ -10,6 +10,10 @@ import { type BrowserOpenMockWindow, makeBrowserOpenMockWindow, stubBrowserOpen const openSkillerMock = vi.hoisted(() => vi.fn()) +const globalsCleanup = Effect.sync(() => { + vi.unstubAllGlobals() +}) + const proofScope = { containerCodexSkillsPath: "/home/dev/.codex/skills", containerHomePath: "/home/dev", @@ -222,9 +226,7 @@ describe("web Skiller actions", () => { expect(context.setBusyLabel).toHaveBeenCalledWith("Opening Skiller") expect(context.setBusyLabel).toHaveBeenLastCalledWith(null) }).pipe( - Effect.ensuring(Effect.sync(() => { - vi.unstubAllGlobals() - })) + Effect.ensuring(globalsCleanup) ) )), { numRuns: 20 } diff --git a/packages/app/tests/docker-git/app-ready-create-fixture.ts b/packages/app/tests/docker-git/app-ready-create-fixture.ts index a22b6128..85fbdf5b 100644 --- a/packages/app/tests/docker-git/app-ready-create-fixture.ts +++ b/packages/app/tests/docker-git/app-ready-create-fixture.ts @@ -22,7 +22,7 @@ export { export type { CreateFlowView } from "../../src/docker-git/menu-create-shared.js" export type { CreateStep } from "../../src/docker-git/menu-types.js" -type HandleCreateKey = typeof AppReadyCreate.handleCreateKey +type HandleCreateKey = typeof AppReadyCreate.didHandleCreateKey type SubmitCreateView = typeof AppReadyCreate.submitCreateView type CreateSubmitMode = AppReadyCreate.CreateSubmitMode type BrowserActionContextOverrides = Parameters[0] @@ -50,8 +50,8 @@ const defaultQuickCreateInputs = { /** @pure false @effect allocates a Vitest spy @invariant setter and spy observe the same calls @precondition n/a @postcondition returned setter is React-compatible @complexity O(1) */ export const createSetCreateViewSpy = () => { const spy = vi.fn<(value: SetStateAction) => void>() - const setCreateView: Dispatch> = spy - return { setCreateView, spy } + const setCreationView: Dispatch> = spy + return { setCreationView, spy } } type SetCreateViewSpy = ReturnType["spy"] @@ -66,7 +66,7 @@ export const requireCreateViewValue = ( return value } -/** @pure false @effect Vitest assertion @invariant selected call equals expected view @precondition callIndex targets a setCreateView call @postcondition assertion records no mutation @complexity O(1) */ +/** @pure false @effect Vitest assertion @invariant selected call equals expected view @precondition callIndex targets a setCreationView call @postcondition assertion records no mutation @complexity O(1) */ export const expectCreateViewUpdate = ( setCreateViewSpy: SetCreateViewSpy, expected: CreateFlowView, @@ -78,10 +78,10 @@ export const expectCreateViewUpdate = ( /** @pure false @effect Vitest assertion @invariant inline error text is exact @precondition submit path attempted empty repo URL @postcondition expected error view was written @complexity O(1) */ export const expectCreateViewInputError = ( setCreateViewSpy: SetCreateViewSpy, - createView: CreateFlowView + view: CreateFlowView ) => { expectCreateViewUpdate(setCreateViewSpy, { - ...createView, + ...view, inputError: EMPTY_REPO_URL_ERROR }) } @@ -139,14 +139,14 @@ export const createSubmitCreateBuffer = (submitCreateView: SubmitCreateView) => options: SubmitCreateOptions = {} ) => submitCreateBuffer(submitCreateView, buffer, options) -/** @pure false @effect allocates a Vitest preventDefault spy @invariant shiftKey is preserved @precondition key is a browser keyboard key @postcondition event matches handleCreateKey input shape @complexity O(1) */ +/** @pure false @effect allocates a Vitest preventDefault spy @invariant shiftKey is preserved @precondition key is a browser keyboard key @postcondition event matches didHandleCreateKey input shape @complexity O(1) */ export const createKeyEvent = ( key: string, - shiftKey = false + hasShiftKey = false ): Parameters[0] => { const event = { key, - shiftKey, + shiftKey: hasShiftKey, preventDefault: vi.fn() } return event @@ -179,14 +179,14 @@ const createActionFrame = ( contextOverrides?: BrowserActionContextOverrides ) => { const { context } = makeBrowserActionContext(contextOverrides ?? { githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - return { context, setCreateView, setCreateViewSpy } + const { setCreationView, spy: setCreateViewSpy } = createSetCreateViewSpy() + return { context, setCreationView, setCreateViewSpy } } -/** @pure false @effect invokes handleCreateKey test subject @invariant controller cwd and projectsRoot are stable @precondition createView is a test snapshot @postcondition returns event, context, and setter spy @complexity O(1) */ +/** @pure false @effect invokes didHandleCreateKey test subject @invariant controller cwd and projectsRoot are stable @precondition creationView is a test snapshot @postcondition returns event, context, and setter spy @complexity O(1) */ export const runCreateKey = ( - handleCreateKey: HandleCreateKey, - createView: CreateFlowView, + didHandleCreateKey: HandleCreateKey, + view: CreateFlowView, key: string, options: { readonly contextOverrides?: BrowserActionContextOverrides @@ -195,14 +195,14 @@ export const runCreateKey = ( ) => { const frame = createActionFrame(options.contextOverrides) const event = createKeyEvent(key, options.shiftKey ?? false) - const handled = handleCreateKey(event, { + const isHandled = didHandleCreateKey(event, { context: frame.context, controllerCwd: "/workspace", - createView, + creationView: view, projectsRoot: "/home/dev/.docker-git", - setCreateView: frame.setCreateView + setCreationView: frame.setCreationView }) - return { ...frame, event, handled } + return { ...frame, event, handled: isHandled } } const expectHandledCreateKey = ( @@ -212,13 +212,13 @@ const expectHandledCreateKey = ( expect(result.event.preventDefault).toHaveBeenCalledTimes(1) } -/** @pure false @effect Vitest assertion @invariant ignored keys do not update view or message @precondition key has no action for createView @postcondition no preventDefault call is made @complexity O(1) */ +/** @pure false @effect Vitest assertion @invariant ignored keys do not update view or message @precondition key has no action for creationView @postcondition no preventDefault call is made @complexity O(1) */ export const expectIgnoredCreateKey = ( - handleCreateKey: HandleCreateKey, - createView: CreateFlowView, + didHandleCreateKey: HandleCreateKey, + view: CreateFlowView, key: "ArrowDown" | "ArrowLeft" | "ArrowRight" ) => { - const result = runCreateKey(handleCreateKey, createView, key) + const result = runCreateKey(didHandleCreateKey, view, key) expect(result.handled).toBe(false) expect(result.event.preventDefault).not.toHaveBeenCalled() @@ -226,10 +226,10 @@ export const expectIgnoredCreateKey = ( expect(result.context.setMessage).not.toHaveBeenCalled() } -/** @pure false @effect invokes submitCreateView test subject @invariant mode defaults to advance @precondition createView is a test snapshot @postcondition returns action context and setter spy @complexity O(1) */ +/** @pure false @effect invokes submitCreateView test subject @invariant mode defaults to advance @precondition creationView is a test snapshot @postcondition returns action context and setter spy @complexity O(1) */ export const runSubmitCreateView = ( submitCreateView: SubmitCreateView, - createView: CreateFlowView, + creationView: CreateFlowView, options: { readonly contextOverrides?: BrowserActionContextOverrides readonly mode?: CreateSubmitMode @@ -239,49 +239,49 @@ export const runSubmitCreateView = ( submitCreateView({ context: frame.context, controllerCwd: "/workspace", - createView, + creationView, projectsRoot: "/home/dev/.docker-git", mode: options.mode ?? "advance", - setCreateView: frame.setCreateView + setCreationView: frame.setCreationView }) return frame } /** @pure false @effect Vitest assertion @invariant vertical arrows clear preview buffers @precondition create settings flow is active @postcondition selected step equals expectedStep(view) @complexity O(1) */ export const expectCreateArrowHandling = ( - handleCreateKey: HandleCreateKey, + didHandleCreateKey: HandleCreateKey, key: "ArrowDown" | "ArrowUp", expectedStep: (view: CreateFlowView) => number ) => { - const createView = createSettingsFlowView() - const result = runCreateKey(handleCreateKey, createView, key) + const creationView = createSettingsFlowView() + const result = runCreateKey(didHandleCreateKey, creationView, key) const nextView = requireCreateViewValue(result.setCreateViewSpy.mock.calls[0]?.[0]) expectHandledCreateKey(result) - expect(nextView.step).toBe(expectedStep(createView)) + expect(nextView.step).toBe(expectedStep(creationView)) expect(nextView.buffer).toBe("") - expect(nextView.values).toEqual(createView.values) + expect(nextView.values).toEqual(creationView.values) expect(result.context.setMessage).toHaveBeenCalledWith(null) } /** @pure false @effect Vitest assertion @invariant side arrows only update buffer @precondition stepName supports or rejects discrete choice as expected @postcondition committed values and submit mock are unchanged @complexity O(1) */ export const expectCreateSideArrowBufferHandling = ( - handleCreateKey: HandleCreateKey, + didHandleCreateKey: HandleCreateKey, submitCreateInputsMock: SubmitCreateInputsMock, key: "ArrowLeft" | "ArrowRight", stepName: CreateStep, expectedBuffer: string ) => { - const createView = createSettingsFlowViewAtStep(stepName, "typed") - const result = runCreateKey(handleCreateKey, createView, key) + const creationView = createSettingsFlowViewAtStep(stepName, "typed") + const result = runCreateKey(didHandleCreateKey, creationView, key) const { context, setCreateViewSpy } = result expectHandledCreateKey(result) expectCreateViewUpdate(setCreateViewSpy, { - ...createView, + ...creationView, buffer: expectedBuffer }) - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]).values).toEqual(createView.values) + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]).values).toEqual(creationView.values) expect(submitCreateInputsMock).not.toHaveBeenCalled() expect(context.setMessage).toHaveBeenCalledWith(null) } @@ -292,27 +292,27 @@ export const expectEmptyRepoInlineError = ( submitCreateInputsMock: SubmitCreateInputsMock, mode: CreateSubmitMode = "advance" ) => { - const createView = createInitialFlowView(" ".repeat(3)) - const { context, setCreateViewSpy } = runSubmitCreateView(submitCreateView, createView, { mode }) + const creationView = createInitialFlowView(" ".repeat(3)) + const { context, setCreateViewSpy } = runSubmitCreateView(submitCreateView, creationView, { mode }) expect(submitCreateInputsMock).not.toHaveBeenCalled() expect(setCreateViewSpy).toHaveBeenCalledTimes(1) - expectCreateViewInputError(setCreateViewSpy, createView) + expectCreateViewInputError(setCreateViewSpy, creationView) expect(context.setMessage).not.toHaveBeenCalled() } -/** @pure false @effect Vitest assertion @invariant Enter and Shift+Enter share empty repo validation @precondition handleCreateKey is the test subject @postcondition inline error is set and submit is skipped @complexity O(1) */ +/** @pure false @effect Vitest assertion @invariant Enter and Shift+Enter share empty repo validation @precondition didHandleCreateKey is the test subject @postcondition inline error is set and submit is skipped @complexity O(1) */ export const expectEmptyRepoKeyboardInlineError = ( - handleCreateKey: HandleCreateKey, + didHandleCreateKey: HandleCreateKey, submitCreateInputsMock: SubmitCreateInputsMock, - shiftKey: boolean + hasShiftKey: boolean ) => { - const createView = createInitialFlowView("") - const result = runCreateKey(handleCreateKey, createView, "Enter", { shiftKey }) + const creationView = createInitialFlowView("") + const result = runCreateKey(didHandleCreateKey, creationView, "Enter", { shiftKey: hasShiftKey }) const { context, setCreateViewSpy } = result expectHandledCreateKey(result) expect(submitCreateInputsMock).not.toHaveBeenCalled() - expectCreateViewInputError(setCreateViewSpy, createView) + expectCreateViewInputError(setCreateViewSpy, creationView) expect(context.setMessage).not.toHaveBeenCalled() } diff --git a/packages/app/tests/docker-git/app-ready-create-settings.test.ts b/packages/app/tests/docker-git/app-ready-create-settings.test.ts index da797879..ae2c4b45 100644 --- a/packages/app/tests/docker-git/app-ready-create-settings.test.ts +++ b/packages/app/tests/docker-git/app-ready-create-settings.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import type { submitCreateInputs } from "../../src/web/actions-projects.js" -import { handleCreateKey, submitCreateView } from "../../src/web/app-ready-create.js" +import { didHandleCreateKey, submitCreateView } from "../../src/web/app-ready-create.js" import { type CreateFlowView, createInitialFlowView, @@ -36,11 +36,11 @@ describe("app-ready-create settings", () => { }) it("moves between settings with arrows and clears the uncommitted buffer", () => { - expectCreateArrowHandling(handleCreateKey, "ArrowDown", (view) => view.step + 1) + expectCreateArrowHandling(didHandleCreateKey, "ArrowDown", (view) => view.step + 1) }) it("wraps settings selection upward with ArrowUp and clears the uncommitted buffer", () => { - expectCreateArrowHandling(handleCreateKey, "ArrowUp", () => resolveCreateDisplaySteps().length - 1) + expectCreateArrowHandling(didHandleCreateKey, "ArrowUp", () => resolveCreateDisplaySteps().length - 1) }) it("fills discrete settings buffers with side arrows without applying values", () => { @@ -61,14 +61,14 @@ describe("app-ready-create settings", () => { for (const { expectedBuffer, key, stepName } of cases) { submitCreateInputsMock.mockReset() - expectCreateSideArrowBufferHandling(handleCreateKey, submitCreateInputsMock, key, stepName, expectedBuffer) + expectCreateSideArrowBufferHandling(didHandleCreateKey, submitCreateInputsMock, key, stepName, expectedBuffer) } }) it("applies a side-arrow choice only after Enter", () => { - const arrowResult = runCreateKey(handleCreateKey, createSettingsFlowViewAtStep("gpu", "typed"), "ArrowRight") + const arrowResult = runCreateKey(didHandleCreateKey, createSettingsFlowViewAtStep("gpu", "typed"), "ArrowRight") const arrowView = requireCreateViewValue(arrowResult.setCreateViewSpy.mock.calls[0]?.[0]) - const enterResult = runCreateKey(handleCreateKey, arrowView, "Enter") + const enterResult = runCreateKey(didHandleCreateKey, arrowView, "Enter") const enteredView = requireCreateViewValue(enterResult.setCreateViewSpy.mock.calls[0]?.[0]) expect(arrowResult.handled).toBe(true) @@ -81,7 +81,7 @@ describe("app-ready-create settings", () => { }) it("wraps to the first settings row after applying the last settings row", () => { - const createView: CreateFlowView = { + const creationView: CreateFlowView = { ...createSettingsFlowViewAtStep("force", "y"), values: { ...createSettingsFlowView().values, @@ -92,10 +92,10 @@ describe("app-ready-create settings", () => { runUp: false } } - const { handled, setCreateViewSpy } = runCreateKey(handleCreateKey, createView, "Enter") + const { handled: isHandled, setCreateViewSpy } = runCreateKey(didHandleCreateKey, creationView, "Enter") const enteredView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) - expect(handled).toBe(true) + expect(isHandled).toBe(true) expect(enteredView.values.force).toBe(true) expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("cpuLimit")) expect(enteredView.buffer).toBe("") @@ -103,14 +103,14 @@ describe("app-ready-create settings", () => { }) it("keeps the previous setting value when Enter applies an empty buffer", () => { - const createView: CreateFlowView = { + const creationView: CreateFlowView = { ...createSettingsFlowViewAtStep("runUp", ""), values: { ...createSettingsFlowView().values, runUp: false } } - const emptyResult = runCreateKey(handleCreateKey, createView, "Enter") + const emptyResult = runCreateKey(didHandleCreateKey, creationView, "Enter") const emptyView = requireCreateViewValue(emptyResult.setCreateViewSpy.mock.calls[0]?.[0]) expect(emptyResult.handled).toBe(true) @@ -120,7 +120,7 @@ describe("app-ready-create settings", () => { }) it("navigates to the next visible row after applying a settings row", () => { - const enterResult = runCreateKey(handleCreateKey, createSettingsFlowViewAtStep("mcpPlaywright", "y"), "Enter") + const enterResult = runCreateKey(didHandleCreateKey, createSettingsFlowViewAtStep("mcpPlaywright", "y"), "Enter") const enteredView = requireCreateViewValue(enterResult.setCreateViewSpy.mock.calls[0]?.[0]) expect(enterResult.handled).toBe(true) @@ -130,11 +130,11 @@ describe("app-ready-create settings", () => { }) it("clears an unconfirmed preview when navigating away from a settings row", () => { - const createView = createSettingsFlowViewAtStep("mcpPlaywright", "y") - const { handled, setCreateViewSpy } = runCreateKey(handleCreateKey, createView, "ArrowDown") + const creationView = createSettingsFlowViewAtStep("mcpPlaywright", "y") + const { handled: isHandled, setCreateViewSpy } = runCreateKey(didHandleCreateKey, creationView, "ArrowDown") const nextView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) - expect(handled).toBe(true) + expect(isHandled).toBe(true) expect(nextView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) expect(nextView.values.enableMcpPlaywright).toBeUndefined() expect(nextView.buffer).toBe("") @@ -166,7 +166,7 @@ describe("app-ready-create settings", () => { it("ignores settings arrows before the Settings flow starts", () => { expectIgnoredCreateKey( - handleCreateKey, + didHandleCreateKey, createInitialFlowView("https://github.com/org/repo"), "ArrowDown" ) @@ -181,8 +181,8 @@ describe("app-ready-create settings", () => { ] for (const key of keys) { - for (const createView of views) { - expectIgnoredCreateKey(handleCreateKey, createView, key) + for (const creationView of views) { + expectIgnoredCreateKey(didHandleCreateKey, creationView, key) } } }) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index f67d1585..3de81b62 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -2,7 +2,7 @@ import * as fc from "fast-check" import { beforeEach, describe, expect, it, vi } from "vitest" import type { submitCreateInputs } from "../../src/web/actions-projects.js" -import { handleCreateKey, setCreateBuffer, submitCreateView } from "../../src/web/app-ready-create.js" +import { didHandleCreateKey, setCreateBuffer, submitCreateView } from "../../src/web/app-ready-create.js" import { type CreateFlowView, createInitialFlowView, @@ -94,32 +94,32 @@ describe("app-ready-create", () => { { name: "Enter", shiftKey: false }, { name: "Shift+Enter", shiftKey: true } ])("shows an inline error for empty repo URL keyboard submit on $name", ({ shiftKey }) => { - expectEmptyRepoKeyboardInlineError(handleCreateKey, submitCreateInputsMock, shiftKey) + expectEmptyRepoKeyboardInlineError(didHandleCreateKey, submitCreateInputsMock, shiftKey) }) it("validates empty repo URL before GitHub auth", () => { - const createView = createInitialFlowView("") - const { context, setCreateViewSpy } = runSubmitCreateView(submitCreateView, createView, { + const creationView = createInitialFlowView("") + const { context, setCreateViewSpy } = runSubmitCreateView(submitCreateView, creationView, { contextOverrides: {}, mode: "quick-create" }) - expectCreateViewInputError(setCreateViewSpy, createView) + expectCreateViewInputError(setCreateViewSpy, creationView) expect(context.setMessage).not.toHaveBeenCalled() expect(context.setActiveScreen).not.toHaveBeenCalled() }) it("clears the inline repo URL error after editing the buffer", () => { - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const createView: CreateFlowView = { + const { setCreationView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const creationView: CreateFlowView = { ...createInitialFlowView(""), inputError: EMPTY_REPO_URL_ERROR } - setCreateBuffer(createView, setCreateView, "https://github.com/org/repo") + setCreateBuffer(creationView, setCreationView, "https://github.com/org/repo") expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ - ...createView, + ...creationView, buffer: "https://github.com/org/repo", inputError: null }) diff --git a/packages/app/tests/docker-git/app-ready-shortcuts.test.ts b/packages/app/tests/docker-git/app-ready-shortcuts.test.ts index 8e473534..4925675c 100644 --- a/packages/app/tests/docker-git/app-ready-shortcuts.test.ts +++ b/packages/app/tests/docker-git/app-ready-shortcuts.test.ts @@ -4,12 +4,12 @@ import { createInitialFlowView } from "../../src/docker-git/menu-create-shared.j import type { DashboardData } from "../../src/web/api.js" import { type BrowserShortcutArgs, dispatchBrowserShortcut } from "../../src/web/app-ready-shortcut-runtime.js" import { - handleMenuNavigationKey, - handleProjectNavigationKey, + didHandleMenuNavigationKey, + didHandleProjectNavigationKey, + isProjectPrimaryNavigationMenu, shortcutHintText, type ShortcutKeyboardEvent, - shouldRefreshProjectDetails, - usesProjectPrimaryNavigation + shouldRefreshProjectDetails } from "../../src/web/app-ready-shortcuts.js" import { makeBrowserActionContext } from "./browser-action-context-fixture.js" @@ -29,18 +29,18 @@ const makeEvent = (key: string): ShortcutKeyboardEvent => { return event } -const runProjectNavigation = (projectNavigationArmed: boolean) => { +const runProjectNavigation = (isProjectNavigationArmed: boolean) => { const event = makeEvent("ArrowDown") const setSelectedProjectId = vi.fn() - const handled = handleProjectNavigationKey(event, { + const isHandled = didHandleProjectNavigationKey(event, { currentMenu: "Select", dashboard, - projectNavigationArmed, + isProjectNavigationArmed, selectedProjectId: "project-a", setSelectedProjectId }) - return { handled, setSelectedProjectId } + return { handled: isHandled, setSelectedProjectId } } const storedTerminalSession: BrowserShortcutArgs["terminalSessions"][number] = { @@ -71,14 +71,14 @@ const makeShortcutArgs = ( actionPrompt: null, context, controllerCwd: "/repo", - createView: createInitialFlowView(""), + creationView: createInitialFlowView(""), currentMenu: "Tasks", dashboard, projectBrowser: null, projectsRoot: "/home/dev/.docker-git", selectedProjectId: "project-a", setActiveScreen: vi.fn(), - setCreateView: vi.fn(), + setCreationView: vi.fn(), setProjectNavigationArmed: vi.fn(), setSelectedMenuIndex: vi.fn(), setSelectedProjectId, @@ -126,29 +126,29 @@ const dashboard: DashboardData = { describe("app-ready-shortcuts", () => { it("uses project-first arrows in Select-like screens", () => { - expect(usesProjectPrimaryNavigation("Select")).toBe(true) - expect(usesProjectPrimaryNavigation("Info")).toBe(true) - expect(usesProjectPrimaryNavigation("Ports")).toBe(true) - expect(usesProjectPrimaryNavigation("Databases")).toBe(true) - expect(usesProjectPrimaryNavigation("Browser")).toBe(true) - expect(usesProjectPrimaryNavigation("Tasks")).toBe(true) - expect(usesProjectPrimaryNavigation("ProjectAuth")).toBe(true) - expect(usesProjectPrimaryNavigation("Logs")).toBe(true) - expect(usesProjectPrimaryNavigation("Create")).toBe(false) - expect(usesProjectPrimaryNavigation("Share")).toBe(false) + expect(isProjectPrimaryNavigationMenu("Select")).toBe(true) + expect(isProjectPrimaryNavigationMenu("Info")).toBe(true) + expect(isProjectPrimaryNavigationMenu("Ports")).toBe(true) + expect(isProjectPrimaryNavigationMenu("Databases")).toBe(true) + expect(isProjectPrimaryNavigationMenu("Browser")).toBe(true) + expect(isProjectPrimaryNavigationMenu("Tasks")).toBe(true) + expect(isProjectPrimaryNavigationMenu("ProjectAuth")).toBe(true) + expect(isProjectPrimaryNavigationMenu("Logs")).toBe(true) + expect(isProjectPrimaryNavigationMenu("Create")).toBe(false) + expect(isProjectPrimaryNavigationMenu("Share")).toBe(false) }) it("does not move projects in Select until project mode is armed", () => { - const { handled, setSelectedProjectId } = runProjectNavigation(false) + const { handled: isHandled, setSelectedProjectId } = runProjectNavigation(false) - expect(handled).toBe(false) + expect(isHandled).toBe(false) expect(setSelectedProjectId).not.toHaveBeenCalled() }) it("moves projects with up/down in armed Select", () => { - const { handled, setSelectedProjectId } = runProjectNavigation(true) + const { handled: isHandled, setSelectedProjectId } = runProjectNavigation(true) - expect(handled).toBe(true) + expect(isHandled).toBe(true) expect(setSelectedProjectId).toHaveBeenCalledWith("project-b") }) @@ -156,9 +156,9 @@ describe("app-ready-shortcuts", () => { const event = makeEvent("ArrowDown") const setSelectedMenuIndex = vi.fn() - const handled = handleMenuNavigationKey(event, "Select", false, setSelectedMenuIndex) + const isHandled = didHandleMenuNavigationKey(event, "Select", false, setSelectedMenuIndex) - expect(handled).toBe(true) + expect(isHandled).toBe(true) expect(setSelectedMenuIndex).toHaveBeenCalledTimes(1) }) @@ -166,9 +166,9 @@ describe("app-ready-shortcuts", () => { const event = makeEvent("ArrowDown") const setSelectedMenuIndex = vi.fn() - const handled = handleMenuNavigationKey(event, "Select", true, setSelectedMenuIndex) + const isHandled = didHandleMenuNavigationKey(event, "Select", true, setSelectedMenuIndex) - expect(handled).toBe(false) + expect(isHandled).toBe(false) expect(setSelectedMenuIndex).not.toHaveBeenCalled() }) @@ -178,11 +178,11 @@ describe("app-ready-shortcuts", () => { const setSelectedMenuIndex = vi.fn() const setSelectedProjectId = vi.fn() - expect(handleMenuNavigationKey(menuEvent, "Create", false, setSelectedMenuIndex)).toBe(true) - expect(handleProjectNavigationKey(projectEvent, { + expect(didHandleMenuNavigationKey(menuEvent, "Create", false, setSelectedMenuIndex)).toBe(true) + expect(didHandleProjectNavigationKey(projectEvent, { currentMenu: "Create", dashboard, - projectNavigationArmed: false, + isProjectNavigationArmed: false, selectedProjectId: "project-a", setSelectedProjectId })).toBe(false) diff --git a/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts b/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts index 2a1a7399..2a561a60 100644 --- a/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts +++ b/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts @@ -81,8 +81,8 @@ type ExpectedProjectHandlers = { readonly terminal: boolean } -const expectOptionalHandler = (handler: (() => void) | undefined, enabled: boolean): void => { - if (enabled) { +const expectOptionalHandler = (handler: (() => void) | undefined, isEnabled: boolean): void => { + if (isEnabled) { expect(typeof handler).toBe("function") return } diff --git a/packages/app/tests/docker-git/auth-stream-markers.test.ts b/packages/app/tests/docker-git/auth-stream-markers.test.ts index 1fb8bf5c..da95547e 100644 --- a/packages/app/tests/docker-git/auth-stream-markers.test.ts +++ b/packages/app/tests/docker-git/auth-stream-markers.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest" import { authStreamMarkerExitCode, - authStreamSucceeded, authStreamVisibleLines, codexLoginFailureMessage, codexLoginStreamMarkers, + didAuthStreamSucceed, githubLoginFailureMessage, githubLoginStreamMarkers, gitlabLoginFailureMessage, @@ -20,7 +20,7 @@ describe("auth stream markers", () => { githubLoginStreamMarkers.success ].join("\n") - expect(authStreamSucceeded(output, githubLoginStreamMarkers)).toBe(true) + expect(didAuthStreamSucceed(output, githubLoginStreamMarkers)).toBe(true) expect(authStreamVisibleLines(output, githubLoginStreamMarkers)).toEqual([ "Copy your one-time code: ABCD-1234" ]) @@ -42,7 +42,7 @@ describe("auth stream markers", () => { `${gitlabLoginStreamMarkers.errorPrefix}post-login` ].join("\n") - expect(authStreamSucceeded(`${gitlabLoginStreamMarkers.success}\n`, gitlabLoginStreamMarkers)).toBe(true) + expect(didAuthStreamSucceed(`${gitlabLoginStreamMarkers.success}\n`, gitlabLoginStreamMarkers)).toBe(true) expect(authStreamMarkerExitCode(output, gitlabLoginStreamMarkers)).toBe("post-login") expect(gitlabLoginFailureMessage(output, "post-login")).toBe("GitLab login failed") expect(authStreamVisibleLines(output, gitlabLoginStreamMarkers)).toEqual(["GitLab login failed"]) @@ -54,7 +54,7 @@ describe("auth stream markers", () => { `${codexLoginStreamMarkers.errorPrefix}1` ].join("\n") - expect(authStreamSucceeded(`${codexLoginStreamMarkers.success}\n`, codexLoginStreamMarkers)).toBe(true) + expect(didAuthStreamSucceed(`${codexLoginStreamMarkers.success}\n`, codexLoginStreamMarkers)).toBe(true) expect(authStreamMarkerExitCode(output, codexLoginStreamMarkers)).toBe("1") expect(codexLoginFailureMessage(output, "1")).toContain("rate-limited") expect(authStreamVisibleLines(output, codexLoginStreamMarkers)).toEqual([ diff --git a/packages/app/tests/docker-git/browser-frontend.test.ts b/packages/app/tests/docker-git/browser-frontend.test.ts index 08610ef3..1fb8b437 100644 --- a/packages/app/tests/docker-git/browser-frontend.test.ts +++ b/packages/app/tests/docker-git/browser-frontend.test.ts @@ -45,8 +45,8 @@ vi.mock("../../src/docker-git/frontend-lib/shell/command-runner.js", () => ({ runCommandExitCodeStreaming: runCommandExitCodeStreamingMock })) -const originalStdinTty = process.stdin.isTTY -const originalStdoutTty = process.stdout.isTTY +const isOriginalStdinTty = process.stdin.isTTY +const isOriginalStdoutTty = process.stdout.isTTY const makeNonInteractive = (): void => { Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }) @@ -54,8 +54,8 @@ const makeNonInteractive = (): void => { } const restoreTty = (): void => { - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalStdinTty }) - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalStdoutTty }) + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: isOriginalStdinTty }) + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: isOriginalStdoutTty }) } const runBrowserCommandUnderTest = Effect.gen(function*(_) { diff --git a/packages/app/tests/docker-git/controller-compose.test.ts b/packages/app/tests/docker-git/controller-compose.test.ts index a8f0467d..c59ce285 100644 --- a/packages/app/tests/docker-git/controller-compose.test.ts +++ b/packages/app/tests/docker-git/controller-compose.test.ts @@ -219,9 +219,10 @@ describe("controller compose preparation", () => { [controllerGpuModeEnvKey, undefined] ]) ) + const recordedExecutorLayer = recordedCommandExecutorLayer(startedCommands, emptyCommandResult) yield* _( runCompose(["up", "-d"]).pipe( - Effect.provide(recordedCommandExecutorLayer(startedCommands, emptyCommandResult)) + Effect.provide(recordedExecutorLayer) ) ) @@ -260,14 +261,13 @@ describe("controller compose preparation", () => { const rootDir = yield* _(temporaryControllerRoot) const startedCommands: Array = [] + const submoduleFailureExecutorLayer = recordedCommandExecutorLayer( + startedCommands, + { exitCode: 128, stderr: "fatal: no submodule mapping found", stdout: "" } + ) const error = yield* _( ensureSkillerSubmoduleInitialized(rootDir).pipe( - Effect.provide( - recordedCommandExecutorLayer( - startedCommands, - { exitCode: 128, stderr: "fatal: no submodule mapping found", stdout: "" } - ) - ), + Effect.provide(submoduleFailureExecutorLayer), Effect.provide(NodeContext.layer), Effect.flip ) diff --git a/packages/app/tests/docker-git/controller-revision.test.ts b/packages/app/tests/docker-git/controller-revision.test.ts index f8cd2664..b30690f8 100644 --- a/packages/app/tests/docker-git/controller-revision.test.ts +++ b/packages/app/tests/docker-git/controller-revision.test.ts @@ -239,8 +239,9 @@ describe("controller revisions", () => { revisionFileContentsArbitrary, ignoredControllerRevisionEntrySubsetArbitrary, revisionFileContentsArbitrary, - (trackedContents, ignoredEntries, generatedContents) => - Effect.runPromise( + (trackedContents, ignoredEntries, generatedContents) => { + const memoryFileSystemLayer = createMemoryFileSystemLayer() + return Effect.runPromise( Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) @@ -256,10 +257,11 @@ describe("controller revisions", () => { const after = yield* _(computeRevisionFromInputs(rootDir, [memoryRevisionInput])) expect(after).toBe(before) }).pipe( - Effect.provide(createMemoryFileSystemLayer()), + Effect.provide(memoryFileSystemLayer), Effect.provide(Path.layer) ) ) + } ) )) diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index e8cfc128..4c5d06f1 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -173,13 +173,13 @@ describe("controller reachability", () => { explicitApiBaseUrlArbitrary, dockerNetworkIpsArbitrary, (dockerHost, explicitApiBaseUrl, currentContainerNetworks) => { - const expected = isRemoteDockerHost(dockerHost) && + const isExpected = isRemoteDockerHost(dockerHost) && explicitApiBaseUrl === undefined && Object.keys(currentContainerNetworks).length === 0 expect( shouldRequireExplicitApiUrlForRemoteDocker(dockerHost, explicitApiBaseUrl, currentContainerNetworks) - ).toBe(expected) + ).toBe(isExpected) } ) ) @@ -187,9 +187,10 @@ describe("controller reachability", () => { it.effect("resolves the current container name from HOSTNAME or OS hostname", () => Effect.sync(() => { + const optionalHostnameArbitrary = fc.option(fc.string(), { nil: undefined }) fc.assert( fc.property( - fc.option(fc.string(), { nil: undefined }), + optionalHostnameArbitrary, fc.string(), (envHostname, systemHostname) => { expect(resolveCurrentContainerName(envHostname, systemHostname)).toBe( diff --git a/packages/app/tests/docker-git/create-flow-render.test.ts b/packages/app/tests/docker-git/create-flow-render.test.ts index 22b1637a..e08cb6f0 100644 --- a/packages/app/tests/docker-git/create-flow-render.test.ts +++ b/packages/app/tests/docker-git/create-flow-render.test.ts @@ -8,12 +8,12 @@ import { type CreateFlowView, createInitialFlowView, type CreateModeFlowView, - createSettingsHint, type DisplayModeFlowView, renderCreateStepLabel, resolveCreateDisplaySteps, resolveCreateFlowSteps, - resolveCreateInputs + resolveCreateInputs, + settingsHint } from "../../src/docker-git/menu-create-shared.js" import { renderCreate } from "../../src/docker-git/menu-render.js" import { webPrimitives } from "../../src/ui/primitives-web.js" @@ -26,7 +26,7 @@ import { featureCreateRepoUrl } from "./create-flow-test-helpers.js" -const createContext: CreateFlowContext = { +const context: CreateFlowContext = { cwd: "/workspace", projectsRoot: "/home/dev/.docker-git" } @@ -36,20 +36,20 @@ const renderWithUi = (element: ReactElement): string => const webCreateSettingsNavigationHint = "↑ - up, ↓ - down, Enter - apply + down" const webCreateSettingsChoiceHint = "←/→ - choose yes/no or GPU" -const createSettingsView = (): DisplayModeFlowView => createFeatureRepoDisplaySettingsView(createContext) +const createSettingsView = (): DisplayModeFlowView => createFeatureRepoDisplaySettingsView(context) const renderCreatePanel = ( - createView: CreateFlowView, + creationView: CreateFlowView, options: { readonly compact?: boolean } = {} ): string => renderWithUi(createElement(CreatePanel, { compact: options.compact ?? false, - controllerCwd: createContext.cwd, - createView, + controllerCwd: context.cwd, + creationView, onBufferChange: vi.fn(), onCancel: vi.fn(), onSubmit: vi.fn(), - projectsRoot: createContext.projectsRoot ?? "" + projectsRoot: context.projectsRoot ?? "" })) const activeStepMarker = "> " @@ -57,14 +57,14 @@ const activeStepMarker = "> " const countActiveStepMarkers = (html: string): number => html.split(activeStepMarker).length - 1 const renderedHelpLine = (line: string): string => `>${line}` -const renderStepLabels = (createView: CreateFlowView): ReadonlyArray => { - const defaults = resolveCreateInputs(createContext, createView.values) - return resolveCreateDisplaySteps(createView.values).map((step) => renderCreateStepLabel(step, defaults)) +const renderStepLabels = (creationView: CreateFlowView): ReadonlyArray => { + const defaults = resolveCreateInputs(context, creationView.values) + return resolveCreateDisplaySteps(creationView.values).map((step) => renderCreateStepLabel(step, defaults)) } -const renderSettingsStepLabels = (createView: CreateFlowView): ReadonlyArray => { - const defaults = resolveCreateInputs(createContext, createView.values) - return resolveCreateDisplaySteps(createView.values) +const renderSettingsStepLabels = (creationView: CreateFlowView): ReadonlyArray => { + const defaults = resolveCreateInputs(context, creationView.values) + return resolveCreateDisplaySteps(creationView.values) .filter((step) => step !== "repoUrl") .map((step) => renderCreateStepLabel(step, defaults)) } @@ -74,18 +74,18 @@ const createSettingsViewAtStep = ( buffer: string ): CreateFlowView => createFlowViewAtStep(createSettingsView(), stepName, buffer) -const createTerminalSettingsView = (): CreateModeFlowView => createFeatureRepoSettingsView(createContext) +const createTerminalSettingsView = (): CreateModeFlowView => createFeatureRepoSettingsView(context) -const renderTerminalCreate = (createView: CreateModeFlowView): string => { - const defaults = resolveCreateInputs(createContext, createView.values) - const steps = resolveCreateFlowSteps(createView.values) - const step = steps[createView.step] ?? "repoUrl" +const renderTerminalCreate = (creationView: CreateModeFlowView): string => { + const defaults = resolveCreateInputs(context, creationView.values) + const steps = resolveCreateFlowSteps(creationView.values) + const step = steps[creationView.step] ?? "repoUrl" return renderWithUi(renderCreate({ - buffer: createView.buffer, + buffer: creationView.buffer, defaults, label: renderCreateStepLabel(step, defaults), message: null, - stepIndex: createView.step, + stepIndex: creationView.step, steps })) } @@ -132,27 +132,27 @@ describe("Create flow rendering", () => { }) it("keeps the compact repo URL step focused on the repo input and action buttons", () => { - const createView = createInitialFlowView(featureCreateRepoUrl) - const html = renderCreatePanel(createView, { compact: true }) + const creationView = createInitialFlowView(featureCreateRepoUrl) + const html = renderCreatePanel(creationView, { compact: true }) expect(html).toContain("Repo URL (optional for empty workspace)") expect(html).not.toContain(webCreateSettingsChoiceHint) - for (const label of renderSettingsStepLabels(createView)) { + for (const label of renderSettingsStepLabels(creationView)) { expect(html).not.toContain(label) } }) it("renders every create row in compact settings mode", () => { - const createView = createSettingsView() - const html = renderCreatePanel(createView, { compact: true }) + const creationView = createSettingsView() + const html = renderCreatePanel(creationView, { compact: true }) - for (const label of renderStepLabels(createView)) { + for (const label of renderStepLabels(creationView)) { expect(html).toContain(label) } }) it("keeps applied create rows visible with confirmed values", () => { - const createView: CreateFlowView = { + const creationView: CreateFlowView = { ...createSettingsViewAtStep("mcpPlaywright", ""), values: { ...createSettingsView().values, @@ -160,7 +160,7 @@ describe("Create flow rendering", () => { force: true } } - const html = renderCreatePanel(createView, { compact: true }) + const html = renderCreatePanel(creationView, { compact: true }) expect(html).toContain("Enable Playwright MCP (nested Chromium browser)? [Y]") expect(html).toContain("Force recreate (overwrite files + wipe volumes)? [Y]") @@ -179,17 +179,17 @@ describe("Create flow rendering", () => { }) it("marks only the current row active in compact settings mode", () => { - const createView = createSettingsView() - const html = renderCreatePanel(createView, { compact: true }) - const activeLabel = renderStepLabels(createView)[createView.step] ?? "Repo URL (optional for empty workspace)" + const creationView = createSettingsView() + const html = renderCreatePanel(creationView, { compact: true }) + const activeLabel = renderStepLabels(creationView)[creationView.step] ?? "Repo URL (optional for empty workspace)" expect(countActiveStepMarkers(html)).toBe(1) expect(html).toContain(`${activeStepMarker}${activeLabel}`) }) it("previews side-arrow choices in the active settings row brackets without applying values", () => { - const createView = createSettingsViewAtStep("mcpPlaywright", "y") - const html = renderCreatePanel(createView, { compact: true }) + const creationView = createSettingsViewAtStep("mcpPlaywright", "y") + const html = renderCreatePanel(creationView, { compact: true }) expect(html).toContain(`${activeStepMarker}Enable Playwright MCP (nested Chromium browser)? [Y]`) expect(html).toContain("Enable Playwright MCP (nested Chromium browser)? [Y]:") @@ -198,8 +198,8 @@ describe("Create flow rendering", () => { }) it("drops unapplied bracket previews after settings navigation clears the buffer", () => { - const createView = createSettingsViewAtStep("force", "") - const html = renderCreatePanel(createView, { compact: true }) + const creationView = createSettingsViewAtStep("force", "") + const html = renderCreatePanel(creationView, { compact: true }) expect(html).toContain(`${activeStepMarker}Force recreate (overwrite files + wipe volumes)? [N]`) expect(html).not.toContain("Force recreate (overwrite files + wipe volumes)? [Y]") @@ -207,7 +207,7 @@ describe("Create flow rendering", () => { it("renders the settings navigation hint only after leaving the repo URL step", () => { expect(renderCreatePanel(createInitialFlowView(featureCreateRepoUrl))).not.toContain( - renderedHelpLine(createSettingsHint) + renderedHelpLine(settingsHint) ) expect(renderCreatePanel(createInitialFlowView(featureCreateRepoUrl))).not.toContain( renderedHelpLine(webCreateSettingsNavigationHint) @@ -215,7 +215,7 @@ describe("Create flow rendering", () => { expect(renderCreatePanel(createInitialFlowView(featureCreateRepoUrl))).not.toContain( renderedHelpLine(webCreateSettingsChoiceHint) ) - expect(renderCreatePanel(createSettingsView())).not.toContain(renderedHelpLine(createSettingsHint)) + expect(renderCreatePanel(createSettingsView())).not.toContain(renderedHelpLine(settingsHint)) expect(renderCreatePanel(createSettingsView())).toContain(renderedHelpLine(webCreateSettingsNavigationHint)) expect(renderCreatePanel(createSettingsView())).toContain(renderedHelpLine(webCreateSettingsChoiceHint)) }) @@ -226,7 +226,7 @@ describe("Create flow rendering", () => { expect(repoHtml).not.toContain("Enter = next, Esc = cancel.") expect(repoHtml).not.toContain("Shift+Enter") - expect(settingsHtml).toContain(renderedHelpLine(createSettingsHint)) + expect(settingsHtml).toContain(renderedHelpLine(settingsHint)) expect(settingsHtml).not.toContain(renderedHelpLine(webCreateSettingsNavigationHint)) expect(settingsHtml).not.toContain(renderedHelpLine(webCreateSettingsChoiceHint)) }) @@ -242,8 +242,8 @@ describe("Create flow rendering", () => { const panelHtml = renderCreatePanel(view) const compactPanelHtml = renderCreatePanel(view, { compact: true }) - expect(panelHtml).not.toContain(renderedHelpLine(createSettingsHint)) - expect(compactPanelHtml).not.toContain(renderedHelpLine(createSettingsHint)) + expect(panelHtml).not.toContain(renderedHelpLine(settingsHint)) + expect(compactPanelHtml).not.toContain(renderedHelpLine(settingsHint)) expect(panelHtml.includes(renderedHelpLine(webCreateSettingsNavigationHint))).toBe(isSettings) expect(compactPanelHtml.includes(renderedHelpLine(webCreateSettingsNavigationHint))).toBe(isSettings) expect(panelHtml.includes(renderedHelpLine(webCreateSettingsChoiceHint))).toBe(isSettings) @@ -263,7 +263,7 @@ describe("Create flow rendering", () => { const view = step === 0 ? createInitialFlowView(featureCreateRepoUrl) : { ...terminalSettingsView, step } const terminalHtml = renderTerminalCreate(view) - expect(terminalHtml.includes(renderedHelpLine(createSettingsHint))).toBe(step > 0) + expect(terminalHtml.includes(renderedHelpLine(settingsHint))).toBe(step > 0) expect(terminalHtml).not.toContain(renderedHelpLine(webCreateSettingsNavigationHint)) expect(terminalHtml).not.toContain(renderedHelpLine(webCreateSettingsChoiceHint)) expect(terminalHtml).not.toContain("Enter = next, Esc = cancel.") diff --git a/packages/app/tests/docker-git/github-auth-gate.test.ts b/packages/app/tests/docker-git/github-auth-gate.test.ts index 3093867b..b191ae8a 100644 --- a/packages/app/tests/docker-git/github-auth-gate.test.ts +++ b/packages/app/tests/docker-git/github-auth-gate.test.ts @@ -43,8 +43,10 @@ describe("github-auth-gate", () => { }) it("accepts valid or unknown tokens as configured", () => { - expect(isGithubAuthConfigured(makeStatus([makeToken("valid")]))).toBe(true) - expect(isGithubAuthConfigured(makeStatus([makeToken("unknown")]))).toBe(true) + const validStatus = makeStatus([makeToken("valid")]) + const unknownStatus = makeStatus([makeToken("unknown")]) + expect(isGithubAuthConfigured(validStatus)).toBe(true) + expect(isGithubAuthConfigured(unknownStatus)).toBe(true) }) it("blocks non-auth browser actions while GitHub auth is required", () => { diff --git a/packages/app/tests/docker-git/menu-create-display-settings.test.ts b/packages/app/tests/docker-git/menu-create-display-settings.test.ts index 2cc4e3ac..1aebb66f 100644 --- a/packages/app/tests/docker-git/menu-create-display-settings.test.ts +++ b/packages/app/tests/docker-git/menu-create-display-settings.test.ts @@ -84,9 +84,10 @@ describe("menu-create-shared display settings", () => { }) it("applies a browser display setting and advances to the next row", () => { + const mcpPlaywrightView = createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), "mcpPlaywright") const next = expectDisplayModeView(expectCreateContinueView(advanceCreateDisplaySettingsStep( cwd, - { ...createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), "mcpPlaywright"), buffer: "y" } + { ...mcpPlaywrightView, buffer: "y" } ))) expect(next.step).toBe(resolveCreateDisplaySteps().indexOf("force")) @@ -114,9 +115,10 @@ describe("menu-create-shared display settings", () => { it("navigates browser display settings without skipping applied rows", () => { const view = createFeatureRepoDisplaySettingsView(cwd) + const mcpPlaywrightView = createFlowViewAtStep(view, "mcpPlaywright") const applied = expectDisplayModeView(expectCreateContinueView(applyCreateDisplaySettingsStep( cwd, - { ...createFlowViewAtStep(view, "mcpPlaywright"), buffer: "y" } + { ...mcpPlaywrightView, buffer: "y" } ))) const down = moveCreateDisplaySettingsStep(applied, "down") const up = moveCreateDisplaySettingsStep(applied, "up") @@ -128,9 +130,10 @@ describe("menu-create-shared display settings", () => { }) it("resolves horizontal choices against applied browser display rows", () => { + const mcpPlaywrightView = createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), "mcpPlaywright") const applied = expectDisplayModeView(expectCreateContinueView(applyCreateDisplaySettingsStep( cwd, - { ...createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), "mcpPlaywright"), buffer: "y" } + { ...mcpPlaywrightView, buffer: "y" } ))) expect(resolveCreateSettingsChoiceBuffer(applied, "left")).toBe("n") @@ -138,9 +141,10 @@ describe("menu-create-shared display settings", () => { }) it("completes browser display settings with a valid active buffer", () => { + const mcpPlaywrightView = createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), "mcpPlaywright") const complete = expectCreateCompleteInputs(completeCreateDisplaySettingsFlow( cwd, - { ...createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), "mcpPlaywright"), buffer: "y" } + { ...mcpPlaywrightView, buffer: "y" } )) expect(complete.enableMcpPlaywright).toBe(true) diff --git a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts index 6366fd59..7fe2b3ae 100644 --- a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts @@ -8,7 +8,7 @@ import { featureCreateRepoUrl } from "./create-flow-test-helpers.js" type CreateSettingStep = Exclude -const createSettingsStepArbitrary: fc.Arbitrary = fc.constantFrom( +const settingsStepArbitrary: fc.Arbitrary = fc.constantFrom( "cpuLimit", "ramLimit", "gpu", @@ -17,7 +17,7 @@ const createSettingsStepArbitrary: fc.Arbitrary = fc.constant "force" ) -const createStepBufferByStep: Readonly> = { +const stepBufferByStep: Readonly> = { cpuLimit: "25%", force: "y", gpu: "all", @@ -26,7 +26,7 @@ const createStepBufferByStep: Readonly> = { runUp: "y" } -const satisfiedCreateSettingsArbitrary = fc.uniqueArray(createSettingsStepArbitrary, { +const satisfiedCreateSettingsArbitrary = fc.uniqueArray(settingsStepArbitrary, { maxLength: 6 }) @@ -112,7 +112,7 @@ describe("menu-create-shared property invariants", () => { it("preserves the next remaining settings index after applying generated current settings", () => { fc.assert( - fc.property(createSettingsStepArbitrary, satisfiedCreateSettingsArbitrary, (currentStep, satisfiedSteps) => { + fc.property(settingsStepArbitrary, satisfiedCreateSettingsArbitrary, (currentStep, satisfiedSteps) => { const values = createValuesWithSatisfiedSettings( satisfiedSteps.filter((satisfiedStep) => satisfiedStep !== currentStep), defaultRoot @@ -124,7 +124,7 @@ describe("menu-create-shared property invariants", () => { const next = advanceCreateFlow( cwd, { - buffer: createStepBufferByStep[currentStep], + buffer: stepBufferByStep[currentStep], inputError: null, mode: "create", step: currentStepIndex, diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index ca7b8ed3..63177803 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -224,7 +224,7 @@ describe("menu-create-shared", () => { }) it("maps create-mode steps to the matching display row when opening browser Settings", () => { - const createView = { + const creationView = { ...createFeatureRepoSettingsView(cwd), step: 1, values: { @@ -232,12 +232,12 @@ describe("menu-create-shared", () => { cpuLimit: "40%" } } - const displayView = createDisplayFlowView(createView) + const displayView = createDisplayFlowView(creationView) - expect(resolveCreateFlowSteps(createView.values)[createView.step]).toBe("ramLimit") + expect(resolveCreateFlowSteps(creationView.values)[creationView.step]).toBe("ramLimit") expect(resolveCreateDisplaySteps()[displayView.step]).toBe("ramLimit") - expect(displayView.buffer).toBe(createView.buffer) - expect(displayView.values).toEqual(createView.values) + expect(displayView.buffer).toBe(creationView.buffer) + expect(displayView.values).toEqual(creationView.values) }) it("does not navigate settings from the repo URL step", () => { @@ -301,16 +301,17 @@ describe("menu-create-shared", () => { }) it("completes after applying generated only remaining create settings", () => { + const generatedSettingsArbitrary = fc.record({ + cpuLimit: fc.constantFrom("", "25%", "50%"), + enableMcpPlaywright: fc.boolean(), + force: fc.boolean(), + gpu: fc.constantFrom("none", "all"), + ramLimit: fc.constantFrom("", "2g", "4g"), + runUp: fc.boolean() + }) fc.assert( fc.property( - fc.record({ - cpuLimit: fc.constantFrom("", "25%", "50%"), - enableMcpPlaywright: fc.boolean(), - force: fc.boolean(), - gpu: fc.constantFrom("none", "all"), - ramLimit: fc.constantFrom("", "2g", "4g"), - runUp: fc.boolean() - }), + generatedSettingsArbitrary, ({ force, ...generatedValues }) => { const values = { outDir: defaultRoot, diff --git a/packages/app/tests/docker-git/menu-select-actions.test.ts b/packages/app/tests/docker-git/menu-select-actions.test.ts index 2225430f..a98886fb 100644 --- a/packages/app/tests/docker-git/menu-select-actions.test.ts +++ b/packages/app/tests/docker-git/menu-select-actions.test.ts @@ -74,8 +74,8 @@ const makeContext = () => { messages.push(message) }, setSkipInputs: vi.fn(), - setSshActive: (active: boolean) => { - sshActiveStates.push(active) + setSshActive: (isActive: boolean) => { + sshActiveStates.push(isActive) }, setView: vi.fn(), sshActiveStates diff --git a/packages/app/tests/docker-git/menu-shared.test.ts b/packages/app/tests/docker-git/menu-shared.test.ts index 52081906..2c401770 100644 --- a/packages/app/tests/docker-git/menu-shared.test.ts +++ b/packages/app/tests/docker-git/menu-shared.test.ts @@ -11,15 +11,15 @@ vi.mock("../../src/docker-git/frontend-lib/shell/terminal-cursor.js", () => ({ repairInteractiveTerminal: repairInteractiveTerminalMock })) -const primaryScreenEscape = "\u001B[?1049l\r\u001B[2K" -const alternateScreenEscape = "\u001B[?1049h\u001B[2J\u001B[H" -const inputModesEscape = "\u001B[0m" + - "\u001B[?25h" + - "\u001B[?1l" + - "\u001B>" + - "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l" + - "\u001B[?1004l\u001B[?2004l" + - "\u001B[>4;0m\u001B[>4m\u001B[" + + "\u{1B}[?1000l\u{1B}[?1002l\u{1B}[?1003l\u{1B}[?1005l\u{1B}[?1006l\u{1B}[?1015l\u{1B}[?1007l" + + "\u{1B}[?1004l\u{1B}[?2004l" + + "\u{1B}[>4;0m\u{1B}[>4m\u{1B}[", `write:${primaryScreenEscape}`] const alternateScreenResumeEvents = [ "repair", @@ -31,8 +31,8 @@ const alternateScreenResumeEvents = [ const originalStdoutWrite: typeof process.stdout.write = process.stdout.write.bind(process.stdout) const originalStderrWrite: typeof process.stderr.write = process.stderr.write.bind(process.stderr) -const originalStdinTty = process.stdin.isTTY -const originalStdoutTty = process.stdout.isTTY +const isOriginalStdinTty = process.stdin.isTTY +const isOriginalStdoutTty = process.stdout.isTTY const originalSetRawMode = Reflect.get(process.stdin, "setRawMode") const loadMenuShared = Effect.tryPromise({ @@ -43,13 +43,15 @@ const loadMenuShared = Effect.tryPromise({ const restoreTerminalBindings = (): void => { process.stdout.write = originalStdoutWrite process.stderr.write = originalStderrWrite - Object.defineProperty(process.stdin, "setRawMode", { configurable: true, value: originalSetRawMode }) - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalStdinTty }) - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalStdoutTty }) + Object.defineProperties(process.stdin, { + setRawMode: { configurable: true, value: originalSetRawMode }, + isTTY: { configurable: true, value: isOriginalStdinTty } + }) + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: isOriginalStdoutTty }) } -const createRawModeStub = (events: Array): typeof process.stdin.setRawMode => (enabled: boolean) => { - events.push(`raw:${String(enabled)}`) +const createRawModeStub = (events: Array): typeof process.stdin.setRawMode => (isEnabled: boolean) => { + events.push(`raw:${String(isEnabled)}`) return process.stdin } diff --git a/packages/app/tests/docker-git/open-url.test.ts b/packages/app/tests/docker-git/open-url.test.ts index e808a5ca..3349fd20 100644 --- a/packages/app/tests/docker-git/open-url.test.ts +++ b/packages/app/tests/docker-git/open-url.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest" import * as fc from "fast-check" import { afterEach, vi } from "vitest" -import { openUrl, prepareOpenUrl } from "../../src/web/open-url.js" +import { didOpenUrl, prepareOpenUrl } from "../../src/web/open-url.js" import { makeBrowserOpenMockWindow, stubBrowserOpen } from "./browser-open-fixture.js" const urlChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/.:?=&" @@ -47,7 +47,7 @@ describe("open-url helpers", () => { it("reports direct opens as blocked when no browser open function exists", () => { vi.stubGlobal("open", null) - expect(openUrl("/api/projects/project-1/browser/novnc")).toBe(false) + expect(didOpenUrl("/api/projects/project-1/browser/novnc")).toBe(false) expect(prepareOpenUrl().navigate("/api/projects/project-1/browser/novnc")).toBe(false) }) @@ -56,7 +56,7 @@ describe("open-url helpers", () => { fc.property(urlArbitrary, (url) => { vi.stubGlobal("open", null) - expect(openUrl(url)).toBe(false) + expect(didOpenUrl(url)).toBe(false) expect(prepareOpenUrl().navigate(url)).toBe(false) }), { numRuns: 30 } diff --git a/packages/app/tests/docker-git/parser-apply-all.test.ts b/packages/app/tests/docker-git/parser-apply-all.test.ts index 95af2b85..96c72864 100644 --- a/packages/app/tests/docker-git/parser-apply-all.test.ts +++ b/packages/app/tests/docker-git/parser-apply-all.test.ts @@ -3,11 +3,11 @@ import { Effect } from "effect" import { parseOrThrow } from "./parser-helpers.js" -const assertApplyAllActiveOnly = (args: ReadonlyArray, expectedActiveOnly: boolean) => { +const assertApplyAllActiveOnly = (args: ReadonlyArray, isExpectedActiveOnly: boolean) => { const command = parseOrThrow(args) expect(command._tag).toBe("ApplyAll") if (command._tag === "ApplyAll") { - expect(command.activeOnly).toBe(expectedActiveOnly) + expect(command.activeOnly).toBe(isExpectedActiveOnly) } } diff --git a/packages/app/tests/docker-git/parser-helpers.ts b/packages/app/tests/docker-git/parser-helpers.ts index 818716c5..e27455a0 100644 --- a/packages/app/tests/docker-git/parser-helpers.ts +++ b/packages/app/tests/docker-git/parser-helpers.ts @@ -38,7 +38,7 @@ export const expectProjectDirRunUpCommand = ( args: ReadonlyArray, expectedTag: ProjectDirRunUpCommand["_tag"], expectedProjectDir: string, - expectedRunUp: boolean + isExpectedRunUp: boolean ) => Effect.sync(() => { const command = parseOrThrow(args) @@ -49,7 +49,7 @@ export const expectProjectDirRunUpCommand = ( throw new Error("expected command with projectDir and runUp") } expect(command.projectDir).toBe(expectedProjectDir) - expect(command.runUp).toBe(expectedRunUp) + expect(command.runUp).toBe(isExpectedRunUp) }) export const expectAttachProjectDirCommand = ( diff --git a/packages/app/tests/docker-git/terminal-session-client.test.ts b/packages/app/tests/docker-git/terminal-session-client.test.ts index 5645dcbb..287b8794 100644 --- a/packages/app/tests/docker-git/terminal-session-client.test.ts +++ b/packages/app/tests/docker-git/terminal-session-client.test.ts @@ -43,10 +43,10 @@ class FakeWebSocket { static readonly CLOSED = 3 static readonly instances: Array = [] + private readonly listeners: Array = [] readonly sent: Array = [] readonly url: string readyState = FakeWebSocket.CONNECTING - private readonly listeners: Array = [] constructor(url: string) { this.url = url @@ -63,16 +63,8 @@ class FakeWebSocket { this.listeners.push({ listener, type: "message" }) return } - if (isVoidSocketListener(type, listener)) { - if (type === "close") { - this.listeners.push({ listener, type: "close" }) - } - if (type === "error") { - this.listeners.push({ listener, type: "error" }) - } - if (type === "open") { - this.listeners.push({ listener, type: "open" }) - } + if (type !== "message" && isVoidSocketListener(type, listener)) { + this.listeners.push({ listener, type }) } } @@ -115,8 +107,8 @@ const loadTerminalSessionClient = Effect.tryPromise({ catch: (error) => (error instanceof Error ? error : new Error(String(error))) }) -const originalStdinIsTty = process.stdin.isTTY -const originalStdoutIsTty = process.stdout.isTTY +const isOriginalStdinIsTty = process.stdin.isTTY +const isOriginalStdoutIsTty = process.stdout.isTTY const originalStdoutColumns = process.stdout.columns const originalStdoutRows = process.stdout.rows const originalStdinOff = process.stdin.off.bind(process.stdin) @@ -128,7 +120,7 @@ const originalSetRawMode = typeof process.stdin.setRawMode === "function" ? process.stdin.setRawMode.bind(process.stdin) : undefined -const setRawModeMock = vi.fn((_enabled: boolean) => process.stdin) +const setRawModeMock = vi.fn((_isEnabled: boolean) => process.stdin) const stdinOnMock = vi.fn((_event: string, _listener: StdinListener) => process.stdin) const stdinOffMock = vi.fn((_event: string, _listener: StdinListener) => process.stdin) const stdoutOnMock = vi.fn((_event: string, _listener: StdoutListener) => process.stdout) @@ -206,39 +198,52 @@ describe("terminal-session-client", () => { vi.stubGlobal("WebSocket", FakeWebSocket) Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }) - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }) - Object.defineProperty(process.stdout, "columns", { configurable: true, value: 132 }) - Object.defineProperty(process.stdout, "rows", { configurable: true, value: 40 }) - Object.defineProperty(process.stdin, "setRawMode", { configurable: true, value: setRawModeMock }) - Object.defineProperty(process.stdin, "on", { configurable: true, value: stdinOnMock }) - Object.defineProperty(process.stdin, "off", { configurable: true, value: stdinOffMock }) - Object.defineProperty(process.stdout, "on", { configurable: true, value: stdoutOnMock }) - Object.defineProperty(process.stdout, "off", { configurable: true, value: stdoutOffMock }) + Object.defineProperties(process.stdout, { + isTTY: { configurable: true, value: true }, + columns: { configurable: true, value: 132 }, + rows: { configurable: true, value: 40 } + }) + Object.defineProperties(process.stdin, { + setRawMode: { configurable: true, value: setRawModeMock }, + on: { configurable: true, value: stdinOnMock }, + off: { configurable: true, value: stdinOffMock } + }) + Object.defineProperties(process.stdout, { + on: { configurable: true, value: stdoutOnMock }, + off: { configurable: true, value: stdoutOffMock } + }) Object.defineProperty(process.stdin, "resume", { configurable: true, value: stdinResumeMock }) }) afterEach(() => { vi.useRealTimers() - Object.defineProperty(process.stdin, "setRawMode", { configurable: true, value: originalSetRawMode }) - Object.defineProperty(process.stdin, "on", { configurable: true, value: originalStdinOn }) - Object.defineProperty(process.stdin, "off", { configurable: true, value: originalStdinOff }) - Object.defineProperty(process.stdout, "on", { configurable: true, value: originalStdoutOn }) - Object.defineProperty(process.stdout, "off", { configurable: true, value: originalStdoutOff }) - Object.defineProperty(process.stdin, "resume", { configurable: true, value: originalStdinResume }) - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalStdinIsTty }) - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalStdoutIsTty }) - Object.defineProperty(process.stdout, "columns", { configurable: true, value: originalStdoutColumns }) - Object.defineProperty(process.stdout, "rows", { configurable: true, value: originalStdoutRows }) + Object.defineProperties(process.stdin, { + setRawMode: { configurable: true, value: originalSetRawMode }, + on: { configurable: true, value: originalStdinOn }, + off: { configurable: true, value: originalStdinOff } + }) + Object.defineProperties(process.stdout, { + on: { configurable: true, value: originalStdoutOn }, + off: { configurable: true, value: originalStdoutOff } + }) + Object.defineProperties(process.stdin, { + resume: { configurable: true, value: originalStdinResume }, + isTTY: { configurable: true, value: isOriginalStdinIsTty } + }) + Object.defineProperties(process.stdout, { + isTTY: { configurable: true, value: isOriginalStdoutIsTty }, + columns: { configurable: true, value: originalStdoutColumns }, + rows: { configurable: true, value: originalStdoutRows } + }) vi.unstubAllGlobals() }) it("fails fast when the websocket never opens", () => Effect.gen(function*(_) { const { attachTerminalSession } = yield* _(loadTerminalSessionClient) - const result = yield* _(Effect.promise(() => { - const promise = Effect.runPromise(attachTerminalSession(makeAttachment()).pipe(Effect.either)) - return vi.advanceTimersByTimeAsync(3001).then(() => promise) - })) + const attachPromise = Effect.runPromise(attachTerminalSession(makeAttachment()).pipe(Effect.either)) + yield* _(Effect.promise(() => vi.advanceTimersByTimeAsync(3001))) + const result = yield* _(Effect.promise(() => attachPromise)) expect(Either.isLeft(result)).toBe(true) if (Either.isLeft(result)) { @@ -259,7 +264,8 @@ describe("terminal-session-client", () => { const { attachTerminalSession } = yield* _(loadTerminalSessionClient) const { promise, socket } = startOpenedAttachment(attachTerminalSession) - const result = yield* _(Effect.promise(() => vi.advanceTimersByTimeAsync(5001).then(() => promise))) + yield* _(Effect.promise(() => vi.advanceTimersByTimeAsync(5001))) + const result = yield* _(Effect.promise(() => promise)) expect(Either.isLeft(result)).toBe(true) if (Either.isLeft(result)) { diff --git a/packages/container/package.json b/packages/container/package.json index d40ad9e7..0c466384 100644 --- a/packages/container/package.json +++ b/packages/container/package.json @@ -50,10 +50,10 @@ "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", "@ton-ai-core/vibecode-linter": "^1.0.11", "@types/node": "^25.9.3", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", - "typescript-eslint": "^8.61.0", - "@vitest/coverage-v8": "^4.1.8", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", + "typescript-eslint": "^8.61.1", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^10.5.0", "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-codegen": "0.34.1", @@ -61,14 +61,14 @@ "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", - "eslint-plugin-unicorn": "^65.0.1", + "eslint-plugin-unicorn": "^67.0.0", "fast-check": "^4.8.0", "@vitest/eslint-plugin": "^1.6.20", "globals": "^17.6.0", "jscpd": "^5.0.9", "typescript": "^6.0.3", "vite": "^8.0.16", - "vitest": "^4.1.8" + "vitest": "^4.1.9" }, "exports": { ".": { diff --git a/packages/container/src/core/templates-entrypoint/agents-notice.ts b/packages/container/src/core/templates-entrypoint/agents-notice.ts index f7fe6b97..14e9d920 100644 --- a/packages/container/src/core/templates-entrypoint/agents-notice.ts +++ b/packages/container/src/core/templates-entrypoint/agents-notice.ts @@ -132,6 +132,6 @@ fi` export const renderEntrypointAgentsNotice = (config: TemplateConfig): string => entrypointAgentsNoticeTemplate - .replaceAll("__CODEX_HOME__", config.codexHome) - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll("__TARGET_DIR__", config.targetDir) + .replaceAll("__CODEX_HOME__", () => config.codexHome) + .replaceAll("__SSH_USER__", () => config.sshUser) + .replaceAll("__TARGET_DIR__", () => config.targetDir) diff --git a/packages/container/src/core/templates-entrypoint/claude-extra-config.ts b/packages/container/src/core/templates-entrypoint/claude-extra-config.ts index 8228b983..d9c4b5a5 100644 --- a/packages/container/src/core/templates-entrypoint/claude-extra-config.ts +++ b/packages/container/src/core/templates-entrypoint/claude-extra-config.ts @@ -92,16 +92,16 @@ const escapeForDoubleQuotes = (value: string): string => { const escapedBackslash = `${backslash}${backslash}` const escapedQuote = `${backslash}${quote}` return value - .replaceAll(backslash, escapedBackslash) - .replaceAll(quote, escapedQuote) + .replaceAll(backslash, () => escapedBackslash) + .replaceAll(quote, () => escapedQuote) } export const renderClaudeGlobalPromptSetup = (config: TemplateConfig): string => entrypointClaudeGlobalPromptTemplate - .replaceAll("__TARGET_DIR__", config.targetDir) - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef)) - .replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl)) + .replaceAll("__TARGET_DIR__", () => config.targetDir) + .replaceAll("__SSH_USER__", () => config.sshUser) + .replaceAll("__REPO_REF_DEFAULT__", () => escapeForDoubleQuotes(config.repoRef)) + .replaceAll("__REPO_URL_DEFAULT__", () => escapeForDoubleQuotes(config.repoUrl)) export const renderClaudeWrapperSetup = (): string => String.raw`CLAUDE_WRAPPER_BIN="/usr/local/bin/claude" diff --git a/packages/container/src/core/templates-entrypoint/claude.ts b/packages/container/src/core/templates-entrypoint/claude.ts index d2705202..e86f0167 100644 --- a/packages/container/src/core/templates-entrypoint/claude.ts +++ b/packages/container/src/core/templates-entrypoint/claude.ts @@ -96,10 +96,13 @@ const renderClaudeAuthConfig = (config: TemplateConfig): string => claudeAuthConfigTemplate .replaceAll( "__CLAUDE_AUTH_ROOT__", - claudeAuthRootContainerPath(config.sshUser) + () => claudeAuthRootContainerPath(config.sshUser) + ) + .replaceAll("__CLAUDE_HOME_DIR__", () => `/home/${config.sshUser}/.claude`) + .replaceAll( + "__CLAUDE_HOME_JSON__", + () => `/home/${config.sshUser}/.claude.json` ) - .replaceAll("__CLAUDE_HOME_DIR__", `/home/${config.sshUser}/.claude`) - .replaceAll("__CLAUDE_HOME_JSON__", `/home/${config.sshUser}/.claude.json`) const renderClaudeCliInstall = (): string => `# Claude Code: ensure CLI command exists (non-blocking startup self-heal) diff --git a/packages/container/src/core/templates-entrypoint/codex-resume-hint.ts b/packages/container/src/core/templates-entrypoint/codex-resume-hint.ts index 2bc05466..08586133 100644 --- a/packages/container/src/core/templates-entrypoint/codex-resume-hint.ts +++ b/packages/container/src/core/templates-entrypoint/codex-resume-hint.ts @@ -3,10 +3,10 @@ import type { TemplateConfig } from "../domain.js" const escapeForDoubleQuotes = (value: string): string => { const backslash = String.fromCodePoint(92) return value - .replaceAll(backslash, `${backslash}${backslash}`) + .replaceAll(backslash, () => `${backslash}${backslash}`) .replaceAll( String.fromCodePoint(34), - `${backslash}${String.fromCodePoint(34)}` + () => `${backslash}${String.fromCodePoint(34)}` ) } @@ -99,5 +99,5 @@ export const renderEntrypointCodexResumeHint = ( config: TemplateConfig ): string => entrypointCodexResumeHintTemplate - .replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef)) - .replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl)) + .replaceAll("__REPO_REF_DEFAULT__", () => escapeForDoubleQuotes(config.repoRef)) + .replaceAll("__REPO_URL_DEFAULT__", () => escapeForDoubleQuotes(config.repoUrl)) diff --git a/packages/container/src/core/templates-entrypoint/codex.ts b/packages/container/src/core/templates-entrypoint/codex.ts index 4d4095e7..a43bd715 100644 --- a/packages/container/src/core/templates-entrypoint/codex.ts +++ b/packages/container/src/core/templates-entrypoint/codex.ts @@ -129,8 +129,8 @@ fi` export const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => entrypointMcpPlaywrightTemplate - .replaceAll("__CODEX_HOME__", config.codexHome) - .replaceAll("__SERVICE_NAME__", config.serviceName) + .replaceAll("__CODEX_HOME__", () => config.codexHome) + .replaceAll("__SERVICE_NAME__", () => config.serviceName) const entrypointProjectCodexSkillsSyncTemplate = String .raw`# Mirror project-owned Codex skill trees into CODEX_HOME without overwriting global skills. @@ -196,5 +196,5 @@ export const renderEntrypointProjectCodexSkillsSync = ( ): string => entrypointProjectCodexSkillsSyncTemplate.replaceAll( "__CODEX_HOME__", - config.codexHome + () => config.codexHome ) diff --git a/packages/container/src/core/templates-entrypoint/gemini.ts b/packages/container/src/core/templates-entrypoint/gemini.ts index 4cd786e5..e8d35570 100644 --- a/packages/container/src/core/templates-entrypoint/gemini.ts +++ b/packages/container/src/core/templates-entrypoint/gemini.ts @@ -111,9 +111,9 @@ const renderGeminiAuthConfig = (config: TemplateConfig): string => geminiAuthConfigTemplate .replaceAll( "__GEMINI_AUTH_ROOT__", - geminiAuthRootContainerPath(config.sshUser) + () => geminiAuthRootContainerPath(config.sshUser) ) - .replaceAll("__GEMINI_HOME_DIR__", config.geminiHome) + .replaceAll("__GEMINI_HOME_DIR__", () => config.geminiHome) const geminiSettingsJsonTemplate = `{ "model": { @@ -339,6 +339,6 @@ export const renderEntrypointGeminiConfig = (config: TemplateConfig): string => renderGeminiSudoConfig(config), renderGeminiProfileSetup(config), entrypointGeminiNoticeTemplate - .replaceAll("__GEMINI_HOME__", config.geminiHome) - .replaceAll("__TARGET_DIR__", config.targetDir) + .replaceAll("__GEMINI_HOME__", () => config.geminiHome) + .replaceAll("__TARGET_DIR__", () => config.targetDir) ].join("\n\n") diff --git a/packages/container/src/core/templates-entrypoint/grok.ts b/packages/container/src/core/templates-entrypoint/grok.ts index 383585bf..86e80bcf 100644 --- a/packages/container/src/core/templates-entrypoint/grok.ts +++ b/packages/container/src/core/templates-entrypoint/grok.ts @@ -145,8 +145,8 @@ docker_git_refresh_grok_env` const renderGrokAuthConfig = (config: TemplateConfig): string => grokAuthConfigTemplate - .replaceAll("__GROK_AUTH_ROOT__", grokAuthRootContainerPath(config.sshUser)) - .replaceAll("__GROK_HOME_DIR__", config.grokHome) + .replaceAll("__GROK_AUTH_ROOT__", () => grokAuthRootContainerPath(config.sshUser)) + .replaceAll("__GROK_HOME_DIR__", () => config.grokHome) const grokSettingsJsonTemplate = `{ "sandboxMode": "off", @@ -323,9 +323,9 @@ chown "$GROK_NOTICE_OWNER_UID:$GROK_NOTICE_OWNER_GID" "$GROK_MD_PATH" || true` const renderEntrypointGrokNotice = (config: TemplateConfig): string => entrypointGrokNoticeTemplate - .replaceAll("__GROK_HOME__", config.grokHome) - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll("__TARGET_DIR__", config.targetDir) + .replaceAll("__GROK_HOME__", () => config.grokHome) + .replaceAll("__SSH_USER__", () => config.sshUser) + .replaceAll("__TARGET_DIR__", () => config.targetDir) /** * Renders the Grok CLI entrypoint bootstrap for a generated project container. diff --git a/packages/container/src/core/templates-entrypoint/nested-docker-git.ts b/packages/container/src/core/templates-entrypoint/nested-docker-git.ts index fcb253ca..3d44babb 100644 --- a/packages/container/src/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/container/src/core/templates-entrypoint/nested-docker-git.ts @@ -231,20 +231,23 @@ export const renderEntrypointDockerGitBootstrap = ( config: TemplateConfig ): string => entrypointDockerGitBootstrapTemplate - .replaceAll("__SSH_USER__", config.sshUser) + .replaceAll("__SSH_USER__", () => config.sshUser) .replaceAll( "__AUTHORIZED_KEYS_BASENAME__", - config.authorizedKeysPath.replaceAll("\\", "/").split("/").at(-1) ?? - "authorized_keys" + () => + config.authorizedKeysPath.replaceAll("\\", "/").split("/").at(-1) ?? + "authorized_keys" ) .replaceAll( "__ENV_GLOBAL_BASENAME__", - config.envGlobalPath.replaceAll("\\", "/").split("/").at(-1) ?? - "global.env" + () => + config.envGlobalPath.replaceAll("\\", "/").split("/").at(-1) ?? + "global.env" ) .replaceAll( "__ENV_PROJECT_BASENAME__", - config.envProjectPath.replaceAll("\\", "/").split("/").at(-1) ?? - "project.env" + () => + config.envProjectPath.replaceAll("\\", "/").split("/").at(-1) ?? + "project.env" ) - .replaceAll("__CODEX_HOME__", config.codexHome) + .replaceAll("__CODEX_HOME__", () => config.codexHome) diff --git a/packages/container/src/core/templates-entrypoint/opencode.ts b/packages/container/src/core/templates-entrypoint/opencode.ts index b71c85d3..3d24dc0e 100644 --- a/packages/container/src/core/templates-entrypoint/opencode.ts +++ b/packages/container/src/core/templates-entrypoint/opencode.ts @@ -207,5 +207,5 @@ export const renderEntrypointOpenCodeConfig = ( config: TemplateConfig ): string => entrypointOpenCodeTemplate - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll("__CODEX_HOME__", config.codexHome) + .replaceAll("__SSH_USER__", () => config.sshUser) + .replaceAll("__CODEX_HOME__", () => config.codexHome) diff --git a/packages/container/src/core/templates-entrypoint/rtk.ts b/packages/container/src/core/templates-entrypoint/rtk.ts index 5199f69a..dff1e6ff 100644 --- a/packages/container/src/core/templates-entrypoint/rtk.ts +++ b/packages/container/src/core/templates-entrypoint/rtk.ts @@ -41,5 +41,5 @@ docker_git_rtk_init_as_user "codex" "HOME=/home/__SSH_USER__ CODEX_HOME='__CODEX docker_git_rtk_init_as_user "claude" "HOME=/home/__SSH_USER__ RTK_CLAUDE_DIR='$CLAUDE_CONFIG_DIR' rtk init -g --auto-patch" docker_git_rtk_init_as_user "gemini" "HOME=/home/__SSH_USER__ rtk init -g --gemini --auto-patch" docker_git_rtk_init_as_user "opencode" "HOME=/home/__SSH_USER__ rtk init -g --opencode"` - .replaceAll("__SSH_USER__", config.sshUser) - .replaceAll("__CODEX_HOME__", config.codexHome) + .replaceAll("__SSH_USER__", () => config.sshUser) + .replaceAll("__CODEX_HOME__", () => config.codexHome) diff --git a/packages/container/src/core/templates/docker-compose.ts b/packages/container/src/core/templates/docker-compose.ts index 19a580f7..a0b25547 100644 --- a/packages/container/src/core/templates/docker-compose.ts +++ b/packages/container/src/core/templates/docker-compose.ts @@ -70,8 +70,8 @@ const renderGitTokenLabelEnv = (gitTokenLabel: string): string => ? ` GITHUB_AUTH_LABEL: "${gitTokenLabel}"\n GIT_AUTH_LABEL: "${gitTokenLabel}"\n` : "" -const renderGithubAuthSkipEnv = (skipGithubAuth: boolean): string => - skipGithubAuth ? ` GITHUB_AUTH_SKIP: "1"\n` : "" +const renderGithubAuthSkipEnv = (shouldSkipGithubAuth: boolean): string => + shouldSkipGithubAuth ? ` GITHUB_AUTH_SKIP: "1"\n` : "" const renderCodexAuthLabelEnv = (codexAuthLabel: string): string => codexAuthLabel.length > 0 @@ -113,9 +113,9 @@ const renderBootstrapMounts = (): string => ` - ${bootstrapVolumeKey}:/opt/ const renderYamlSingleQuoted = (value: string): string => `'${value.replaceAll("'", "''")}'` const renderOptionalDockerSocketMount = ( - enableLocalDockerSocket: boolean + shouldEnableLocalDockerSocket: boolean ): string => - enableLocalDockerSocket + shouldEnableLocalDockerSocket ? ` - /var/run/docker.sock:/var/run/docker.sock` : "" diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json index 12cc86ef..3410227f 100644 --- a/packages/docker-git-session-sync/package.json +++ b/packages/docker-git-session-sync/package.json @@ -42,6 +42,6 @@ "@vitejs/plugin-react": "^6.0.2", "typescript": "^6.0.3", "vite": "^8.0.16", - "vitest": "^4.1.8" + "vitest": "^4.1.9" } } diff --git a/packages/lib/package.json b/packages/lib/package.json index 3b35981d..cda1bef3 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -67,10 +67,10 @@ "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", "@ton-ai-core/vibecode-linter": "^1.0.11", "@types/node": "^25.9.3", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", - "typescript-eslint": "^8.61.0", - "@vitest/coverage-v8": "^4.1.8", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", + "typescript-eslint": "^8.61.1", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^10.5.0", "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-codegen": "0.34.1", @@ -78,14 +78,14 @@ "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", - "eslint-plugin-unicorn": "^65.0.1", + "eslint-plugin-unicorn": "^67.0.0", "fast-check": "^4.8.0", "@vitest/eslint-plugin": "^1.6.20", "globals": "^17.6.0", "jscpd": "^5.0.9", "typescript": "^6.0.3", "vite": "^8.0.16", - "vitest": "^4.1.8" + "vitest": "^4.1.9" }, "types": "dist/index.d.ts", "exports": { diff --git a/packages/lib/src/core/command-builders-shared.ts b/packages/lib/src/core/command-builders-shared.ts index 955caa63..9d399321 100644 --- a/packages/lib/src/core/command-builders-shared.ts +++ b/packages/lib/src/core/command-builders-shared.ts @@ -12,7 +12,7 @@ import { const parsePort = (value: string): Either.Either => { const parsed = Number(value) - if (!Number.isInteger(parsed)) { + if (!Number.isSafeInteger(parsed)) { return Either.left({ _tag: "InvalidOption", option: "--ssh-port", @@ -34,14 +34,14 @@ const isAsciiLetterCode = (code: number): boolean => (code >= 65 && code <= 90) const isPathSeparator = (value: string | undefined): boolean => value === "/" || value === "\\" const rootPathLength = (value: string): number => { - if (isPathSeparator(value[0])) { + if (isPathSeparator(value.at(0))) { return 1 } if ( value.length >= 3 && isAsciiLetterCode(value.codePointAt(0) ?? 0) && - value[1] === ":" && - isPathSeparator(value[2]) + value.at(1) === ":" && + isPathSeparator(value.at(2)) ) { return 3 } diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 6934ac9c..a91974a5 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -48,7 +48,7 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either codePoint !== undefined && codePoint >= 0x40 && codePoint <= 0x7E diff --git a/packages/lib/src/shell/config.ts b/packages/lib/src/shell/config.ts index bf7d2127..68e997b8 100644 --- a/packages/lib/src/shell/config.ts +++ b/packages/lib/src/shell/config.ts @@ -153,8 +153,8 @@ export const readProjectConfig = ( const { fs, path, resolved } = yield* _(resolveBaseDir(baseDir)) const configPath = path.join(resolved, "docker-git.json") - const exists = yield* _(fs.exists(configPath)) - if (!exists) { + const isExists = yield* _(fs.exists(configPath)) + if (!isExists) { return yield* _(Effect.fail(new ConfigNotFoundError({ path: configPath }))) } diff --git a/packages/lib/src/shell/docker-auth.ts b/packages/lib/src/shell/docker-auth.ts index 400ba3e2..e939accd 100644 --- a/packages/lib/src/shell/docker-auth.ts +++ b/packages/lib/src/shell/docker-auth.ts @@ -55,14 +55,14 @@ const normalizeDockerPathForCompare = (value: string): string => { const originalPrefixLength = (prefix: string): number => trimDockerPathTrailingSlash(prefix.trim()).length -const pathStartsWith = (candidate: string, prefix: string): boolean => { +const isPathUnderPrefix = (candidate: string, prefix: string): boolean => { const normalizedCandidate = normalizeDockerPathForCompare(candidate) const normalizedPrefix = normalizeDockerPathForCompare(prefix) return normalizedCandidate === normalizedPrefix || normalizedCandidate.startsWith(`${normalizedPrefix}/`) } const translatePathPrefix = (candidate: string, sourcePrefix: string, targetPrefix: string): string | null => - pathStartsWith(candidate, sourcePrefix) + isPathUnderPrefix(candidate, sourcePrefix) ? `${targetPrefix}${candidate.slice(originalPrefixLength(sourcePrefix))}` : null @@ -125,7 +125,7 @@ export const remapDockerBindHostPathFromMounts = ( ): string => { let match: DockerMountBinding | null = null for (const mount of mounts) { - if (!pathStartsWith(hostPath, mount.destination)) { + if (!isPathUnderPrefix(hostPath, mount.destination)) { continue } if (match === null || mount.destination.length > match.destination.length) { diff --git a/packages/lib/src/shell/docker-compose.ts b/packages/lib/src/shell/docker-compose.ts index 34b221f3..fe2ea05b 100644 --- a/packages/lib/src/shell/docker-compose.ts +++ b/packages/lib/src/shell/docker-compose.ts @@ -14,7 +14,7 @@ const buildComposeCommand = ( env: Readonly> ) => ({ ...composeSpec(cwd, args), - ...(Object.keys(env).length > 0 ? { env } : {}) + ...((Object.keys(env).length > 0) && { env }) }) const runCompose = ( @@ -28,7 +28,7 @@ const runCompose = ( runCommandWithStreamingOutput( buildComposeCommand(cwd, args, env), okExitCodes, - (exitCode, output) => new DockerCommandError({ exitCode, ...(output.length > 0 ? { details: output } : {}) }) + (exitCode, output) => new DockerCommandError({ exitCode, ...((output.length > 0) && { details: output }) }) ) ) }) @@ -101,11 +101,13 @@ export const runDockerComposeUp = ( options: { readonly buildMode?: DockerComposeUpBuildMode } = {} -): Effect.Effect => - retryDockerComposeUp( +): Effect.Effect => { + const successExitCode = Number(ExitCode(0)) + return retryDockerComposeUp( cwd, - runCompose(cwd, dockerComposeUpArgs(options.buildMode ?? "build"), [Number(ExitCode(0))]) + runCompose(cwd, dockerComposeUpArgs(options.buildMode ?? "build"), [successExitCode]) ) +} export const dockerComposeUpRecreateArgs: ReadonlyArray = [ "up", @@ -116,8 +118,10 @@ export const dockerComposeUpRecreateArgs: ReadonlyArray = [ export const runDockerComposeUpRecreate = ( cwd: string -): Effect.Effect => - retryDockerComposeUp(cwd, runCompose(cwd, dockerComposeUpRecreateArgs, [Number(ExitCode(0))])) +): Effect.Effect => { + const successExitCode = Number(ExitCode(0)) + return retryDockerComposeUp(cwd, runCompose(cwd, dockerComposeUpRecreateArgs, [successExitCode])) +} export const runDockerComposeDown = ( cwd: string diff --git a/packages/lib/src/shell/docker-daemon-access.ts b/packages/lib/src/shell/docker-daemon-access.ts index 107b226f..a3449eca 100644 --- a/packages/lib/src/shell/docker-daemon-access.ts +++ b/packages/lib/src/shell/docker-daemon-access.ts @@ -57,18 +57,15 @@ const runDockerInfoCommand = ( Effect.scoped( Effect.gen(function*(_) { const executor = yield* _(CommandExecutor.CommandExecutor) - const process = yield* _( - executor.start( - pipe( - Command.make("docker", "info"), - Command.workingDirectory(cwd), - env ? Command.env(env) : (value) => value, - Command.stdin("pipe"), - Command.stdout("pipe"), - Command.stderr("pipe") - ) - ) + const dockerInfoCommand = pipe( + Command.make("docker", "info"), + Command.workingDirectory(cwd), + env ? Command.env(env) : (value) => value, + Command.stdin("pipe"), + Command.stdout("pipe"), + Command.stderr("pipe") ) + const process = yield* _(executor.start(dockerInfoCommand)) const stderrBytes = yield* _( pipe(process.stderr, Stream.runCollect, Effect.map((chunks) => collectUint8Array(chunks))) @@ -142,7 +139,7 @@ export const ensureDockerDaemonAccess = ( return } - failureDetails = `${failureDetails}\n${formatDockerFallbackFailure(fallbackHost, fallbackResult.details)}` + failureDetails += `\n${formatDockerFallbackFailure(fallbackHost, fallbackResult.details)}` } return yield* _( diff --git a/packages/lib/src/shell/docker-network.ts b/packages/lib/src/shell/docker-network.ts index bf370004..1d2687e4 100644 --- a/packages/lib/src/shell/docker-network.ts +++ b/packages/lib/src/shell/docker-network.ts @@ -74,7 +74,7 @@ export const runDockerNetworkContainerCount = ( ): Effect.Effect => runDockerNetworkCapture(cwd, ["network", "inspect", "-f", "{{len .Containers}}", networkName]).pipe( Effect.map((output) => { - const parsed = Number.parseInt(output.trim(), 10) + const parsed = Number(output.trim()) return Number.isNaN(parsed) ? 0 : parsed }) ) diff --git a/packages/lib/src/shell/docker-published-ports.ts b/packages/lib/src/shell/docker-published-ports.ts index c318090f..06d932b1 100644 --- a/packages/lib/src/shell/docker-published-ports.ts +++ b/packages/lib/src/shell/docker-published-ports.ts @@ -15,8 +15,8 @@ const parsePublishedHostPortsFromLine = (line: string): ReadonlyArray => if (rawPort === undefined) { continue } - const value = Number.parseInt(rawPort, 10) - if (Number.isInteger(value) && value > 0 && value <= 65_535) { + const value = Number(rawPort) + if (Number.isSafeInteger(value) && value > 0 && value <= 65_535) { parsed.push(value) } } @@ -65,16 +65,18 @@ export const parseDockerPublishedHostPorts = (output: string): ReadonlyArray, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => - pipe( +): Effect.Effect, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => { + const successExitCode = Number(ExitCode(0)) + return pipe( runCommandCapture( { cwd, command: "docker", args: ["ps", "--format", "{{.Ports}}"] }, - [Number(ExitCode(0))], + [successExitCode], (exitCode) => new CommandFailedError({ command: "docker ps", exitCode }) ), Effect.map((output) => parseDockerPublishedHostPorts(output)) ) +} diff --git a/packages/lib/src/shell/docker-runtime.ts b/packages/lib/src/shell/docker-runtime.ts index b644ebca..cfe7b25b 100644 --- a/packages/lib/src/shell/docker-runtime.ts +++ b/packages/lib/src/shell/docker-runtime.ts @@ -80,15 +80,16 @@ export const runDockerInspectContainerBridgeIp = createDockerInspectReader( export const runDockerPsNames = ( cwd: string -): Effect.Effect, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => - pipe( +): Effect.Effect, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => { + const successExitCode = Number(ExitCode(0)) + return pipe( runCommandCapture( { cwd, command: "docker", args: ["ps", "--format", "{{.Names}}"] }, - [Number(ExitCode(0))], + [successExitCode], (exitCode) => new CommandFailedError({ command: "docker ps", exitCode }) ), Effect.map((output) => @@ -98,3 +99,4 @@ export const runDockerPsNames = ( .filter((line) => line.length > 0) ) ) +} diff --git a/packages/lib/src/shell/docker-volume.ts b/packages/lib/src/shell/docker-volume.ts index c81d0c07..c5204d99 100644 --- a/packages/lib/src/shell/docker-volume.ts +++ b/packages/lib/src/shell/docker-volume.ts @@ -8,7 +8,7 @@ import { DockerCommandError } from "./errors.js" const escapedSingleQuote = String.raw`'\''` -const shellEscape = (value: string): string => `'${value.replaceAll("'", escapedSingleQuote)}'` +const shellEscape = (value: string): string => `'${value.replaceAll("'", () => escapedSingleQuote)}'` export const runDockerVolumeCreate = ( cwd: string, diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index ec4a24b9..8036ef28 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -31,13 +31,13 @@ const loadHostResources = (): Effect.Effect< { readonly cpuCount: number; readonly totalMemoryBytes: number } > => Effect.tryPromise({ - try: () => - import("node:os").then((os) => ({ - cpuCount: os.availableParallelism(), - totalMemoryBytes: os.totalmem() - })), + try: () => import("node:os"), catch: (error) => new Error(String(error)) }).pipe( + Effect.map((os) => ({ + cpuCount: os.availableParallelism(), + totalMemoryBytes: os.totalmem() + })), Effect.match({ onFailure: () => fallbackHostResources, onSuccess: (value) => value @@ -92,8 +92,8 @@ const collectExistingFilePaths = ( continue } const filePath = resolveSpecPath(path, baseDir, spec) - const exists = yield* _(fs.exists(filePath)) - if (exists) { + const isExists = yield* _(fs.exists(filePath)) + if (isExists) { existingPaths.push(filePath) } } @@ -102,9 +102,9 @@ const collectExistingFilePaths = ( const failOnExistingFiles = ( existingFilePaths: ReadonlyArray, - skipExistingFiles: boolean + shouldSkipExistingFiles: boolean ): Effect.Effect => { - if (skipExistingFiles || existingFilePaths.length === 0) { + if (shouldSkipExistingFiles || existingFilePaths.length === 0) { return Effect.void } const firstPath = existingFilePaths[0] @@ -131,8 +131,8 @@ const provisionDockerGitScripts = ( const sourceScriptsDir = path.join(workspaceRoot, "scripts") const targetScriptsDir = path.join(baseDir, "scripts") - const sourceExists = yield* _(fs.exists(sourceScriptsDir)) - if (!sourceExists) { + const isSourceExists = yield* _(fs.exists(sourceScriptsDir)) + if (!isSourceExists) { return } @@ -141,8 +141,8 @@ const provisionDockerGitScripts = ( for (const scriptName of dockerGitScriptNames) { const sourcePath = path.join(sourceScriptsDir, scriptName) const targetPath = path.join(targetScriptsDir, scriptName) - const exists = yield* _(fs.exists(sourcePath)) - if (exists) { + const isExists = yield* _(fs.exists(sourcePath)) + if (isExists) { const contents = yield* _(fs.readFileString(sourcePath)) yield* _(fs.writeFileString(targetPath, contents)) } @@ -199,8 +199,8 @@ const provisionDockerGitSessionSyncTool = ( const targetPath = path.join(baseDir, sessionSyncToolRelativePath) const candidates = yield* _(sessionSyncToolCandidates(path, workspaceRoot)) for (const sourcePath of candidates) { - const exists = yield* _(fs.exists(sourcePath)) - if (exists) { + const isExists = yield* _(fs.exists(sourcePath)) + if (isExists) { const contents = yield* _(fs.readFileString(sourcePath)) yield* _(ensureParentDir(path, fs, targetPath)) yield* _(fs.writeFileString(targetPath, contents, { mode: 0o755 })) @@ -227,8 +227,8 @@ const provisionDockerGitSessionSyncTool = ( export const writeProjectFiles = ( outDir: string, config: TemplateConfig, - force: boolean, - skipExistingFiles: boolean = false + shouldForce: boolean, + shouldSkipExistingFiles: boolean = false ): Effect.Effect< ReadonlyArray, FileExistsError | PlatformError, @@ -249,13 +249,13 @@ export const writeProjectFiles = ( compose: { enableLocalDockerSocket: shouldMountLocalDockerSocket() } }) const created: Array = [] - const existingFilePaths = force ? [] : yield* _(collectExistingFilePaths(fs, path, baseDir, specs)) + const existingFilePaths = shouldForce ? [] : yield* _(collectExistingFilePaths(fs, path, baseDir, specs)) const existingSet = new Set(existingFilePaths) - yield* _(failOnExistingFiles(existingFilePaths, skipExistingFiles)) + yield* _(failOnExistingFiles(existingFilePaths, shouldSkipExistingFiles)) for (const spec of specs) { - if (!force && skipExistingFiles && isFileSpec(spec)) { + if (!shouldForce && shouldSkipExistingFiles && isFileSpec(spec)) { const filePath = resolveSpecPath(path, baseDir, spec) if (existingSet.has(filePath)) { continue diff --git a/packages/lib/src/shell/ports.ts b/packages/lib/src/shell/ports.ts index 434fd8d7..544508ed 100644 --- a/packages/lib/src/shell/ports.ts +++ b/packages/lib/src/shell/ports.ts @@ -53,8 +53,8 @@ export const findAvailablePort = ( const max = Math.max(1, attempts) for (let offset = 0; offset < max; offset += 1) { const candidate = preferred + offset - const available = yield* _(isPortAvailable(candidate, host)) - if (available) { + const isAvailable = yield* _(isPortAvailable(candidate, host)) + if (isAvailable) { return candidate } } diff --git a/packages/lib/src/usecases/actions/create-project-conflicts.ts b/packages/lib/src/usecases/actions/create-project-conflicts.ts index 3d3c44c3..21365037 100644 --- a/packages/lib/src/usecases/actions/create-project-conflicts.ts +++ b/packages/lib/src/usecases/actions/create-project-conflicts.ts @@ -160,7 +160,7 @@ const deleteConflictingProjects = ( export const deleteConflictingProjectsIfNeeded = ( resolvedOutDir: string, config: DockerIdentityOwner, - force: boolean + shouldForce: boolean ): Effect.Effect => Effect.gen(function*(_) { const state = yield* _(scanConflicts(resolvedOutDir, config)) @@ -168,7 +168,7 @@ export const deleteConflictingProjectsIfNeeded = ( return } - if (!force) { + if (!shouldForce) { return yield* _( Effect.fail(new DockerIdentityConflictError({ projectDir: resolvedOutDir, conflicts: state.conflicts })) ) diff --git a/packages/lib/src/usecases/actions/create-project-open-ssh.ts b/packages/lib/src/usecases/actions/create-project-open-ssh.ts index b3d5200a..3329ec59 100644 --- a/packages/lib/src/usecases/actions/create-project-open-ssh.ts +++ b/packages/lib/src/usecases/actions/create-project-open-ssh.ts @@ -68,14 +68,12 @@ const openSshBestEffort = ( const remoteCommandLabel = remoteCommand === undefined ? "" : ` (${remoteCommand})` yield* _(Effect.log(`Opening SSH: ${sshCommand}${remoteCommandLabel}`)) + const sshArgs = buildSshArgs(template, sshKey, remoteCommand, ipAddress) + const sshCommandSpec = { cwd: process.cwd(), command: "ssh", args: sshArgs } yield* _( withPreservedTerminalState( runCommandWithExitCodes( - { - cwd: process.cwd(), - command: "ssh", - args: buildSshArgs(template, sshKey, remoteCommand, ipAddress) - }, + sshCommandSpec, [0, 130], (exitCode) => new CommandFailedError({ command: "ssh", exitCode }) ) @@ -91,21 +89,21 @@ const openSshBestEffort = ( const resolveInteractiveRemoteCommand = ( projectConfig: CreateCommand["config"], - interactiveAgent: boolean + isInteractiveAgent: boolean ): string | undefined => - interactiveAgent && projectConfig.agentMode !== undefined + isInteractiveAgent && projectConfig.agentMode !== undefined ? `cd '${projectConfig.targetDir}' && ${projectConfig.agentMode}` : undefined export const maybeOpenSsh = ( command: CreateCommand, hasAgent: boolean, - waitForAgent: boolean, + shouldWaitForAgent: boolean, projectConfig: CreateCommand["config"] ): Effect.Effect => Effect.gen(function*(_) { - const interactiveAgent = hasAgent && !waitForAgent - if (!command.openSsh || (hasAgent && !interactiveAgent)) { + const isInteractiveAgent = hasAgent && !shouldWaitForAgent + if (!command.openSsh || (hasAgent && !isInteractiveAgent)) { return } @@ -119,6 +117,6 @@ export const maybeOpenSsh = ( return } - const remoteCommand = resolveInteractiveRemoteCommand(projectConfig, interactiveAgent) + const remoteCommand = resolveInteractiveRemoteCommand(projectConfig, isInteractiveAgent) yield* _(openSshBestEffort(projectConfig, remoteCommand)) }).pipe(Effect.asVoid) diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 48af5359..13ce76bf 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -123,11 +123,11 @@ const resolveFinalAgentConfig = ( }) const maybeCleanupAfterAgent = ( - waitForAgent: boolean, + shouldWaitForAgent: boolean, resolvedOutDir: string ): Effect.Effect => Effect.gen(function*(_) { - if (!waitForAgent) { + if (!shouldWaitForAgent) { return } yield* _(Effect.log("Agent finished. Cleaning up container...")) @@ -172,14 +172,14 @@ export const runPreparedProject = ( ): Effect.Effect => Effect.gen(function*(_) { const hasAgent = prepared.finalConfig.agentMode !== undefined - const waitForAgent = hasAgent && (prepared.finalConfig.agentAuto ?? false) + const shouldWaitForAgent = hasAgent && (prepared.finalConfig.agentAuto ?? false) yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(prepared.projectConfig.repoUrl)}`)) yield* _( runDockerUpIfNeeded(prepared.resolvedOutDir, prepared.projectConfig, { runUp: command.runUp, waitForClone: command.waitForClone, - waitForAgent, + waitForAgent: shouldWaitForAgent, force: command.force, forceEnv: command.forceEnv }) @@ -188,8 +188,8 @@ export const runPreparedProject = ( yield* _(logDockerAccessInfo(prepared.resolvedOutDir, prepared.projectConfig)) } - yield* _(maybeCleanupAfterAgent(waitForAgent, prepared.resolvedOutDir)) - yield* _(maybeOpenSsh(command, hasAgent, waitForAgent, prepared.projectConfig)) + yield* _(maybeCleanupAfterAgent(shouldWaitForAgent, prepared.resolvedOutDir)) + yield* _(maybeOpenSsh(command, hasAgent, shouldWaitForAgent, prepared.projectConfig)) }).pipe(Effect.asVoid) const runCreateProject = ( diff --git a/packages/lib/src/usecases/actions/docker-up.ts b/packages/lib/src/usecases/actions/docker-up.ts index b9433c68..5601f77d 100644 --- a/packages/lib/src/usecases/actions/docker-up.ts +++ b/packages/lib/src/usecases/actions/docker-up.ts @@ -108,15 +108,12 @@ const waitForCloneCompletion = ( Effect.fork ) ) + const clonePollSchedule = Schedule.addDelay( + Schedule.recurUntil((state) => state !== "pending"), + () => clonePollInterval + ) const result = yield* _( - checkCloneState(cwd, config.containerName).pipe( - Effect.repeat( - Schedule.addDelay( - Schedule.recurUntil((state) => state !== "pending"), - () => clonePollInterval - ) - ) - ) + checkCloneState(cwd, config.containerName).pipe(Effect.repeat(clonePollSchedule)) ) yield* _(Fiber.interrupt(logsFiber)) if (result === "failed") { @@ -161,15 +158,12 @@ const waitForAgentCompletion = ( Effect.fork ) ) + const agentPollSchedule = Schedule.addDelay( + Schedule.recurUntil((state) => state !== "pending"), + () => agentPollInterval + ) const result = yield* _( - checkAgentState(cwd, config.containerName).pipe( - Effect.repeat( - Schedule.addDelay( - Schedule.recurUntil((state) => state !== "pending"), - () => agentPollInterval - ) - ) - ) + checkAgentState(cwd, config.containerName).pipe(Effect.repeat(agentPollSchedule)) ) yield* _(Fiber.interrupt(logsFiber)) if (result === "failed") { @@ -187,13 +181,13 @@ const waitForAgentCompletion = ( const runDockerComposeUpByMode = ( resolvedOutDir: string, projectConfig: CreateCommand["config"], - force: boolean, - forceEnv: boolean + shouldForce: boolean, + shouldForceEnv: boolean ): Effect.Effect => Effect.gen(function*(_) { yield* _(ensureComposeNetworkReady(resolvedOutDir, projectConfig)) - if (force) { + if (shouldForce) { yield* _(Effect.log("Force enabled: removing stale containers and wiping docker compose volumes...")) yield* _(runDockerComposeDownVolumes(resolvedOutDir)) yield* _(ensureSharedCodexVolumeReady(resolvedOutDir, projectConfig)) @@ -206,7 +200,7 @@ const runDockerComposeUpByMode = ( return } yield* _(ensureSharedCodexVolumeReady(resolvedOutDir, projectConfig)) - if (forceEnv) { + if (shouldForceEnv) { yield* _(Effect.log("Force env enabled: resetting env defaults and recreating containers (volumes preserved)...")) yield* _(runDockerComposeUpRecreate(resolvedOutDir)) return diff --git a/packages/lib/src/usecases/actions/paths.ts b/packages/lib/src/usecases/actions/paths.ts index 41b8f368..3f09b02d 100644 --- a/packages/lib/src/usecases/actions/paths.ts +++ b/packages/lib/src/usecases/actions/paths.ts @@ -64,9 +64,9 @@ export const buildProjectConfigs = ( dockerGitPath: "./.docker-git", authorizedKeysPath: "./authorized_keys", envGlobalPath: "./.orch/env/global.env", - envProjectPath: path.isAbsolute(resolvedConfig.envProjectPath) - ? relativeFromOutDir(resolvedConfig.envProjectPath) - : toPosixPath(resolvedConfig.envProjectPath), + envProjectPath: (path.isAbsolute(resolvedConfig.envProjectPath) + ? relativeFromOutDir + : toPosixPath)(resolvedConfig.envProjectPath), // Project-local Codex state (sessions/logs/etc) is kept under .orch. codexAuthPath: "./.orch/auth/codex", // Keep the global auth source path so runtime can seed the shared Docker volume when containers start. diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index a720975e..4a03d4c8 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -31,8 +31,8 @@ const ensureFileReady = ( onDirectoryMessage: (resolvedPath: string, backupPath: string) => string ): Effect.Effect => Effect.gen(function*(_) { - const exists = yield* _(fs.exists(resolved)) - if (!exists) { + const isExists = yield* _(fs.exists(resolved)) + if (!isExists) { return "missing" } @@ -96,8 +96,8 @@ const resolveManagedAuthorizedKeysSource = ( ): Effect.Effect => Effect.gen(function*(_) { const preferred = resolvePathFromBase(path, baseDir, preferredSource) - const preferredExists = yield* _(fs.exists(preferred)) - if (preferredExists && preferred !== resolved) { + const isPreferredExists = yield* _(fs.exists(preferred)) + if (isPreferredExists && preferred !== resolved) { return preferred } @@ -173,7 +173,7 @@ const ensureAuthorizedKeys = ( baseDir: string, authorizedKeysPath: string, preferredSource: string, - overwriteExisting: boolean + shouldOverwriteExisting: boolean ): Effect.Effect => withFsPathContext(({ fs, path }) => Effect.gen(function*(_) { @@ -188,7 +188,7 @@ const ensureAuthorizedKeys = ( ) ) - if (state === "exists" && resolved !== managedDefaultAuthorizedKeys && !overwriteExisting) { + if (state === "exists" && resolved !== managedDefaultAuthorizedKeys && !shouldOverwriteExisting) { return } @@ -214,7 +214,7 @@ const ensureAuthorizedKeys = ( managedDefaultAuthorizedKeys, source, desiredContents, - overwriteExisting + overwriteExisting: shouldOverwriteExisting }) ) }) @@ -237,7 +237,7 @@ const ensureEnvFile = ( baseDir: string, envPath: string, defaultContents: string, - overwrite: boolean = false + shouldOverwrite: boolean = false ): Effect.Effect => withFsPathContext(({ fs, path }) => Effect.gen(function*(_) { @@ -249,7 +249,7 @@ const ensureEnvFile = ( (_resolvedPath, backupPath) => `Env file was a directory, moved to ${backupPath}.` ) ) - if (state === "exists" && !overwrite) { + if (state === "exists" && !shouldOverwrite) { return } @@ -272,9 +272,9 @@ export const prepareProjectFiles = ( ): Effect.Effect, PrepareProjectFilesError, FileSystem.FileSystem | Path.Path> => Effect.gen(function*(_) { const path = yield* _(Path.Path) - const rewriteManagedFiles = options.force || options.forceEnv - const envOnlyRefresh = options.forceEnv && !options.force - const createdFiles = yield* _(writeProjectFiles(resolvedOutDir, projectConfig, rewriteManagedFiles)) + const isRewriteManagedFiles = options.force || options.forceEnv + const isEnvOnlyRefresh = options.forceEnv && !options.force + const createdFiles = yield* _(writeProjectFiles(resolvedOutDir, projectConfig, isRewriteManagedFiles)) yield* _( ensureAuthorizedKeys( resolvedOutDir, @@ -284,7 +284,7 @@ export const prepareProjectFiles = ( ) ) yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envGlobalPath, defaultGlobalEnvContents)) - yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envProjectPath, defaultProjectEnvContents, envOnlyRefresh)) + yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envProjectPath, defaultProjectEnvContents, isEnvOnlyRefresh)) yield* _(ensureCodexConfigFile(baseDir, globalConfig.codexAuthPath)) const globalClaudeAuthPath = path.join(path.dirname(globalConfig.codexAuthPath), "claude") yield* _(ensureClaudeAuthSeedFromHome(baseDir, globalClaudeAuthPath)) diff --git a/packages/lib/src/usecases/agent-auto-select.ts b/packages/lib/src/usecases/agent-auto-select.ts index 53c951bb..0a3ebbb0 100644 --- a/packages/lib/src/usecases/agent-auto-select.ts +++ b/packages/lib/src/usecases/agent-auto-select.ts @@ -29,8 +29,8 @@ const isRegularFile = ( filePath: string ): Effect.Effect => Effect.gen(function*(_) { - const exists = yield* _(fs.exists(filePath)) - if (!exists) { + const isExists = yield* _(fs.exists(filePath)) + if (!isExists) { return false } const info = yield* _(fs.stat(filePath)) @@ -73,18 +73,18 @@ const hasClaudeAuth = ( ): Effect.Effect => Effect.gen(function*(_) { for (const accountPath of resolveClaudeAccountPath(rootPath, label)) { - const oauthToken = yield* _(hasNonEmptyFile(fs, `${accountPath}/.oauth-token`)) - if (oauthToken) { + const isOauthToken = yield* _(hasNonEmptyFile(fs, `${accountPath}/.oauth-token`)) + if (isOauthToken) { return true } - const credentials = yield* _(isRegularFile(fs, `${accountPath}/.credentials.json`)) - if (credentials) { + const isCredentials = yield* _(isRegularFile(fs, `${accountPath}/.credentials.json`)) + if (isCredentials) { return true } - const nestedCredentials = yield* _(isRegularFile(fs, `${accountPath}/.claude/.credentials.json`)) - if (nestedCredentials) { + const isNestedCredentials = yield* _(isRegularFile(fs, `${accountPath}/.claude/.credentials.json`)) + if (isNestedCredentials) { return true } } @@ -103,12 +103,12 @@ const resolveAvailableAgentAuth = ( > ): Effect.Effect => Effect.gen(function*(_) { - const claudeAvailable = yield* _( + const isClaudeAvailable = yield* _( hasClaudeAuth(fs, resolveClaudeRoot(config.codexSharedAuthPath), config.claudeAuthLabel) ) - const codexAvailable = yield* _(hasCodexAuth(fs, config.codexSharedAuthPath, config.codexAuthLabel)) - const grokAvailable = yield* _(hasGrokAuth(fs, config.grokAuthPath, config.grokAuthLabel)) - return { claudeAvailable, codexAvailable, grokAvailable } + const isCodexAvailable = yield* _(hasCodexAuth(fs, config.codexSharedAuthPath, config.codexAuthLabel)) + const isGrokAvailable = yield* _(hasGrokAuth(fs, config.grokAuthPath, config.grokAuthLabel)) + return { claudeAvailable: isClaudeAvailable, codexAvailable: isCodexAvailable, grokAvailable: isGrokAvailable } }) const resolveExplicitAutoAgentMode = ( @@ -164,12 +164,11 @@ export const resolveAutoAgentMode = ( > ): Effect.Effect => Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - if (config.agentAuto !== true) { return config.agentMode } + const fs = yield* _(FileSystem.FileSystem) const available = yield* _(resolveAvailableAgentAuth(fs, config)) const explicitMode = yield* _(resolveExplicitAutoAgentMode(available, config.agentMode)) if (explicitMode !== undefined) { diff --git a/packages/lib/src/usecases/apply-project-discovery.ts b/packages/lib/src/usecases/apply-project-discovery.ts index c775409f..328a43f6 100644 --- a/packages/lib/src/usecases/apply-project-discovery.ts +++ b/packages/lib/src/usecases/apply-project-discovery.ts @@ -204,7 +204,11 @@ export const collectRemoteIdentities = ( const identity = normalizeRepoIdentity(url) identityMap.set(`${identity.fullPath}|${identity.repo}`, identity) } - return [...identityMap.values()] + const identities: Array = [] + identityMap.forEach((identity) => { + identities.push(identity) + }) + return identities }) export const gitCapture = tryGitCapture diff --git a/packages/lib/src/usecases/apply.ts b/packages/lib/src/usecases/apply.ts index 4aabcfd5..11099481 100644 --- a/packages/lib/src/usecases/apply.ts +++ b/packages/lib/src/usecases/apply.ts @@ -125,7 +125,7 @@ const runApplyForProjectDir = ( projectDir: string, command: ApplyCommand ): Effect.Effect => - command.runUp ? applyProjectWithUp(projectDir, command) : applyProjectFiles(projectDir, command) + (command.runUp ? applyProjectWithUp : applyProjectFiles)(projectDir, command) const applyProjectWithUp = ( projectDir: string, diff --git a/packages/lib/src/usecases/auth-claude-oauth.ts b/packages/lib/src/usecases/auth-claude-oauth.ts index 8484a235..c1502770 100644 --- a/packages/lib/src/usecases/auth-claude-oauth.ts +++ b/packages/lib/src/usecases/auth-claude-oauth.ts @@ -105,16 +105,18 @@ const buildDockerSetupTokenArgs = (spec: DockerSetupTokenSpec): ReadonlyArray => - executor.start( +): Effect.Effect => { + const dockerArgs = buildDockerSetupTokenArgs(spec) + return executor.start( pipe( - Command.make("docker", ...buildDockerSetupTokenArgs(spec)), + Command.make("docker", ...dockerArgs), Command.workingDirectory(spec.cwd), Command.stdin("inherit"), Command.stdout("pipe"), Command.stderr("pipe") ) ) +} const pumpDockerOutput = ( source: Stream.Stream, diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index ceb53fd9..a9907c0e 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -53,8 +53,8 @@ const syncClaudeCredentialsFile = ( Effect.gen(function*(_) { const nestedPath = claudeNestedCredentialsPath(accountPath) const rootPath = claudeCredentialsPath(accountPath) - const nestedExists = yield* _(isRegularFile(fs, nestedPath)) - if (nestedExists) { + const isNestedExists = yield* _(isRegularFile(fs, nestedPath)) + if (isNestedExists) { const nestedText = yield* _(readFileStringIfPresent(fs, nestedPath)) if (nestedText !== null) { yield* _(writeFileStringEnsuringParent(fs, path, rootPath, nestedText)) @@ -63,8 +63,8 @@ const syncClaudeCredentialsFile = ( return } - const rootExists = yield* _(isRegularFile(fs, rootPath)) - if (rootExists) { + const isRootExists = yield* _(isRegularFile(fs, rootPath)) + if (isRootExists) { const rootText = yield* _(readFileStringIfPresent(fs, rootPath)) if (rootText === null) { return @@ -131,10 +131,10 @@ const resolveClaudeAuthMethod = ( }) const buildClaudeAuthEnv = ( - interactive: boolean, + isInteractive: boolean, oauthToken: string | null = null ): ReadonlyArray => [ - ...(interactive + ...(isInteractive ? [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`, "BROWSER=echo"] : [`HOME=${claudeContainerHomeDir}`, `CLAUDE_CONFIG_DIR=${claudeContainerHomeDir}`]), ...(oauthToken === null ? [] : [`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`]) @@ -204,7 +204,7 @@ const runClaudeAuthCommand = ( accountPath: string, args: ReadonlyArray, commandLabel: string, - interactive: boolean + isInteractive: boolean ): Effect.Effect => runDockerAuth( buildDockerAuthSpec({ @@ -212,9 +212,9 @@ const runClaudeAuthCommand = ( image: claudeImageName, hostPath: accountPath, containerPath: claudeContainerHomeDir, - env: buildClaudeAuthEnv(interactive), + env: buildClaudeAuthEnv(isInteractive), args, - interactive + interactive: isInteractive }), [0], (exitCode) => new CommandFailedError({ command: commandLabel, exitCode }) diff --git a/packages/lib/src/usecases/auth-codex.ts b/packages/lib/src/usecases/auth-codex.ts index 5f52b046..fb299d99 100644 --- a/packages/lib/src/usecases/auth-codex.ts +++ b/packages/lib/src/usecases/auth-codex.ts @@ -114,7 +114,7 @@ const runCodexAuthCommand = ( accountPath: string, args: ReadonlyArray, commandLabel: string, - interactive: boolean + isInteractive: boolean ): Effect.Effect => runDockerAuth( buildDockerAuthSpec({ @@ -124,7 +124,7 @@ const runCodexAuthCommand = ( containerPath: codexHome, env: `CODEX_HOME=${codexHome}`, args, - interactive + interactive: isInteractive }), [0], (exitCode) => new CommandFailedError({ command: commandLabel, exitCode }) @@ -182,13 +182,15 @@ const runCodexLogout = ( // COMPLEXITY: O(command) export const authCodexLogin = ( command: AuthCodexLoginCommand -): Effect.Effect => - withCodexAuth(command, ({ accountPath, cwd }) => +): Effect.Effect => { + const accountLabel = normalizeAccountLabel(command.label, "default") + return withCodexAuth(command, ({ accountPath, cwd }) => runCodexLogin(cwd, accountPath).pipe( Effect.flatMap((output) => (output.length === 0 ? Effect.void : Effect.log(output))) )).pipe( - Effect.zipRight(autoSyncState(`chore(state): auth codex ${normalizeAccountLabel(command.label, "default")}`)) + Effect.zipRight(autoSyncState(`chore(state): auth codex ${accountLabel}`)) ) +} // CHANGE: show Codex auth status for a given label // WHY: make it obvious whether Codex is connected @@ -229,7 +231,9 @@ export const authCodexStatus = ( // COMPLEXITY: O(command) export const authCodexLogout = ( command: AuthCodexLogoutCommand -): Effect.Effect => - withCodexAuth(command, ({ accountPath, cwd }) => runCodexLogout(cwd, accountPath)).pipe( - Effect.zipRight(autoSyncState(`chore(state): auth codex logout ${normalizeAccountLabel(command.label, "default")}`)) +): Effect.Effect => { + const accountLabel = normalizeAccountLabel(command.label, "default") + return withCodexAuth(command, ({ accountPath, cwd }) => runCodexLogout(cwd, accountPath)).pipe( + Effect.zipRight(autoSyncState(`chore(state): auth codex logout ${accountLabel}`)) ) +} diff --git a/packages/lib/src/usecases/auth-copy.ts b/packages/lib/src/usecases/auth-copy.ts index b2b4779d..389ef1e1 100644 --- a/packages/lib/src/usecases/auth-copy.ts +++ b/packages/lib/src/usecases/auth-copy.ts @@ -91,8 +91,8 @@ const sourceDirReady = ( if (sourceDir === targetDir) { return false } - const sourceExists = yield* _(fs.exists(sourceDir)) - if (!sourceExists) { + const isSourceExists = yield* _(fs.exists(sourceDir)) + if (!isSourceExists) { return false } const sourceInfo = yield* _(statIfPresent(fs, sourceDir)) @@ -135,8 +135,8 @@ export const copyDirIfEmpty = ( label: string ): Effect.Effect => Effect.gen(function*(_) { - const ready = yield* _(sourceDirReady(fs, sourceDir, targetDir)) - if (!ready) { + const isReady = yield* _(sourceDirReady(fs, sourceDir, targetDir)) + if (!isReady) { return } yield* _(fs.makeDirectory(targetDir, { recursive: true })) @@ -192,8 +192,8 @@ export const copyDirMissingEntries = ( label: string ): Effect.Effect => Effect.gen(function*(_) { - const ready = yield* _(sourceDirReady(fs, sourceDir, targetDir)) - if (!ready) { + const isReady = yield* _(sourceDirReady(fs, sourceDir, targetDir)) + if (!isReady) { return } diff --git a/packages/lib/src/usecases/auth-gemini-helpers.ts b/packages/lib/src/usecases/auth-gemini-helpers.ts index 61f4c63b..413ec4b7 100644 --- a/packages/lib/src/usecases/auth-gemini-helpers.ts +++ b/packages/lib/src/usecases/auth-gemini-helpers.ts @@ -157,8 +157,8 @@ export const hasOauthCredentials = ( ): Effect.Effect => Effect.gen(function*(_) { const credentialsDir = geminiCredentialsPath(accountPath) - const dirExists = yield* _(fs.exists(credentialsDir)) - if (!dirExists) { + const isDirExists = yield* _(fs.exists(credentialsDir)) + if (!isDirExists) { return false } // Check for various possible credential files Gemini CLI might create @@ -169,8 +169,8 @@ export const hasOauthCredentials = ( `${credentialsDir}/application_default_credentials.json` ] for (const filePath of possibleFiles) { - const fileExists = yield* _(isRegularFile(fs, filePath)) - if (fileExists) { + const isFileExists = yield* _(isRegularFile(fs, filePath)) + if (isFileExists) { return true } } @@ -212,7 +212,7 @@ export const prepareGeminiCredentialsDir = ( ) => Effect.gen(function*(_) { const credentialsDir = geminiCredentialsPath(accountPath) - const removeFallback = pipe( + const fallbackRemoval = pipe( runCommandExitCode({ cwd, command: "docker", @@ -233,7 +233,7 @@ export const prepareGeminiCredentialsDir = ( yield* _( fs.remove(credentialsDir, { recursive: true, force: true }).pipe( - Effect.orElse(() => removeFallback) + Effect.orElse(() => fallbackRemoval) ) ) yield* _(fs.makeDirectory(credentialsDir, { recursive: true })) diff --git a/packages/lib/src/usecases/auth-gemini-oauth.ts b/packages/lib/src/usecases/auth-gemini-oauth.ts index 90f93cbc..979cdd7c 100644 --- a/packages/lib/src/usecases/auth-gemini-oauth.ts +++ b/packages/lib/src/usecases/auth-gemini-oauth.ts @@ -47,7 +47,7 @@ const detectAuthResult = (output: string): GeminiAuthResult => { const normalized = stripAnsi(output).toLowerCase() // Markers that indicate we are in the middle of or after an auth flow - const authInitiated = [ + const isAuthInitiated = [ "please visit the following url", "enter the authorization code", "authorized the application" @@ -56,7 +56,7 @@ const detectAuthResult = (output: string): GeminiAuthResult => { const isSuccess = authSuccessPatterns.some( (pattern) => normalized.includes(pattern.toLowerCase()) && - (authInitiated || normalized.includes("logged in with google")) + (isAuthInitiated || normalized.includes("logged in with google")) ) if (isSuccess) return "success" @@ -169,16 +169,18 @@ const cleanupExistingContainers = ( const startDockerProcess = ( executor: CommandExecutor.CommandExecutor, spec: DockerGeminiAuthSpec -): Effect.Effect => - executor.start( +): Effect.Effect => { + const dockerArgs = buildDockerGeminiAuthArgs(spec) + return executor.start( pipe( - Command.make("docker", ...buildDockerGeminiAuthArgs(spec)), + Command.make("docker", ...dockerArgs), Command.workingDirectory(spec.cwd), Command.stdin("inherit"), Command.stdout("pipe"), Command.stderr("pipe") ) ) +} const pumpDockerOutput = ( source: Stream.Stream, @@ -326,17 +328,14 @@ export const runGeminiOauthLoginWithPrompt = ( const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, resultBox, authDeferred))) const stderrFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stderr, 2, resultBox, authDeferred))) - const exitCode = yield* _( - Effect.race( - proc.exitCode.pipe(Effect.map(Number)), - pipe( - Deferred.await(authDeferred), - Effect.delay("500 millis"), - Effect.flatMap(() => proc.kill()), - Effect.map(() => 0) - ) - ) + const waitForExit = proc.exitCode.pipe(Effect.map(Number)) + const killAfterAuth = pipe( + Deferred.await(authDeferred), + Effect.delay("500 millis"), + Effect.flatMap(() => proc.kill()), + Effect.map(() => 0) ) + const exitCode = yield* _(Effect.race(waitForExit, killAfterAuth)) yield* _(Fiber.interrupt(stdoutFiber)) yield* _(Fiber.interrupt(stderrFiber)) diff --git a/packages/lib/src/usecases/auth-github.ts b/packages/lib/src/usecases/auth-github.ts index 90c8f1f9..94bfecb5 100644 --- a/packages/lib/src/usecases/auth-github.ts +++ b/packages/lib/src/usecases/auth-github.ts @@ -201,16 +201,18 @@ const runGithubLogin = ( const retryGithubLogin = ( effect: Effect.Effect -): Effect.Effect => - effect.pipe( +): Effect.Effect => { + const recurTwice = Schedule.recurs(2) + return effect.pipe( Effect.tapError(() => Effect.logWarning("GH auth login failed; retrying...")), Effect.retry( Schedule.addDelay( - Schedule.recurs(2), + recurTwice, () => Duration.seconds(2) ) ) ) +} const persistGithubToken = ( fs: FileSystem.FileSystem, diff --git a/packages/lib/src/usecases/auth-gitlab.ts b/packages/lib/src/usecases/auth-gitlab.ts index c847022c..b5bc9030 100644 --- a/packages/lib/src/usecases/auth-gitlab.ts +++ b/packages/lib/src/usecases/auth-gitlab.ts @@ -195,16 +195,18 @@ const runGitlabLogin = ( const retryGitlabLogin = ( effect: Effect.Effect -): Effect.Effect => - effect.pipe( +): Effect.Effect => { + const recurTwice = Schedule.recurs(2) + return effect.pipe( Effect.tapError(() => Effect.logWarning("GitLab auth login failed; retrying...")), Effect.retry( Schedule.addDelay( - Schedule.recurs(2), + recurTwice, () => Duration.seconds(2) ) ) ) +} const persistGitlabToken = ( fs: FileSystem.FileSystem, diff --git a/packages/lib/src/usecases/auth-grok-helpers.ts b/packages/lib/src/usecases/auth-grok-helpers.ts index b0699c75..918e98c2 100644 --- a/packages/lib/src/usecases/auth-grok-helpers.ts +++ b/packages/lib/src/usecases/auth-grok-helpers.ts @@ -123,6 +123,22 @@ export const withGrokAuth = ( }) ) +// PURITY: CORE +// INVARIANT: returns the first non-empty api-key value matching any grok env key prefix on the line, else null +const extractApiKeyFromEnvLine = (trimmed: string): string | null => { + for (const key of grokEnvApiKeyNames) { + const prefix = `${key}=` + if (!trimmed.startsWith(prefix)) { + continue + } + const value = trimmed.slice(prefix.length).replaceAll(/^['"]|['"]$/g, "").trim() + if (value.length > 0) { + return value + } + } + return null +} + const readApiKeyFromEnvFile = ( fs: FileSystem.FileSystem, envFilePath: string @@ -134,16 +150,9 @@ const readApiKeyFromEnvFile = ( } const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => "")) for (const line of envContent.split("\n")) { - const trimmed = line.trim() - for (const key of grokEnvApiKeyNames) { - const prefix = `${key}=` - if (!trimmed.startsWith(prefix)) { - continue - } - const value = trimmed.slice(prefix.length).replaceAll(/^['"]|['"]$/g, "").trim() - if (value.length > 0) { - return value - } + const value = extractApiKeyFromEnvLine(line.trim()) + if (value !== null) { + return value } } return null @@ -211,7 +220,7 @@ export const prepareGrokCredentialsDir = ( ) => Effect.gen(function*(_) { const credentialsDir = grokCredentialsPath(accountPath) - const removeFallback = pipe( + const fallbackRemoval = pipe( runCommandExitCode({ cwd, command: "docker", @@ -226,7 +235,7 @@ export const prepareGrokCredentialsDir = ( yield* _( fs.remove(credentialsDir, { recursive: true, force: true }).pipe( - Effect.orElse(() => removeFallback) + Effect.orElse(() => fallbackRemoval) ) ) yield* _(fs.makeDirectory(credentialsDir, { recursive: true })) @@ -246,7 +255,7 @@ export const defaultGrokProjectSettings = { } export const defaultGrokUserSettings = (apiKey: string | null) => ({ - ...(apiKey === null ? {} : { apiKey }), + ...(apiKey !== null && { apiKey }), sandboxMode: "off", confirmBeforeToolUse: false }) @@ -271,10 +280,11 @@ export const writeInitialGrokSettings = ( ? !(yield* _(isRegularFile(fs, userSettingsPath))) : true if (shouldWriteUserSettings) { + const userSettings = defaultGrokUserSettings(apiKey) yield* _( fs.writeFileString( userSettingsPath, - JSON.stringify(defaultGrokUserSettings(apiKey), null, 2) + "\n" + JSON.stringify(userSettings, null, 2) + "\n" ) ) yield* _(fs.chmod(userSettingsPath, 0o600)) diff --git a/packages/lib/src/usecases/auth-helpers.ts b/packages/lib/src/usecases/auth-helpers.ts index 14d2e049..1ddc6601 100644 --- a/packages/lib/src/usecases/auth-helpers.ts +++ b/packages/lib/src/usecases/auth-helpers.ts @@ -49,8 +49,8 @@ export const buildDockerAuthSpec = (input: DockerAuthSpecInput): DockerAuthSpec cwd: input.cwd, image: input.image, volume: { hostPath: input.hostPath, containerPath: input.containerPath }, - ...(typeof input.entrypoint === "string" ? { entrypoint: input.entrypoint } : {}), - ...(input.env === undefined ? {} : { env: input.env }), + ...((typeof input.entrypoint === "string") && { entrypoint: input.entrypoint }), + ...(input.env !== undefined && { env: input.env }), args: input.args, interactive: input.interactive }) @@ -70,8 +70,8 @@ export const isRegularFile = ( filePath: string ): Effect.Effect => Effect.gen(function*(_) { - const exists = yield* _(fs.exists(filePath)) - if (!exists) { + const isExists = yield* _(fs.exists(filePath)) + if (!isExists) { return false } const info = yield* _(fs.stat(filePath)) diff --git a/packages/lib/src/usecases/auth-sync-helpers.ts b/packages/lib/src/usecases/auth-sync-helpers.ts index dba1fd67..c0c49b46 100644 --- a/packages/lib/src/usecases/auth-sync-helpers.ts +++ b/packages/lib/src/usecases/auth-sync-helpers.ts @@ -142,8 +142,8 @@ export const hasNonEmptyFile = ( filePath: string ): Effect.Effect => Effect.gen(function*(_) { - const exists = yield* _(fs.exists(filePath)) - if (!exists) { + const isExists = yield* _(fs.exists(filePath)) + if (!isExists) { return false } diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index 556393cd..3a8309e5 100644 --- a/packages/lib/src/usecases/auth-sync.ts +++ b/packages/lib/src/usecases/auth-sync.ts @@ -127,8 +127,8 @@ export const ensureCodexConfigFile = ( const resolved = resolvePathFromBase(path, baseDir, codexAuthPath) const configPath = path.join(resolved, "config.toml") const writeConfig = Effect.gen(function*(__) { - const exists = yield* __(fs.exists(configPath)) - if (exists) { + const isExists = yield* __(fs.exists(configPath)) + if (isExists) { const current = yield* __(fs.readFileString(configPath)) if (!shouldRewriteDockerGitCodexConfig(current)) { return @@ -197,8 +197,8 @@ export const migrateLegacyOrchLayout = ( withFsPathContext(({ fs, path }) => Effect.gen(function*(_) { const legacyRoot = path.resolve(baseDir, ".orch") - const legacyExists = yield* _(fs.exists(legacyRoot)) - if (!legacyExists) { + const isLegacyExists = yield* _(fs.exists(legacyRoot)) + if (!isLegacyExists) { return } const legacyInfo = yield* _(fs.stat(legacyRoot)) diff --git a/packages/lib/src/usecases/docker-dns.ts b/packages/lib/src/usecases/docker-dns.ts index b72c2c90..59d4b77b 100644 --- a/packages/lib/src/usecases/docker-dns.ts +++ b/packages/lib/src/usecases/docker-dns.ts @@ -26,15 +26,15 @@ export const ensureDockerDnsHost = ( DockerCommandError | PlatformError, FileSystem.FileSystem | CommandExecutor.CommandExecutor > => { - const addHost = Effect.gen(function*(_) { + const hostEntry = Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const hostName = deriveDockerDnsName(repoUrl) - const hostsPath = "/etc/hosts" const ipAddress = yield* _(runDockerInspectContainerIp(cwd, containerName)) if (ipAddress.length === 0) { yield* _(Effect.logWarning(`Docker IP not available for ${containerName}; skipping DNS entry.`)) return } + const hostsPath = "/etc/hosts" const current = yield* _(fs.readFileString(hostsPath)) if (current.includes(` ${hostName}`) || current.includes(`\t${hostName}`)) { return @@ -44,7 +44,7 @@ export const ensureDockerDnsHost = ( yield* _(Effect.log(`DNS alias added: ${hostName} -> ${ipAddress}`)) }) - return Effect.match(addHost, { + return Effect.match(hostEntry, { onFailure: (error) => Effect.logWarning( `Failed to update /etc/hosts for docker DNS: ${error instanceof Error ? error.message : String(error)}` diff --git a/packages/lib/src/usecases/docker-git-config-search.ts b/packages/lib/src/usecases/docker-git-config-search.ts index 68f37947..80989820 100644 --- a/packages/lib/src/usecases/docker-git-config-search.ts +++ b/packages/lib/src/usecases/docker-git-config-search.ts @@ -56,8 +56,8 @@ export const findDockerGitConfigPaths = ( rootDir: string ): Effect.Effect, PlatformError> => Effect.gen(function*(_) { - const exists = yield* _(fs.exists(rootDir)) - if (!exists) { + const isExists = yield* _(fs.exists(rootDir)) + if (!isExists) { return [] } diff --git a/packages/lib/src/usecases/docker-image.ts b/packages/lib/src/usecases/docker-image.ts index 32b7e6a7..c65589d9 100644 --- a/packages/lib/src/usecases/docker-image.ts +++ b/packages/lib/src/usecases/docker-image.ts @@ -66,9 +66,9 @@ export const ensureDockerImage = ( Effect.map(Number) ) ) - const dockerfileExists = yield* _(fs.exists(dockerfilePath)) - const dockerfileMatches = yield* _( - dockerfileExists + const isDockerfileExists = yield* _(fs.exists(dockerfilePath)) + const isDockerfileMatches = yield* _( + isDockerfileExists ? Effect.gen(function*(__) { const info = yield* __(fs.stat(dockerfilePath)) if (info.type !== "File") { @@ -79,7 +79,7 @@ export const ensureDockerImage = ( }) : Effect.succeed(false) ) - if (imageCheck === 0 && dockerfileMatches) { + if (imageCheck === 0 && isDockerfileMatches) { return } diff --git a/packages/lib/src/usecases/docker-network-gc.ts b/packages/lib/src/usecases/docker-network-gc.ts index a7295dc0..a251b4d7 100644 --- a/packages/lib/src/usecases/docker-network-gc.ts +++ b/packages/lib/src/usecases/docker-network-gc.ts @@ -44,7 +44,7 @@ const createSharedNetworkWithSubnetFallback = ( ): Effect.Effect => Effect.gen(function*(_) { for (const subnet of sharedNetworkFallbackSubnets) { - const created = yield* _( + const isCreated = yield* _( runDockerNetworkCreateBridgeWithSubnet(cwd, networkName, subnet).pipe( Effect.as(true), Effect.catchTag("DockerCommandError", (error) => @@ -53,7 +53,7 @@ const createSharedNetworkWithSubnetFallback = ( ).pipe(Effect.as(false))) ) ) - if (created) { + if (isCreated) { yield* _(Effect.log(`Created shared Docker network ${networkName} with subnet ${subnet}.`)) return true } diff --git a/packages/lib/src/usecases/env-file.ts b/packages/lib/src/usecases/env-file.ts index cb161d88..e09d5593 100644 --- a/packages/lib/src/usecases/env-file.ts +++ b/packages/lib/src/usecases/env-file.ts @@ -55,7 +55,7 @@ const isEnvKey = (value: string): boolean => { if (value.length === 0) { return false } - const first = value[0] ?? "" + const first = value.at(0) ?? "" if (!isValidFirstChar(first)) { return false } @@ -223,8 +223,8 @@ export const sanitizeComposeEnvFile = ( envPath: string ): Effect.Effect, PlatformError> => Effect.gen(function*(_) { - const exists = yield* _(fs.exists(envPath)) - if (!exists) { + const isExists = yield* _(fs.exists(envPath)) + if (!isExists) { return [] } @@ -259,8 +259,8 @@ export const ensureEnvFile = ( envPath: string ): Effect.Effect => Effect.gen(function*(_) { - const exists = yield* _(fs.exists(envPath)) - if (exists) { + const isExists = yield* _(fs.exists(envPath)) + if (isExists) { return } yield* _(fs.makeDirectory(path.dirname(envPath), { recursive: true })) @@ -282,8 +282,8 @@ export const readEnvText = ( envPath: string ): Effect.Effect => Effect.gen(function*(_) { - const exists = yield* _(fs.exists(envPath)) - if (!exists) { + const isExists = yield* _(fs.exists(envPath)) + if (!isExists) { return defaultEnvContents } const info = yield* _(fs.stat(envPath)) diff --git a/packages/lib/src/usecases/errors.ts b/packages/lib/src/usecases/errors.ts index 9205f32a..111e0f90 100644 --- a/packages/lib/src/usecases/errors.ts +++ b/packages/lib/src/usecases/errors.ts @@ -78,7 +78,7 @@ const renderDockerAccessActionPlan = (issue: DockerAccessError["issue"]): string "3) Retry command in a new shell." ] - return issue === "PermissionDenied" ? permissionDeniedPlan.join("\n") : daemonUnavailablePlan.join("\n") + return (issue === "PermissionDenied" ? permissionDeniedPlan : daemonUnavailablePlan).join("\n") } // CHANGE: classify Docker build apt signature noise that is commonly caused by storage exhaustion. diff --git a/packages/lib/src/usecases/github-token-preflight.ts b/packages/lib/src/usecases/github-token-preflight.ts index 988cef29..0d81a479 100644 --- a/packages/lib/src/usecases/github-token-preflight.ts +++ b/packages/lib/src/usecases/github-token-preflight.ts @@ -31,17 +31,17 @@ const defaultGithubTokenKeys: ReadonlyArray = [ ] export const githubRepoAccessMessage = (repoUrl: string, hasToken: boolean): string => - hasToken + (hasToken ? [ `GitHub access denied for repository: ${repoUrl}`, "Reason: the repository does not exist, is private, or the selected token has no rights.", "If you need access, run: docker-git auth github login --web" - ].join("\n") + ] : [ `GitHub repository is not accessible without auth: ${repoUrl}`, "Reason: the repository does not exist, is private, or a GitHub token/key is required.", "If you need access, run: docker-git auth github login --web" - ].join("\n") + ]).join("\n") const findFirstEnvValue = (input: string, keys: ReadonlyArray): string | null => { for (const key of keys) { @@ -132,7 +132,7 @@ export const probeGithubRepoAccess = ( const response = yield* _( client.get(`https://api.github.com/repos/${repo.owner}/${repo.repo}`, { headers: { - ...(token === null ? {} : { Authorization: `Bearer ${token}` }), + ...(token !== null && { Authorization: `Bearer ${token}` }), Accept: "application/vnd.github+json" } }) diff --git a/packages/lib/src/usecases/gitlab-token-preflight.ts b/packages/lib/src/usecases/gitlab-token-preflight.ts index f86b7e71..fe850e0b 100644 --- a/packages/lib/src/usecases/gitlab-token-preflight.ts +++ b/packages/lib/src/usecases/gitlab-token-preflight.ts @@ -31,17 +31,17 @@ const defaultGitlabTokenKeys: ReadonlyArray = [ ] export const gitlabRepoAccessMessage = (repoUrl: string, hasToken: boolean): string => - hasToken + (hasToken ? [ `GitLab access denied for repository: ${repoUrl}`, "Reason: the repository does not exist, is private, or the selected token has no rights.", "If you need access, run: docker-git auth gitlab login" - ].join("\n") + ] : [ `GitLab repository is not accessible without auth: ${repoUrl}`, "Reason: the repository does not exist, is private, or a GitLab token/key is required.", "If you need access, run: docker-git auth gitlab login" - ].join("\n") + ]).join("\n") const findFirstEnvValue = (input: string, keys: ReadonlyArray): string | null => { for (const key of keys) { diff --git a/packages/lib/src/usecases/mcp-playwright.ts b/packages/lib/src/usecases/mcp-playwright.ts index be74376e..cd8f20c8 100644 --- a/packages/lib/src/usecases/mcp-playwright.ts +++ b/packages/lib/src/usecases/mcp-playwright.ts @@ -42,11 +42,11 @@ export const enableMcpPlaywrightProjectFiles = ( ): Effect.Effect => Effect.gen(function*(_) { const config = yield* _(readProjectConfig(projectDir)) - const alreadyEnabled = config.template.enableMcpPlaywright - const updated = alreadyEnabled ? config.template : enableInTemplate(config.template) + const wasAlreadyEnabled = config.template.enableMcpPlaywright + const updated = wasAlreadyEnabled ? config.template : enableInTemplate(config.template) yield* _( - alreadyEnabled + wasAlreadyEnabled ? Effect.log("Playwright MCP is already enabled for this project.") : Effect.log("Enabling Playwright MCP for this project (templates only)...") ) diff --git a/packages/lib/src/usecases/path-helpers.ts b/packages/lib/src/usecases/path-helpers.ts index fbb76ac3..4096106c 100644 --- a/packages/lib/src/usecases/path-helpers.ts +++ b/packages/lib/src/usecases/path-helpers.ts @@ -34,7 +34,7 @@ const expandHome = (value: string, home: string | null): string => { const trimTrailingSlash = (value: string): string => { let end = value.length while (end > 0) { - if (end === 1 && value[0] === "/") { + if (end === 1 && value.at(0) === "/") { break } if (end === 3 && /^[a-z]:[\\/]/iu.test(value.slice(0, end))) { @@ -108,8 +108,8 @@ export const findExistingUpwards = ( for (let depth = 0; depth <= maxDepth; depth += 1) { const candidate = path.join(current, fileName) - const exists = yield* _(fs.exists(candidate)) - if (exists) { + const isExists = yield* _(fs.exists(candidate)) + if (isExists) { return candidate } diff --git a/packages/lib/src/usecases/ports-reserve.ts b/packages/lib/src/usecases/ports-reserve.ts index ccc7f10e..3df0016a 100644 --- a/packages/lib/src/usecases/ports-reserve.ts +++ b/packages/lib/src/usecases/ports-reserve.ts @@ -90,9 +90,9 @@ export const loadReservedPorts = ( if (!filter(item)) { continue } - const occupiedByDocker = publishedByDocker.has(item.sshPort) - const occupiedBySocket = occupiedByDocker ? false : !(yield* _(isPortAvailable(item.sshPort))) - if (occupiedByDocker || occupiedBySocket) { + const isOccupiedByDocker = publishedByDocker.has(item.sshPort) + const isOccupiedBySocket = isOccupiedByDocker ? false : !(yield* _(isPortAvailable(item.sshPort))) + if (isOccupiedByDocker || isOccupiedBySocket) { reservePort(reserved, seen, item.sshPort, item.projectDir) } } @@ -144,11 +144,12 @@ export const selectAvailablePort = ( if (Option.isSome(selected)) { return selected.value } + const effectiveAttempts = Math.max(1, attempts) return yield* _( Effect.fail( new PortProbeError({ port: preferred, - message: `no available port in range ${preferred}-${preferred + Math.max(1, attempts) - 1}` + message: `no available port in range ${preferred}-${preferred + effectiveAttempts - 1}` }) ) ) diff --git a/packages/lib/src/usecases/project-runtime-state.ts b/packages/lib/src/usecases/project-runtime-state.ts index 243d046a..0772e263 100644 --- a/packages/lib/src/usecases/project-runtime-state.ts +++ b/packages/lib/src/usecases/project-runtime-state.ts @@ -201,11 +201,11 @@ export const readProjectRuntimeState = ( const path = yield* _(Path.Path) const statePath = resolveProjectRuntimeStatePath(path, projectDir) const exists = yield* _(Effect.either(fs.exists(statePath))) - const fileExists = Either.match(exists, { + const isFileExists = Either.match(exists, { onLeft: () => false, onRight: (value) => value }) - if (!fileExists) { + if (!isFileExists) { return emptyProjectRuntimeState() } diff --git a/packages/lib/src/usecases/projects-apply-all.ts b/packages/lib/src/usecases/projects-apply-all.ts index cf285399..b2e2d0ae 100644 --- a/packages/lib/src/usecases/projects-apply-all.ts +++ b/packages/lib/src/usecases/projects-apply-all.ts @@ -6,7 +6,15 @@ import { Effect, pipe } from "effect" import type { ApplyAllCommand } from "../core/domain.js" import { ensureDockerDaemonAccess, runDockerPsNames } from "../shell/docker.js" -import type { CommandFailedError, DockerAccessError, DockerCommandError } from "../shell/errors.js" +import type { + CommandFailedError, + ConfigDecodeError, + ConfigNotFoundError, + DockerAccessError, + DockerCommandError, + FileExistsError, + PortProbeError +} from "../shell/errors.js" import { renderError } from "./errors.js" import { forEachProjectStatus, @@ -36,39 +44,32 @@ const applyToProjects = ( ) => forEachProjectStatus( index.configPaths, - (status) => - runningNames !== null && !runningNames.includes(status.config.template.containerName) - ? Effect.log(`Skipping ${status.projectDir}: container is not running`) - : pipe( - Effect.log(renderProjectStatusHeader(status)), - Effect.zipRight( - runDockerComposeUpWithPortCheck(status.projectDir).pipe( - Effect.catchTag("DockerCommandError", (error: DockerCommandError) => - Effect.logWarning( - `apply failed for ${status.projectDir}: ${ - renderError(error) - }. Check the project docker-compose config (e.g. env files for merge conflicts, port conflicts in docker-compose.yml config) and retry.` - )), - Effect.catchTag("ConfigNotFoundError", (error) => - Effect.logWarning( - `Skipping ${status.projectDir}: ${renderError(error)}` - )), - Effect.catchTag("ConfigDecodeError", (error) => - Effect.logWarning( - `Skipping ${status.projectDir}: ${renderError(error)}` - )), - Effect.catchTag("PortProbeError", (error) => - Effect.logWarning( - `Skipping ${status.projectDir}: ${renderError(error)}` - )), - Effect.catchTag("FileExistsError", (error) => - Effect.logWarning( - `Skipping ${status.projectDir}: ${renderError(error)}` - )), - Effect.asVoid - ) - ) + (status) => { + if (runningNames !== null && !runningNames.includes(status.config.template.containerName)) { + return Effect.log(`Skipping ${status.projectDir}: container is not running`) + } + const applyFailedWarning = (error: DockerCommandError) => + Effect.logWarning( + `apply failed for ${status.projectDir}: ${ + renderError(error) + }. Check the project docker-compose config (e.g. env files for merge conflicts, port conflicts in docker-compose.yml config) and retry.` ) + const skipWarning = ( + error: ConfigNotFoundError | ConfigDecodeError | PortProbeError | FileExistsError + ) => Effect.logWarning(`Skipping ${status.projectDir}: ${renderError(error)}`) + const applyWithRecovery = runDockerComposeUpWithPortCheck(status.projectDir).pipe( + Effect.catchTag("DockerCommandError", applyFailedWarning), + Effect.catchTag("ConfigNotFoundError", skipWarning), + Effect.catchTag("ConfigDecodeError", skipWarning), + Effect.catchTag("PortProbeError", skipWarning), + Effect.catchTag("FileExistsError", skipWarning), + Effect.asVoid + ) + return pipe( + Effect.log(renderProjectStatusHeader(status)), + Effect.zipRight(applyWithRecovery) + ) + } ) export const applyAllDockerGitProjects = ( diff --git a/packages/lib/src/usecases/projects-core.ts b/packages/lib/src/usecases/projects-core.ts index 125ce90d..7ea13ea3 100644 --- a/packages/lib/src/usecases/projects-core.ts +++ b/packages/lib/src/usecases/projects-core.ts @@ -129,7 +129,7 @@ export const loadProjectSummary = ( projectDir, config.template.authorizedKeysPath ) - const authExists = yield* _(fs.exists(resolvedAuthorizedKeys)) + const isAuthExists = yield* _(fs.exists(resolvedAuthorizedKeys)) const sshCommand = buildSshCommand(config.template, sshKey) return { @@ -138,7 +138,7 @@ export const loadProjectSummary = ( sshCommand, sshKeyPath: sshKey, authorizedKeysPath: resolvedAuthorizedKeys, - authorizedKeysExists: authExists + authorizedKeysExists: isAuthExists } }) @@ -192,7 +192,7 @@ export const loadProjectItem = ( const template = config.template const resolvedAuthorizedKeys = resolveAuthorizedKeysPath(path, projectDir, template.authorizedKeysPath) - const authExists = yield* _(fs.exists(resolvedAuthorizedKeys)) + const isAuthExists = yield* _(fs.exists(resolvedAuthorizedKeys)) const sshCommand = buildSshCommand(template, sshKey) const displayName = formatDisplayName(template.repoUrl) const runtimeState = yield* _(readProjectRuntimeState(projectDir)) @@ -211,7 +211,7 @@ export const loadProjectItem = ( sshCommand, sshKeyPath: sshKey, authorizedKeysPath: resolvedAuthorizedKeys, - authorizedKeysExists: authExists, + authorizedKeysExists: isAuthExists, envGlobalPath: resolvePathFromCwd(path, projectDir, template.envGlobalPath), envProjectPath: resolvePathFromCwd(path, projectDir, template.envProjectPath), codexAuthPath: resolvePathFromCwd(path, projectDir, template.codexAuthPath), @@ -238,10 +238,11 @@ export const forEachProjectStatus = ( ): Effect.Effect => Effect.gen(function*(_) { for (const configPath of configPaths) { + const onFailure = skipWithWarning(configPath) const status = yield* _( loadProjectStatus(configPath).pipe( Effect.matchEffect({ - onFailure: skipWithWarning(configPath), + onFailure, onSuccess: (value) => Effect.succeed(value) }) ) diff --git a/packages/lib/src/usecases/projects-delete.ts b/packages/lib/src/usecases/projects-delete.ts index 6777c121..0337ca9a 100644 --- a/packages/lib/src/usecases/projects-delete.ts +++ b/packages/lib/src/usecases/projects-delete.ts @@ -91,8 +91,8 @@ export const deleteDockerGitProject = ( return } - const exists = yield* _(fs.exists(targetDir)) - if (!exists) { + const isExists = yield* _(fs.exists(targetDir)) + if (!isExists) { yield* _(Effect.logWarning(`Project directory already missing: ${targetDir}`)) return } diff --git a/packages/lib/src/usecases/projects-down.ts b/packages/lib/src/usecases/projects-down.ts index 4588ffa3..868d89ea 100644 --- a/packages/lib/src/usecases/projects-down.ts +++ b/packages/lib/src/usecases/projects-down.ts @@ -29,19 +29,21 @@ export const downAllDockerGitProjects: Effect.Effect< Effect.flatMap((index) => index === null ? Effect.void - : forEachProjectStatus(index.configPaths, (status) => - pipe( - Effect.log(renderProjectStatusHeader(status)), - Effect.zipRight( - runDockerComposeDown(status.projectDir).pipe( - Effect.catchTag("DockerCommandError", (error: DockerCommandError) => - Effect.logWarning( - `docker compose down failed for ${status.projectDir}: ${renderError(error)}` - )), - Effect.zipRight(gcProjectNetworkByTemplate(status.projectDir, status.config.template)) - ) + : forEachProjectStatus(index.configPaths, (status) => { + const downFailedWarning = (error: DockerCommandError) => + Effect.logWarning( + `docker compose down failed for ${status.projectDir}: ${renderError(error)}` ) - )) + const gcNetwork = gcProjectNetworkByTemplate(status.projectDir, status.config.template) + const downWithRecovery = runDockerComposeDown(status.projectDir).pipe( + Effect.catchTag("DockerCommandError", downFailedWarning), + Effect.zipRight(gcNetwork) + ) + return pipe( + Effect.log(renderProjectStatusHeader(status)), + Effect.zipRight(downWithRecovery) + ) + }) ), Effect.asVoid ) diff --git a/packages/lib/src/usecases/projects-list.ts b/packages/lib/src/usecases/projects-list.ts index cb4b7bb5..5e4bac24 100644 --- a/packages/lib/src/usecases/projects-list.ts +++ b/packages/lib/src/usecases/projects-list.ts @@ -38,10 +38,11 @@ export const listProjects: Effect.Effect< const available: Array = [] for (const configPath of index.configPaths) { + const onFailure = skipWithWarning(configPath) const summary = yield* _( loadProjectSummary(configPath, sshKey).pipe( Effect.matchEffect({ - onFailure: skipWithWarning(configPath), + onFailure, onSuccess: (value) => Effect.succeed(value) }) ) @@ -155,11 +156,13 @@ export const listProjectItems: Effect.Effect< // EFFECT: Effect, PlatformError | CommandFailedError, FileSystem | Path | CommandExecutor> // INVARIANT: result order follows listProjectItems order // COMPLEXITY: O(n + command) +const runningProjectNames = runDockerPsNames(process.cwd()) + export const listRunningProjectItems: Effect.Effect< ReadonlyArray, PlatformError | CommandFailedError, ListProjectsContext > = pipe( - Effect.all([listProjectItems, runDockerPsNames(process.cwd())]), + Effect.all([listProjectItems, runningProjectNames]), Effect.map(([items, runningNames]) => items.filter((item) => runningNames.includes(item.containerName))) ) diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index 2692b0ce..5d8d3982 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -110,23 +110,19 @@ export const waitForProjectSshReady = ( const host = item.ipAddress ?? "localhost" const port = item.ipAddress ? 22 : item.sshPort const probe = Effect.gen(function*(_) { - const ready = yield* _(probeProjectSshReady(item)) - if (!ready) { + const isReady = yield* _(probeProjectSshReady(item)) + if (!isReady) { return yield* _(Effect.fail(new CommandFailedError({ command: "ssh wait", exitCode: 1 }))) } }) + const retrySchedule = pipe( + Schedule.spaced(Duration.seconds(2)), + Schedule.intersect(Schedule.recurs(30)) + ) return pipe( Effect.log(`Waiting for SSH on ${host}:${port} ...`), - Effect.zipRight( - Effect.retry( - probe, - pipe( - Schedule.spaced(Duration.seconds(2)), - Schedule.intersect(Schedule.recurs(30)) - ) - ) - ), + Effect.zipRight(Effect.retry(probe, retrySchedule)), Effect.tap(() => Effect.log("SSH is ready.")) ) } diff --git a/packages/lib/src/usecases/projects-up.ts b/packages/lib/src/usecases/projects-up.ts index b44455cb..cdc667af 100644 --- a/packages/lib/src/usecases/projects-up.ts +++ b/packages/lib/src/usecases/projects-up.ts @@ -294,7 +294,7 @@ export const runDockerComposeUpWithPortCheck = ( > => Effect.gen(function*(_) { const config = yield* _(readProjectConfig(projectDir)) - const alreadyRunning = yield* _( + const isAlreadyRunning = yield* _( runDockerComposePsFormatted(projectDir).pipe( Effect.map((raw) => parseComposePsOutput(raw)), Effect.map((rows) => rows.length > 0) @@ -302,7 +302,7 @@ export const runDockerComposeUpWithPortCheck = ( ) // Avoid port churn when the project's compose environment is already running. - const updated = alreadyRunning + const updated = isAlreadyRunning ? config.template : yield* _(ensureAvailableSshPort(projectDir, config)) const resolvedTemplate = yield* _(resolveTemplateResourceLimits(updated)) @@ -311,9 +311,9 @@ export const runDockerComposeUpWithPortCheck = ( yield* _(ensureComposeNetworkReady(projectDir, resolvedTemplate)) yield* _(ensureSharedCodexVolumeReady(projectDir, resolvedTemplate)) const startedTemplate = yield* _(runProjectComposeUp(projectDir, resolvedTemplate, options.buildMode ?? "build")) - yield* (options.waitForPostStart === false - ? _(startProjectPostStartSelfHealInBackground(projectDir, startedTemplate)) - : _(runProjectPostStartSelfHeal(projectDir, startedTemplate))) + yield* _((options.waitForPostStart === false + ? startProjectPostStartSelfHealInBackground + : runProjectPostStartSelfHeal)(projectDir, startedTemplate)) return startedTemplate }) diff --git a/packages/lib/src/usecases/scrap-common.ts b/packages/lib/src/usecases/scrap-common.ts index 5abb16db..319b1941 100644 --- a/packages/lib/src/usecases/scrap-common.ts +++ b/packages/lib/src/usecases/scrap-common.ts @@ -87,11 +87,11 @@ export const runDockerExec = ( ) export const ensureSafeScrapImportWipe = ( - wipe: boolean, + shouldWipe: boolean, template: ScrapTemplate, relative: string ): Effect.Effect => - wipe && relative.length === 0 + shouldWipe && relative.length === 0 ? Effect.fail( new ScrapWipeRefusedErrorClass({ sshUser: template.sshUser, diff --git a/packages/lib/src/usecases/scrap-session-export.ts b/packages/lib/src/usecases/scrap-session-export.ts index 41b60d84..e1fce4dc 100644 --- a/packages/lib/src/usecases/scrap-session-export.ts +++ b/packages/lib/src/usecases/scrap-session-export.ts @@ -98,8 +98,8 @@ const exportHostEnvFiles = ( Effect.gen(function*(_) { const copyIfExists = (srcAbs: string, dstName: string) => Effect.gen(function*(__) { - const exists = yield* __(ctx.fs.exists(srcAbs)) - if (!exists) { + const isExists = yield* __(ctx.fs.exists(srcAbs)) + if (!isExists) { return null } const contents = yield* __(ctx.fs.readFileString(srcAbs)) diff --git a/packages/lib/src/usecases/scrap-session-import.ts b/packages/lib/src/usecases/scrap-session-import.ts index 78bad01d..2b48f8e9 100644 --- a/packages/lib/src/usecases/scrap-session-import.ts +++ b/packages/lib/src/usecases/scrap-session-import.ts @@ -39,8 +39,8 @@ const resolveSessionSnapshotDir = ( ): Effect.Effect => Effect.gen(function*(_) { const baseAbs = resolvePathFromCwd(path, projectDir, archivePath) - const exists = yield* _(fs.exists(baseAbs)) - if (!exists) { + const isExists = yield* _(fs.exists(baseAbs)) + if (!isExists) { return yield* _(Effect.fail(new ScrapArchiveNotFoundError({ path: baseAbs }))) } @@ -49,8 +49,8 @@ const resolveSessionSnapshotDir = ( return yield* _(Effect.fail(new ScrapArchiveNotFoundError({ path: baseAbs }))) } - const direct = yield* _(fs.exists(path.join(baseAbs, "manifest.json"))) - if (direct) { + const isDirect = yield* _(fs.exists(path.join(baseAbs, "manifest.json"))) + if (isDirect) { return baseAbs } @@ -100,8 +100,8 @@ const resolveSnapshotPartsAbs = ( const chunks = yield* _(decodeChunkManifest(chunksAbs, chunksText)) const partsAbs = chunks.parts.map((part) => ctx.path.join(ctx.snapshotDir, part)) for (const partAbs of partsAbs) { - const partExists = yield* _(ctx.fs.exists(partAbs)) - if (!partExists) { + const isPartExists = yield* _(ctx.fs.exists(partAbs)) + if (!isPartExists) { return yield* _(Effect.fail(new ScrapArchiveNotFoundError({ path: partAbs }))) } } @@ -118,8 +118,8 @@ const restoreHostEnvFiles = (ctx: SessionImportContext): Effect.Effect => { const targetDir = shellEscape(ctx.template.targetDir) - const wipeLine = wipe ? `rm -rf ${targetDir}` : ":" + const wipeLine = shouldWipe ? `rm -rf ${targetDir}` : ":" const gitDir = `${ctx.template.targetDir}/.git` const prepScript = [ "set -e", diff --git a/packages/lib/src/usecases/scrap-session-manifest.ts b/packages/lib/src/usecases/scrap-session-manifest.ts index d8676460..f3b8c8b7 100644 --- a/packages/lib/src/usecases/scrap-session-manifest.ts +++ b/packages/lib/src/usecases/scrap-session-manifest.ts @@ -28,6 +28,10 @@ export type SessionManifest = { } } +const NullableString = Schema.Union(Schema.String, Schema.Null) + +const StringArray = Schema.Array(Schema.String) + const SessionManifestSchema = Schema.Struct({ schemaVersion: Schema.Literal(1), mode: Schema.Literal("session"), @@ -42,12 +46,12 @@ const SessionManifestSchema = Schema.Struct({ worktreePatchChunks: Schema.String, codexChunks: Schema.String, codexSharedChunks: Schema.String, - envGlobalFile: Schema.optionalWith(Schema.Union(Schema.String, Schema.Null), { default: () => null }), - envProjectFile: Schema.optionalWith(Schema.Union(Schema.String, Schema.Null), { default: () => null }) + envGlobalFile: Schema.optionalWith(NullableString, { default: () => null }), + envProjectFile: Schema.optionalWith(NullableString, { default: () => null }) }), rebuild: Schema.optionalWith( Schema.Struct({ - commands: Schema.Array(Schema.String) + commands: StringArray }), { default: () => ({ commands: [] }) } ) diff --git a/packages/lib/src/usecases/ssh-access.ts b/packages/lib/src/usecases/ssh-access.ts index d286553f..6b0b5c34 100644 --- a/packages/lib/src/usecases/ssh-access.ts +++ b/packages/lib/src/usecases/ssh-access.ts @@ -31,18 +31,18 @@ const trimAliasEdges = (value: string): string => { const sanitizeSshHostAlias = (value: string): string => { let normalized = "" - let previousWasDash = false + let isPreviousWasDash = false for (const char of value.trim()) { if (isAliasChar(char)) { normalized += char - previousWasDash = false + isPreviousWasDash = false continue } - if (!previousWasDash) { + if (!isPreviousWasDash) { normalized += "-" - previousWasDash = true + isPreviousWasDash = true } } @@ -190,7 +190,7 @@ export const resolveProjectSshAccess = ( : undefined const authorizedKeysPath = resolveAuthorizedKeysPath(path, baseDir, config.authorizedKeysPath) - const authorizedKeysExists = yield* _(fs.exists(authorizedKeysPath)) + const isAuthorizedKeysExists = yield* _(fs.exists(authorizedKeysPath)) const sshKeyPath = yield* _(findSshPrivateKey(fs, path, process.cwd())) const editor = buildEditorSshAccess(config, sshKeyPath, ipAddress) @@ -198,7 +198,7 @@ export const resolveProjectSshAccess = ( sshCommand: buildSshCommand(config, sshKeyPath, ipAddress), editor, authorizedKeysPath, - authorizedKeysExists, + authorizedKeysExists: isAuthorizedKeysExists, ipAddress } }) diff --git a/packages/lib/src/usecases/state-repo.ts b/packages/lib/src/usecases/state-repo.ts index c58bcc69..e8f02f68 100644 --- a/packages/lib/src/usecases/state-repo.ts +++ b/packages/lib/src/usecases/state-repo.ts @@ -109,19 +109,19 @@ const autoSyncStateRaw = (message: string): Effect.Effect 0 ? isTruthyEnv(strictValue) : false + const isStrict = strictValue !== undefined && strictValue.trim().length > 0 ? isTruthyEnv(strictValue) : false const effect = stateSyncRaw(message) - if (strict) { + if (isStrict) { yield* _(effect) return } @@ -160,17 +160,17 @@ const autoPullStateRaw: Effect.Effect = Effect.gen(fu const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) const root = resolveStateRoot(path, process.cwd()) - const rootExists = yield* _(fs.exists(root)) - if (!rootExists) { + const isRootExists = yield* _(fs.exists(root)) + if (!isRootExists) { return } - const repoOk = yield* _(isGitRepo(root)) - if (!repoOk) { + const isRepoOk = yield* _(isGitRepo(root)) + if (!isRepoOk) { return } - const originOk = yield* _(hasOriginRemote(root)) - const enabled = isAutoPullEnabled(process.env[autoPullEnvKey], originOk) - if (!enabled) { + const isOriginOk = yield* _(hasOriginRemote(root)) + const isEnabled = isAutoPullEnabled(process.env[autoPullEnvKey], isOriginOk) + if (!isEnabled) { return } // CHANGE: abort any in-progress rebase if pull fails to prevent conflict markers @@ -296,8 +296,8 @@ const ensureOriginRemote = ( env: GitAuthEnv ): Effect.Effect => Effect.gen(function*(_) { - const setUrlExit = yield* _(gitExitCode(root, ["remote", "set-url", "origin", repoUrl], env)) - if (setUrlExit === successExitCode) { + const urlExitCode = yield* _(gitExitCode(root, ["remote", "set-url", "origin", repoUrl], env)) + if (urlExitCode === successExitCode) { return } yield* _(git(root, ["remote", "add", "origin", repoUrl], env)) diff --git a/packages/lib/src/usecases/state-repo/github-auth-state.ts b/packages/lib/src/usecases/state-repo/github-auth-state.ts index 9bdc1279..706c67e6 100644 --- a/packages/lib/src/usecases/state-repo/github-auth-state.ts +++ b/packages/lib/src/usecases/state-repo/github-auth-state.ts @@ -8,8 +8,8 @@ import { git, gitBaseEnv, gitCapture } from "./git-commands.js" import { isGithubHttpsRemote, normalizeGithubHttpsRemote, - requiresGithubAuthHint, - resolveGithubToken + resolveGithubToken, + shouldHintGithubAuth } from "./github-auth.js" export const githubAuthLoginHint = @@ -46,7 +46,7 @@ export const resolveStateGithubContext = ( return { originUrl, token, - authHintNeeded: requiresGithubAuthHint(originUrl, token) + authHintNeeded: shouldHintGithubAuth(originUrl, token) } }) @@ -55,15 +55,15 @@ export const shouldLogGithubAuthHintForStateSyncFailure = ( token: string | null, error: CommandFailedError | PlatformError ): boolean => - requiresGithubAuthHint(originUrl, token) || + shouldHintGithubAuth(originUrl, token) || (isGithubHttpsRemote(originUrl) && error._tag === "CommandFailedError" && error.command === "git fetch origin --prune") export const withGithubAuthHintOnFailure = ( effect: Effect.Effect, - enabled: boolean + isEnabled: boolean ): Effect.Effect => effect.pipe( - Effect.tapError(() => enabled ? Effect.logWarning(githubAuthLoginHint) : Effect.void) + Effect.tapError(() => isEnabled ? Effect.logWarning(githubAuthLoginHint) : Effect.void) ) diff --git a/packages/lib/src/usecases/state-repo/github-auth.ts b/packages/lib/src/usecases/state-repo/github-auth.ts index 7f068667..13dc36ab 100644 --- a/packages/lib/src/usecases/state-repo/github-auth.ts +++ b/packages/lib/src/usecases/state-repo/github-auth.ts @@ -53,7 +53,7 @@ export const normalizeGithubHttpsRemote = (url: string): string | null => { return parts === null ? null : `https://github.com/${parts.owner}/${parts.repo}.git` } -export const requiresGithubAuthHint = (originUrl: string, token: string | null | undefined): boolean => +export const shouldHintGithubAuth = (originUrl: string, token: string | null | undefined): boolean => isGithubHttpsRemote(originUrl) && (token?.trim() ?? "").length === 0 const resolveTokenFromProcessEnv = (): string | null => { @@ -120,8 +120,8 @@ export const resolveGithubToken = ( ] for (const envPath of candidates) { - const exists = yield* _(fs.exists(envPath)) - if (!exists) { + const isExists = yield* _(fs.exists(envPath)) + if (!isExists) { continue } const text = yield* _(fs.readFileString(envPath)) diff --git a/packages/lib/src/usecases/state-repo/gitignore.ts b/packages/lib/src/usecases/state-repo/gitignore.ts index c4c39dd2..b15278fd 100644 --- a/packages/lib/src/usecases/state-repo/gitignore.ts +++ b/packages/lib/src/usecases/state-repo/gitignore.ts @@ -75,8 +75,8 @@ export const ensureStateGitignore = ( ): Effect.Effect => Effect.gen(function*(_) { const gitignorePath = path.join(root, ".gitignore") - const exists = yield* _(fs.exists(gitignorePath)) - if (!exists) { + const isExists = yield* _(fs.exists(gitignorePath)) + if (!isExists) { yield* _(fs.writeFileString(gitignorePath, defaultStateGitignore)) return } diff --git a/packages/lib/src/usecases/state-repo/gitlab-auth.ts b/packages/lib/src/usecases/state-repo/gitlab-auth.ts index 7141db67..232f37cb 100644 --- a/packages/lib/src/usecases/state-repo/gitlab-auth.ts +++ b/packages/lib/src/usecases/state-repo/gitlab-auth.ts @@ -128,8 +128,8 @@ export const resolveGitlabToken = ( ] for (const envPath of candidates) { - const exists = yield* _(fs.exists(envPath)) - if (!exists) { + const isExists = yield* _(fs.exists(envPath)) + if (!isExists) { continue } const text = yield* _(fs.readFileString(envPath)) diff --git a/packages/lib/src/usecases/terminal-cursor.ts b/packages/lib/src/usecases/terminal-cursor.ts index a24c7ed3..73bc9c09 100644 --- a/packages/lib/src/usecases/terminal-cursor.ts +++ b/packages/lib/src/usecases/terminal-cursor.ts @@ -3,22 +3,22 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import * as FileSystem from "@effect/platform/FileSystem" import { Effect, Option, pipe } from "effect" -const terminalSaneEscape = "\u001B[0m" + // reset rendition - "\u001B[?25h" + // show cursor - "\u001B[?1l" + // normal cursor keys mode - "\u001B>" + // normal keypad mode - "\u001B[?1000l" + // disable mouse click tracking - "\u001B[?1002l" + // disable mouse drag tracking - "\u001B[?1003l" + // disable any-event mouse tracking - "\u001B[?1005l" + // disable UTF-8 mouse mode - "\u001B[?1006l" + // disable SGR mouse mode - "\u001B[?1015l" + // disable urxvt mouse mode - "\u001B[?1007l" + // disable alternate scroll mode - "\u001B[?1004l" + // disable focus reporting - "\u001B[?2004l" + // disable bracketed paste - "\u001B[>4;0m" + // disable xterm modifyOtherKeys - "\u001B[>4m" + // reset xterm modifyOtherKeys - "\u001B[" + // normal keypad mode + "\u{1B}[?1000l" + // disable mouse click tracking + "\u{1B}[?1002l" + // disable mouse drag tracking + "\u{1B}[?1003l" + // disable any-event mouse tracking + "\u{1B}[?1005l" + // disable UTF-8 mouse mode + "\u{1B}[?1006l" + // disable SGR mouse mode + "\u{1B}[?1015l" + // disable urxvt mouse mode + "\u{1B}[?1007l" + // disable alternate scroll mode + "\u{1B}[?1004l" + // disable focus reporting + "\u{1B}[?2004l" + // disable bracketed paste + "\u{1B}[>4;0m" + // disable xterm modifyOtherKeys + "\u{1B}[>4m" + // reset xterm modifyOtherKeys + "\u{1B}[ => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) - const wroteTty = yield* _(succeeds(fs.writeFileString(controllingTtyPath, terminalSaneEscape))) - if (wroteTty) { + const isWroteTty = yield* _(succeeds(fs.writeFileString(controllingTtyPath, terminalSaneEscape))) + if (isWroteTty) { return true } @@ -130,9 +130,9 @@ const repairInteractiveTerminal = (): Effect.Effect( Effect.gen(function*(_) { const snapshot = yield* _(snapshotTerminalState()) yield* _(ensureTerminalCursorVisible()) - return yield* _(use.pipe(Effect.ensuring(restoreTerminalState(snapshot)))) + const restore = restoreTerminalState(snapshot) + return yield* _(use.pipe(Effect.ensuring(restore))) }) diff --git a/packages/lib/src/usecases/terminal-sessions.ts b/packages/lib/src/usecases/terminal-sessions.ts index 286f29a1..b4c5d005 100644 --- a/packages/lib/src/usecases/terminal-sessions.ts +++ b/packages/lib/src/usecases/terminal-sessions.ts @@ -117,13 +117,13 @@ const listSessionsScriptAll = [ // QUOTE(ТЗ): "Можно ли запомнить какие процессы изначально запущены и просто их не отображать как терминалы?" // REF: user-request-2026-02-05-sessions-baseline // SOURCE: n/a -// FORMAT THEOREM: ∀p: default(p) ∧ ¬includeDefault → hidden(p) +// FORMAT THEOREM: ∀p: default(p) ∧ ¬shouldIncludeDefault → hidden(p) // PURITY: SHELL // EFFECT: Effect -// INVARIANT: includeDefault=true preserves full list +// INVARIANT: shouldIncludeDefault=true preserves full list // COMPLEXITY: O(n) where n = number of processes -const buildListSessionsScript = (includeDefault: boolean): string => { - if (includeDefault) { +const buildListSessionsScript = (shouldIncludeDefault: boolean): string => { + if (shouldIncludeDefault) { return listSessionsScriptAll } diff --git a/packages/lib/tests/usecases/state-repo-github-auth.test.ts b/packages/lib/tests/usecases/state-repo-github-auth.test.ts index 60ff0b02..2dd1055e 100644 --- a/packages/lib/tests/usecases/state-repo-github-auth.test.ts +++ b/packages/lib/tests/usecases/state-repo-github-auth.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest" import { isGithubHttpsRemote, normalizeGithubHttpsRemote, - requiresGithubAuthHint, + shouldHintGithubAuth, tryBuildGithubCompareUrl } from "../../src/usecases/state-repo/github-auth.js" @@ -25,9 +25,9 @@ describe("state-repo github auth helpers", () => { }) it("requires an auth hint only for GitHub https remotes without a usable token", () => { - expect(requiresGithubAuthHint("https://github.com/acme/demo.git", null)).toBe(true) - expect(requiresGithubAuthHint("https://github.com/acme/demo.git", " ")).toBe(true) - expect(requiresGithubAuthHint("https://github.com/acme/demo.git", "ghp_valid")).toBe(false) - expect(requiresGithubAuthHint("git@github.com:acme/demo.git", null)).toBe(false) + expect(shouldHintGithubAuth("https://github.com/acme/demo.git", null)).toBe(true) + expect(shouldHintGithubAuth("https://github.com/acme/demo.git", " ")).toBe(true) + expect(shouldHintGithubAuth("https://github.com/acme/demo.git", "ghp_valid")).toBe(false) + expect(shouldHintGithubAuth("git@github.com:acme/demo.git", null)).toBe(false) }) }) diff --git a/packages/terminal/package.json b/packages/terminal/package.json index a71e8722..3e0c916f 100644 --- a/packages/terminal/package.json +++ b/packages/terminal/package.json @@ -40,9 +40,9 @@ "@types/node": "^25.9.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", - "@vitest/coverage-v8": "^4.1.8", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", + "@vitest/coverage-v8": "^4.1.9", "@vitest/eslint-plugin": "^1.6.20", "eslint": "^10.5.0", "eslint-import-resolver-typescript": "^4.4.5", @@ -51,16 +51,16 @@ "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", - "eslint-plugin-unicorn": "^65.0.1", + "eslint-plugin-unicorn": "^67.0.0", "fast-check": "^4.8.0", "globals": "^17.6.0", "jscpd": "^5.0.9", "react-dom": "19.2.4", "typescript": "^6.0.3", - "typescript-eslint": "^8.61.0", + "typescript-eslint": "^8.61.1", "vite": "^8.0.16", "vite-tsconfig-paths": "^6.1.1", - "vitest": "^4.1.8" + "vitest": "^4.1.9" }, "exports": { ".": { diff --git a/packages/terminal/src/core/image-paste.ts b/packages/terminal/src/core/image-paste.ts index 811c70a1..c0991e8c 100644 --- a/packages/terminal/src/core/image-paste.ts +++ b/packages/terminal/src/core/image-paste.ts @@ -256,7 +256,7 @@ export const createTerminalImagePastePlan = ( if (fileName === null) { return invalidTerminalImagePaste(`Unsupported image type: ${payload.mediaType || "unknown"}.`) } - if (!Number.isFinite(payload.size) || payload.size <= 0) { + if (!Number.isFinite(payload.size) || payload.size === 0) { return invalidTerminalImagePaste("Image payload is empty.") } if (payload.size > terminalImagePasteMaxBytes) { diff --git a/packages/terminal/src/core/project-terminal-label.ts b/packages/terminal/src/core/project-terminal-label.ts index 17b41b63..605e0db1 100644 --- a/packages/terminal/src/core/project-terminal-label.ts +++ b/packages/terminal/src/core/project-terminal-label.ts @@ -17,14 +17,14 @@ const readPathPart = (value: string | undefined): string | null => { const splitGitHubRemotePath = (repoUrl: string): ReadonlyArray | null => { const trimmed = repoUrl.trim() const httpsPrefix = "https://github.com/" - const sshUrlPrefix = "ssh://git@github.com/" - const sshScpPrefix = "git@github.com:" if (trimmed.startsWith(httpsPrefix)) { return trimmed.slice(httpsPrefix.length).split("/").filter((part) => part.length > 0) } + const sshUrlPrefix = "ssh://git@github.com/" if (trimmed.startsWith(sshUrlPrefix)) { return trimmed.slice(sshUrlPrefix.length).split("/").filter((part) => part.length > 0) } + const sshScpPrefix = "git@github.com:" if (trimmed.startsWith(sshScpPrefix)) { return trimmed.slice(sshScpPrefix.length).split("/").filter((part) => part.length > 0) } diff --git a/packages/terminal/src/server/image-fetch.ts b/packages/terminal/src/server/image-fetch.ts index 36eb85ac..7ac70a7e 100644 --- a/packages/terminal/src/server/image-fetch.ts +++ b/packages/terminal/src/server/image-fetch.ts @@ -41,9 +41,9 @@ const supportedExtensionMediaTypes = new Map([ ]) const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F)}` -const deleteChar = String.fromCodePoint(0x7F) +const ruboutChar = String.fromCodePoint(0x7F) const invalidCharacterPattern = new RegExp( - String.raw`[\s${controlCharRange}${deleteChar}]`, + String.raw`[\s${controlCharRange}${ruboutChar}]`, "u" ) const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u diff --git a/packages/terminal/src/web/panel-terminal-styles.ts b/packages/terminal/src/web/panel-terminal-styles.ts index 4cef64d7..55c10fe7 100644 --- a/packages/terminal/src/web/panel-terminal-styles.ts +++ b/packages/terminal/src/web/panel-terminal-styles.ts @@ -12,9 +12,9 @@ const panelStyle: CSSProperties = { overflow: "hidden" } -export const terminalPanelStyle = (mobileMode: boolean, keyboardOpen: boolean): CSSProperties => ({ +export const terminalPanelStyle = (isMobileMode: boolean, isKeyboardOpen: boolean): CSSProperties => ({ ...panelStyle, - marginTop: mobileMode || keyboardOpen ? 0 : "8px" + marginTop: isMobileMode || isKeyboardOpen ? 0 : "8px" }) export const headerStyle: CSSProperties = { @@ -55,15 +55,15 @@ const bodyStyleKeyboardOpen: CSSProperties = { padding: 0 } -const terminalBodyStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => { - if (compactTypingMode) { +const terminalBodyStyle = (isCompactTypingMode: boolean, isMobileMode: boolean): CSSProperties => { + if (isCompactTypingMode) { return bodyStyleKeyboardOpen } - return mobileMode ? bodyStyleMobile : bodyStyle + return isMobileMode ? bodyStyleMobile : bodyStyle } -export const terminalBodyFrameStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => ({ - ...terminalBodyStyle(compactTypingMode, mobileMode), +export const terminalBodyFrameStyle = (isCompactTypingMode: boolean, isMobileMode: boolean): CSSProperties => ({ + ...terminalBodyStyle(isCompactTypingMode, isMobileMode), boxSizing: "border-box", overflow: "hidden", position: "relative" @@ -154,11 +154,11 @@ export const mobileArrowRowStyle: CSSProperties = { gridTemplateColumns: "repeat(4, minmax(0, 1fr))" } -export const mobileControlButtonStyle = (active = false): CSSProperties => ({ - background: active ? "#1d3550" : "#121a23", - border: `1px solid ${active ? "#78f0a3" : "#3a4652"}`, +export const mobileControlButtonStyle = (isActive = false): CSSProperties => ({ + background: isActive ? "#1d3550" : "#121a23", + border: `1px solid ${isActive ? "#78f0a3" : "#3a4652"}`, borderRadius: "8px", - color: active ? "#e8fff0" : "#d6e5f7", + color: isActive ? "#e8fff0" : "#d6e5f7", cursor: "pointer", font: "inherit", fontWeight: 600, diff --git a/packages/terminal/src/web/panel-terminal.tsx b/packages/terminal/src/web/panel-terminal.tsx index e673b885..9b43f22e 100644 --- a/packages/terminal/src/web/panel-terminal.tsx +++ b/packages/terminal/src/web/panel-terminal.tsx @@ -18,7 +18,7 @@ import { } from "./panel-terminal-styles.js" import type { TerminalPanelProps } from "./panel-terminal-types.js" import type { MobileTerminalKey } from "./terminal-mobile-controls.js" -import { resolveTerminalCompactHeaderMode, resolveTerminalTypingMode } from "./terminal-mobile-layout.js" +import { isTerminalCompactHeaderMode, isTerminalTypingMode } from "./terminal-mobile-layout.js" import { type TerminalConnectionState, type TerminalExitInfo, @@ -110,40 +110,44 @@ const useInlineImagePreviewState = ( terminalSessionId: string ): InlineImagePreviewState => { const inlineImagePreviewsEnabledRef = useRef(true) - const [inlineImagePreviewsEnabled, setInlineImagePreviewsEnabled] = useState(true) + const [isInlineImagePreviewsEnabled, setInlineImagePreviewsEnabled] = useState(true) useEffect(() => { inlineImagePreviewsEnabledRef.current = true setInlineImagePreviewsEnabled(true) }, [terminalSessionId]) const toggleInlineImagePreviews = useCallback(() => { setInlineImagePreviewsEnabled((current) => { - const next = !current - inlineImagePreviewsEnabledRef.current = next - return next + const isNext = !current + inlineImagePreviewsEnabledRef.current = isNext + return isNext }) retainTerminalFocus(runtimeRef.current) }, [runtimeRef]) - return { inlineImagePreviewsEnabled, inlineImagePreviewsEnabledRef, toggleInlineImagePreviews } + return { + inlineImagePreviewsEnabled: isInlineImagePreviewsEnabled, + inlineImagePreviewsEnabledRef, + toggleInlineImagePreviews + } } const useMobileCtrlKeyboard = ( { hostRef, + isMobileMode, mobileCtrlArmed, - mobileMode, runtimeRef, setMobileCtrlArmed }: { readonly hostRef: RefState + readonly isMobileMode: boolean readonly mobileCtrlArmed: boolean - readonly mobileMode: boolean readonly runtimeRef: RefState - readonly setMobileCtrlArmed: (armed: boolean) => void + readonly setMobileCtrlArmed: (isArmed: boolean) => void } ): void => { useEffect(() => { - if (!mobileMode || !mobileCtrlArmed || hostRef.current === null) { + if (!isMobileMode || !mobileCtrlArmed || hostRef.current === null) { return } const handleKeyDown = (event: KeyboardEvent): void => { @@ -158,27 +162,29 @@ const useMobileCtrlKeyboard = ( sendMobileCtrlEventInput(runtimeRef.current, event) } const host = hostRef.current - host.addEventListener("keydown", handleKeyDown, true) + host.addEventListener("keydown", handleKeyDown, { capture: true }) return () => { host.removeEventListener("keydown", handleKeyDown, true) } - }, [hostRef, mobileCtrlArmed, mobileMode, runtimeRef, setMobileCtrlArmed]) + }, [hostRef, isMobileMode, mobileCtrlArmed, runtimeRef, setMobileCtrlArmed]) } const useMobileTerminalControlState = ( - mobileMode: boolean, + isMobileMode: boolean, hostRef: RefState, runtimeRef: RefState ): MobileTerminalControlState => { - const [mobileControlsCollapsed, setMobileControlsCollapsed] = useState(false) - const [mobileCtrlArmed, setMobileCtrlArmed] = useState(false) + const [isMobileControlsCollapsed, setMobileControlsCollapsed] = useState(false) + const [isMobileCtrlArmed, setMobileCtrlArmed] = useState(false) useEffect(() => { - if (!mobileMode) { - setMobileControlsCollapsed(false) - setMobileCtrlArmed(false) + if (isMobileMode) { + return } - }, [mobileMode]) - useMobileCtrlKeyboard({ hostRef, mobileCtrlArmed, mobileMode, runtimeRef, setMobileCtrlArmed }) + + setMobileControlsCollapsed(false) + setMobileCtrlArmed(false) + }, [isMobileMode]) + useMobileCtrlKeyboard({ hostRef, isMobileMode, mobileCtrlArmed: isMobileCtrlArmed, runtimeRef, setMobileCtrlArmed }) const handleMobileKeyPress = useCallback((key: MobileTerminalKey) => { if (key === "ctrl-c") { setMobileCtrlArmed(false) @@ -195,7 +201,13 @@ const useMobileTerminalControlState = ( retainTerminalFocus(runtimeRef.current) }, [runtimeRef]) - return { handleMobileKeyPress, mobileControlsCollapsed, mobileCtrlArmed, toggleMobileControls, toggleMobileCtrl } + return { + handleMobileKeyPress, + mobileControlsCollapsed: isMobileControlsCollapsed, + mobileCtrlArmed: isMobileCtrlArmed, + toggleMobileControls, + toggleMobileCtrl + } } const useTerminalCloseActions = ( @@ -272,8 +284,8 @@ export const TerminalPanel = (props: TerminalPanelProps): JSX.Element => { const hostRef = useRef(null) const runtimeRef = useRef(null) const [status, setStatus] = useState(() => resolveInitialTerminalStatus(props.session)) - const compactHeaderMode = resolveTerminalCompactHeaderMode(props.mobileMode) - const compactTypingMode = resolveTerminalTypingMode(props.mobileMode, props.keyboardOpen) + const isCompactHeaderMode = isTerminalCompactHeaderMode(props.mobileMode) + const isCompactTypingMode = isTerminalTypingMode(props.mobileMode, props.keyboardOpen) const terminalSessionId = props.session.session.id const notifications = useTerminalNotificationHandlers(props) const inlineImageState = useInlineImagePreviewState(runtimeRef, terminalSessionId) @@ -301,8 +313,8 @@ export const TerminalPanel = (props: TerminalPanelProps): JSX.Element => { {...closeActions} {...inlineImageState} {...mobileControlState} - compactHeaderMode={compactHeaderMode} - compactTypingMode={compactTypingMode} + compactHeaderMode={isCompactHeaderMode} + compactTypingMode={isCompactTypingMode} hostRef={hostRef} status={status} /> diff --git a/packages/terminal/src/web/terminal-copy-interaction.ts b/packages/terminal/src/web/terminal-copy-interaction.ts index ba4dcbb1..b433c311 100644 --- a/packages/terminal/src/web/terminal-copy-interaction.ts +++ b/packages/terminal/src/web/terminal-copy-interaction.ts @@ -1,21 +1,21 @@ import { clearNativeBrowserCopyMenu, - prepareNativeBrowserCopyMenu, + didPrepareNativeBrowserCopyMenu, type TerminalCopyTextarea, type TerminalNativeCopyMenuHost } from "./terminal-copy-native-menu.js" import { + didWriteTerminalSelectionToClipboardData, hasActiveMouseTracking, isKeyboardCopyShortcut, shouldForceBrowserTerminalSelection, shouldLetBrowserHandleTerminalCopyShortcut, type TerminalCopyKeyboardEvent, - type TerminalMouseTrackingMode, - writeTerminalSelectionToClipboardData + type TerminalMouseTrackingMode } from "./terminal-copy-rules.js" import { createTerminalSelectionDragController, - forceTerminalSelectionModifier, + didForceTerminalSelectionModifier, suppressTerminalMouseReport, type TerminalCopyMouseEvent, type TerminalCopyMouseEventType, @@ -30,28 +30,28 @@ import { } from "./terminal-copy-selection-snapshot.js" export { + didWriteTerminalSelectionToClipboardData, shouldForceBrowserTerminalSelection, shouldForceTerminalSelectionContext, - shouldLetBrowserHandleTerminalCopyShortcut, - writeTerminalSelectionToClipboardData + shouldLetBrowserHandleTerminalCopyShortcut } from "./terminal-copy-rules.js" export type { TerminalCopyKeyboardEvent, TerminalMouseTrackingMode } from "./terminal-copy-rules.js" -export { forceTerminalSelectionModifier } from "./terminal-copy-selection-drag.js" +export { didForceTerminalSelectionModifier } from "./terminal-copy-selection-drag.js" type TerminalDisposable = { readonly dispose: () => void } -export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { +export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & TerminalSelectionRestoreTarget & { readonly attachCustomKeyEventHandler?: ( - handler: (event: TerminalCopyKeyboardEvent) => boolean + shouldHandleKeyEvent: (event: TerminalCopyKeyboardEvent) => boolean ) => void readonly modes: { readonly mouseTrackingMode: TerminalMouseTrackingMode } readonly onSelectionChange?: (handler: () => void) => TerminalDisposable readonly textarea?: TerminalCopyTextarea | undefined -} & TerminalSelectionRestoreTarget +} type TerminalCopyClipboardEvent = { readonly clipboardData: TerminalCopyClipboardData | null @@ -60,8 +60,16 @@ type TerminalCopyClipboardEvent = { } type TerminalCopyListenerRegistration = { - (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void - (type: TerminalCopyMouseEventType, listener: (event: TerminalCopyMouseEvent) => void, options: true): void + ( + type: "copy", + listener: (event: TerminalCopyClipboardEvent) => void, + isCapture: true | { readonly capture: true } + ): void + ( + type: TerminalCopyMouseEventType, + listener: (event: TerminalCopyMouseEvent) => void, + isCapture: true | { readonly capture: true } + ): void } type TerminalCopyInteractionHost = TerminalNativeCopyMenuHost & { @@ -87,21 +95,6 @@ class TerminalCopyInteractionController { private readonly selectionDrag: ReturnType private selectionChangeDisposable: TerminalDisposable | null = null - constructor(private readonly args: TerminalCopyInteractionArgs) { - this.selectionContext = new TerminalSelectionContextSnapshot(args.terminal) - this.selectionDrag = createTerminalSelectionDragController(args.host) - } - - readonly attach = (): { readonly dispose: () => void } => { - this.args.terminal.attachCustomKeyEventHandler?.(this.onTerminalKeyEvent) - this.selectionChangeDisposable = this.args.terminal.onSelectionChange?.(this.onTerminalSelectionChange) ?? null - this.args.host.addEventListener("mousedown", this.onMouseDown, true) - this.args.host.addEventListener("mouseup", this.onMouseUp, true) - this.args.host.addEventListener("contextmenu", this.onContextMenu, true) - this.args.host.addEventListener("copy", this.onCopy, true) - return { dispose: this.dispose } - } - private readonly shouldLetBrowserHandleCopyShortcut = (event: TerminalCopyKeyboardEvent): boolean => shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal) || (isKeyboardCopyShortcut(event) && this.selectionContext.has()) @@ -137,7 +130,7 @@ class TerminalCopyInteractionController { (this.selectionContext.has() || this.args.terminal.hasSelection()) private readonly prepareNativeBrowserCopyMenu = (event: TerminalCopyMouseEvent): boolean => - prepareNativeBrowserCopyMenu({ + didPrepareNativeBrowserCopyMenu({ event, host: this.args.host, selection: this.selectionContext.read(), @@ -155,7 +148,7 @@ class TerminalCopyInteractionController { if (!this.shouldProtectSelectionContext(event)) { return false } - forceTerminalSelectionModifier(event) + didForceTerminalSelectionModifier(event) if (this.args.terminal.hasSelection()) { this.selectionContext.refresh() } @@ -167,17 +160,17 @@ class TerminalCopyInteractionController { this.selectionContext.clear() this.clearNativeBrowserCopyMenu() } - const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, this.args.terminal) - const forceSelectionContext = this.shouldProtectSelectionContext(event) - if (!forceBrowserSelection && !forceSelectionContext) { + const isForceBrowserSelection = shouldForceBrowserTerminalSelection(event, this.args.terminal) + const isForceSelectionContext = this.shouldProtectSelectionContext(event) + if (!isForceBrowserSelection && !isForceSelectionContext) { if (isSecondaryMouseButton(event)) { this.selectionContext.clear() this.clearNativeBrowserCopyMenu() } return } - forceTerminalSelectionModifier(event) - if (forceSelectionContext) { + didForceTerminalSelectionModifier(event) + if (isForceSelectionContext) { if (this.args.terminal.hasSelection()) { this.selectionContext.refresh() } @@ -185,7 +178,7 @@ class TerminalCopyInteractionController { suppressTerminalMouseReport(event) return } - if (forceBrowserSelection) { + if (isForceBrowserSelection) { this.selectionDrag.start() } } @@ -210,7 +203,7 @@ class TerminalCopyInteractionController { if (!this.hasProtectedSelectionContext()) { return } - forceTerminalSelectionModifier(event) + didForceTerminalSelectionModifier(event) if (this.args.terminal.hasSelection()) { this.selectionContext.refresh() } @@ -219,9 +212,9 @@ class TerminalCopyInteractionController { } private readonly onCopy = (event: TerminalCopyClipboardEvent): void => { - const wroteSelection = writeTerminalSelectionToClipboardData(this.args.terminal, event.clipboardData) - const wroteSnapshot = wroteSelection ? false : this.selectionContext.writeToClipboardData(event.clipboardData) - if (!wroteSelection && !wroteSnapshot) { + const isWroteSelection = didWriteTerminalSelectionToClipboardData(this.args.terminal, event.clipboardData) + const isWroteSnapshot = isWroteSelection ? false : this.selectionContext.writeToClipboardData(event.clipboardData) + if (!isWroteSelection && !isWroteSnapshot) { return } this.selectionContext.clear() @@ -241,6 +234,21 @@ class TerminalCopyInteractionController { this.args.host.removeEventListener("contextmenu", this.onContextMenu, true) this.args.host.removeEventListener("copy", this.onCopy, true) } + + readonly attach = (): { readonly dispose: () => void } => { + this.args.terminal.attachCustomKeyEventHandler?.(this.onTerminalKeyEvent) + this.selectionChangeDisposable = this.args.terminal.onSelectionChange?.(this.onTerminalSelectionChange) ?? null + this.args.host.addEventListener("mousedown", this.onMouseDown, { capture: true }) + this.args.host.addEventListener("mouseup", this.onMouseUp, { capture: true }) + this.args.host.addEventListener("contextmenu", this.onContextMenu, { capture: true }) + this.args.host.addEventListener("copy", this.onCopy, { capture: true }) + return { dispose: this.dispose } + } + + constructor(private readonly args: TerminalCopyInteractionArgs) { + this.selectionContext = new TerminalSelectionContextSnapshot(args.terminal) + this.selectionDrag = createTerminalSelectionDragController(args.host) + } } export const attachTerminalCopyInteraction = ( diff --git a/packages/terminal/src/web/terminal-copy-native-menu.ts b/packages/terminal/src/web/terminal-copy-native-menu.ts index da3ea458..8a28914e 100644 --- a/packages/terminal/src/web/terminal-copy-native-menu.ts +++ b/packages/terminal/src/web/terminal-copy-native-menu.ts @@ -180,7 +180,7 @@ const resolveContextMenuScreenElement = ( // EFFECT: DOM textarea style/value/focus/select and layout reads // INVARIANT: false result leaves textarea unmodified by this function // COMPLEXITY: O(n)/O(1) -export const prepareNativeBrowserCopyMenu = ( +export const didPrepareNativeBrowserCopyMenu = ( { event, host, selection, textarea }: PrepareNativeBrowserCopyMenuArgs ): boolean => { const screenElement = resolveContextMenuScreenElement(host) diff --git a/packages/terminal/src/web/terminal-copy-rules.ts b/packages/terminal/src/web/terminal-copy-rules.ts index 81ceab12..890dd560 100644 --- a/packages/terminal/src/web/terminal-copy-rules.ts +++ b/packages/terminal/src/web/terminal-copy-rules.ts @@ -95,7 +95,7 @@ export const shouldForceTerminalSelectionContext = ( terminal: TerminalMouseTrackingTarget & TerminalSelectionTarget ): boolean => event.button === secondaryMouseButton && hasActiveMouseTracking(terminal) && terminal.hasSelection() -export const writeTerminalSelectionToClipboardData = ( +export const didWriteTerminalSelectionToClipboardData = ( terminal: TerminalSelectionTarget, clipboardData: TerminalCopyClipboardData | null ): boolean => { diff --git a/packages/terminal/src/web/terminal-copy-selection-drag.ts b/packages/terminal/src/web/terminal-copy-selection-drag.ts index d6e4c85c..17c9af39 100644 --- a/packages/terminal/src/web/terminal-copy-selection-drag.ts +++ b/packages/terminal/src/web/terminal-copy-selection-drag.ts @@ -27,7 +27,7 @@ export type TerminalCopyMouseEventType = "contextmenu" | "mousedown" | TerminalS type TerminalSelectionDragListenerRegistration = ( type: TerminalSelectionDragEventType, listener: (event: TerminalCopyMouseEvent) => void, - options: true + isCapture: true | { readonly capture: true } ) => void export type TerminalSelectionDragTarget = { @@ -57,7 +57,7 @@ const currentNavigatorPlatform = (): string => { const terminalSelectionModifier = (platform: string): keyof TerminalSelectionModifierEvent => macPlatformNames.has(platform) ? "altKey" : "shiftKey" -export const forceTerminalSelectionModifier = ( +export const didForceTerminalSelectionModifier = ( event: TerminalSelectionModifierEvent, platform: string = currentNavigatorPlatform() ): boolean => @@ -68,8 +68,6 @@ export const forceTerminalSelectionModifier = ( const optionalNumber = (value: number | undefined): number => value ?? 0 -const optionalBoolean = (value: boolean | undefined): boolean => value ?? false - const forcedTerminalMouseUpInit = (event: TerminalCopyMouseEvent): MouseEventInit => { const selectionModifier = terminalSelectionModifier(currentNavigatorPlatform()) return { @@ -80,9 +78,9 @@ const forcedTerminalMouseUpInit = (event: TerminalCopyMouseEvent): MouseEventIni cancelable: true, clientX: optionalNumber(event.clientX), clientY: optionalNumber(event.clientY), - ctrlKey: optionalBoolean(event.ctrlKey), + ctrlKey: event.ctrlKey ?? false, detail: optionalNumber(event.detail), - metaKey: optionalBoolean(event.metaKey), + metaKey: event.metaKey ?? false, screenX: optionalNumber(event.screenX), screenY: optionalNumber(event.screenY), shiftKey: selectionModifier === "shiftKey" ? true : event.shiftKey @@ -104,17 +102,17 @@ const copyMouseEventInitProperties = ( event: Event, init: MouseEventInit ): void => { - defineMouseEventProperty(event, "altKey", optionalBoolean(init.altKey)) + defineMouseEventProperty(event, "altKey", init.altKey ?? false) defineMouseEventProperty(event, "button", optionalNumber(init.button)) defineMouseEventProperty(event, "buttons", optionalNumber(init.buttons)) defineMouseEventProperty(event, "clientX", optionalNumber(init.clientX)) defineMouseEventProperty(event, "clientY", optionalNumber(init.clientY)) - defineMouseEventProperty(event, "ctrlKey", optionalBoolean(init.ctrlKey)) + defineMouseEventProperty(event, "ctrlKey", init.ctrlKey ?? false) defineMouseEventProperty(event, "detail", optionalNumber(init.detail)) - defineMouseEventProperty(event, "metaKey", optionalBoolean(init.metaKey)) + defineMouseEventProperty(event, "metaKey", init.metaKey ?? false) defineMouseEventProperty(event, "screenX", optionalNumber(init.screenX)) defineMouseEventProperty(event, "screenY", optionalNumber(init.screenY)) - defineMouseEventProperty(event, "shiftKey", optionalBoolean(init.shiftKey)) + defineMouseEventProperty(event, "shiftKey", init.shiftKey ?? false) } const createForcedTerminalMouseUpEvent = ( @@ -154,30 +152,9 @@ class TerminalSelectionDragControllerImpl implements TerminalSelectionDragContro private forcedSelectionDrag = false private selectionDragTarget: TerminalSelectionDragTarget | null = null - constructor(private readonly host: TerminalSelectionDragHost) {} - - readonly dispose = (): void => { - if (this.selectionDragTarget === null) { - this.forcedSelectionDrag = false - return - } - this.selectionDragTarget.removeEventListener("mousemove", this.onMouseMove, true) - this.selectionDragTarget.removeEventListener("mouseup", this.onMouseUp, true) - this.selectionDragTarget = null - this.forcedSelectionDrag = false - } - - readonly start = (): void => { - this.dispose() - this.forcedSelectionDrag = true - this.selectionDragTarget = resolveTerminalSelectionDragTarget(this.host) - this.selectionDragTarget.addEventListener("mousemove", this.onMouseMove, true) - this.selectionDragTarget.addEventListener("mouseup", this.onMouseUp, true) - } - private readonly onMouseMove = (event: TerminalCopyMouseEvent): void => { if (this.forcedSelectionDrag) { - forceTerminalSelectionModifier(event) + didForceTerminalSelectionModifier(event) } } @@ -186,7 +163,7 @@ class TerminalSelectionDragControllerImpl implements TerminalSelectionDragContro return } const target = this.selectionDragTarget - forceTerminalSelectionModifier(event) + didForceTerminalSelectionModifier(event) if (target?.dispatchEvent === undefined) { this.dispose() return @@ -195,6 +172,27 @@ class TerminalSelectionDragControllerImpl implements TerminalSelectionDragContro this.dispose() replayForcedTerminalMouseUp(target, event) } + + readonly dispose = (): void => { + if (this.selectionDragTarget === null) { + this.forcedSelectionDrag = false + return + } + this.selectionDragTarget.removeEventListener("mousemove", this.onMouseMove, true) + this.selectionDragTarget.removeEventListener("mouseup", this.onMouseUp, true) + this.selectionDragTarget = null + this.forcedSelectionDrag = false + } + + readonly start = (): void => { + this.dispose() + this.forcedSelectionDrag = true + this.selectionDragTarget = resolveTerminalSelectionDragTarget(this.host) + this.selectionDragTarget.addEventListener("mousemove", this.onMouseMove, { capture: true }) + this.selectionDragTarget.addEventListener("mouseup", this.onMouseUp, { capture: true }) + } + + constructor(private readonly host: TerminalSelectionDragHost) {} } export const createTerminalSelectionDragController = ( diff --git a/packages/terminal/src/web/terminal-copy-selection-snapshot.ts b/packages/terminal/src/web/terminal-copy-selection-snapshot.ts index 87d7f167..9e54663a 100644 --- a/packages/terminal/src/web/terminal-copy-selection-snapshot.ts +++ b/packages/terminal/src/web/terminal-copy-selection-snapshot.ts @@ -58,15 +58,15 @@ type TerminalSelectionNormalizedRangeSnapshot = { const terminalSelectionContextSnapshotTtlMs = 10_000 -const isNonNegativeInteger = (value: number): boolean => Number.isInteger(value) && value >= 0 +const isNonNegativeInteger = (value: number): boolean => Number.isSafeInteger(value) && value >= 0 -const isPositiveInteger = (value: number): boolean => Number.isInteger(value) && value > 0 +const isPositiveInteger = (value: number): boolean => Number.isSafeInteger(value) && value > 0 -const validTerminalSelectionColumn = (column: number, cols: number): boolean => +const isValidTerminalSelectionColumn = (column: number, cols: number): boolean => isNonNegativeInteger(column) && column <= cols -const validTerminalSelectionCell = (cell: TerminalSelectionCellPosition, cols: number): boolean => - validTerminalSelectionColumn(cell.x, cols) && isNonNegativeInteger(cell.y) +const isValidTerminalSelectionCell = (cell: TerminalSelectionCellPosition, cols: number): boolean => + isValidTerminalSelectionColumn(cell.x, cols) && isNonNegativeInteger(cell.y) const terminalSelectionCellCompare = ( left: TerminalSelectionCellPosition, @@ -119,13 +119,13 @@ const readTerminalSelectionActiveBuffer = ( } } -const validTerminalSelectionRange = ( +const isValidTerminalSelectionRange = ( range: TerminalSelectionBufferRange, cols: number, bufferLength: number ): boolean => - validTerminalSelectionCell(range.start, cols) && - validTerminalSelectionCell(range.end, cols) && + isValidTerminalSelectionCell(range.start, cols) && + isValidTerminalSelectionCell(range.end, cols) && range.end.y < bufferLength const readTerminalSelectionNormalizedRangeSnapshot = ( @@ -138,7 +138,7 @@ const readTerminalSelectionNormalizedRangeSnapshot = ( return null } const normalizedRange = normalizeTerminalSelectionRange(range) - if (!validTerminalSelectionRange(normalizedRange, cols, bufferLength)) { + if (!isValidTerminalSelectionRange(normalizedRange, cols, bufferLength)) { return null } const length = terminalSelectionRangeLength(normalizedRange, cols) @@ -188,7 +188,7 @@ const canRestoreTerminalSelection = ( snapshot.endRow < activeBuffer.length } -const restoreTerminalSelection = ( +const didRestoreTerminalSelection = ( terminal: TerminalSelectionRestoreTarget, snapshot: TerminalSelectionRestoreSnapshot ): boolean => { @@ -204,8 +204,6 @@ export class TerminalSelectionContextSnapshot { private selection = "" private timer: ReturnType | null = null - constructor(private readonly terminal: TerminalSelectionTarget & TerminalSelectionRestoreTarget) {} - readonly clear = (): void => { this.restoreSnapshot = null this.selection = "" @@ -260,7 +258,7 @@ export class TerminalSelectionContextSnapshot { if (this.restoreSnapshot === null) { return false } - return restoreTerminalSelection(this.terminal, this.restoreSnapshot) + return didRestoreTerminalSelection(this.terminal, this.restoreSnapshot) } readonly writeToClipboardData = (clipboardData: TerminalCopyClipboardData | null): boolean => { @@ -270,4 +268,6 @@ export class TerminalSelectionContextSnapshot { clipboardData.setData("text/plain", this.selection) return true } + + constructor(private readonly terminal: TerminalSelectionTarget & TerminalSelectionRestoreTarget) {} } diff --git a/packages/terminal/src/web/terminal-image-paste.ts b/packages/terminal/src/web/terminal-image-paste.ts index 8d7e12c3..e466a416 100644 --- a/packages/terminal/src/web/terminal-image-paste.ts +++ b/packages/terminal/src/web/terminal-image-paste.ts @@ -25,7 +25,7 @@ type TerminalPasteTrapState = { const terminalImagePasteMaxBytes = 10 * 1024 * 1024 const dataUrlBase64Marker = ";base64," -const nativeImagePasteControlInput = "\u0016" +const nativeImagePasteControlInput = "\u{16}" const nativeImagePasteSuppressWindowMs = 800 const pasteTrapRestoreDelayMs = 800 const supportedImageMediaTypes = new Set(["image/gif", "image/jpeg", "image/png", "image/webp"]) @@ -69,7 +69,7 @@ const hasImageFileTransfer = (dataTransfer: DataTransfer | null): boolean => { [...dataTransfer.files].some((file) => file.type.toLowerCase().startsWith("image/")) } -const socketCanSend = (socket: WebSocket | null): socket is WebSocket => +const canSocketSend = (socket: WebSocket | null): socket is WebSocket => socket !== null && socket.readyState === WebSocket.OPEN const sendTerminalInput = ( @@ -77,7 +77,7 @@ const sendTerminalInput = ( data: string ): void => { const socket = args.socketRef.current - if (!socketCanSend(socket)) { + if (!canSocketSend(socket)) { args.notifyMessage("Terminal is not connected; clipboard was not pasted.") return } @@ -101,7 +101,7 @@ const sendImagePasteMessage = ( base64: string ): void => { const socket = args.socketRef.current - if (!socketCanSend(socket)) { + if (!canSocketSend(socket)) { args.notifyMessage("Terminal is not connected; image was not pasted.") return } @@ -116,7 +116,7 @@ const readAndSendImageFile = ( args.notifyMessage(`Unsupported image type: ${file.type || "unknown"}.`) return } - if (file.size <= 0) { + if (file.size === 0) { args.notifyMessage("Image clipboard item is empty.") return } @@ -159,17 +159,17 @@ const handleImageFiles = ( const textFromTransfer = (dataTransfer: DataTransfer | null): string => dataTransfer?.getData("text/plain") ?? "" -const handleTerminalClipboardTransfer = ( +const didHandleTerminalClipboardTransfer = ( args: TerminalImagePasteArgs, dataTransfer: DataTransfer | null, - includeText: boolean + shouldIncludeText: boolean ): boolean => { const files = terminalImageFilesFromTransfer(dataTransfer) if (files.length > 0) { handleImageFiles(args, files) return true } - if (!includeText) { + if (!shouldIncludeText) { return false } const text = textFromTransfer(dataTransfer) @@ -197,17 +197,17 @@ export const createTerminalPasteGuard = ( currentTimeMillis: () => number = () => Date.now() ): TerminalPasteGuard => { let expiresAtMs = 0 - let pending = false + let isPending = false return { shouldSuppressTerminalInput: (data) => { - if (!pending || data !== nativeImagePasteControlInput || currentTimeMillis() > expiresAtMs) { + if (!isPending || data !== nativeImagePasteControlInput || currentTimeMillis() > expiresAtMs) { return false } - pending = false + isPending = false return true }, suppressNextNativeImagePaste: () => { - pending = true + isPending = true expiresAtMs = currentTimeMillis() + nativeImagePasteSuppressWindowMs } } @@ -216,7 +216,7 @@ export const createTerminalPasteGuard = ( export const isTerminalPasteShortcut = (event: TerminalPasteShortcutEvent): boolean => (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "v" -const eventTargetInsideHost = ( +const isEventTargetInsideHost = ( host: HTMLDivElement, event: Event ): boolean => event.target instanceof Node && host.contains(event.target) @@ -237,10 +237,12 @@ const createTerminalPasteTrap = (host: HTMLDivElement): HTMLTextAreaElement => { } const clearPasteTrapTimer = (state: TerminalPasteTrapState): void => { - if (state.restoreTimer !== null) { - clearTimeout(state.restoreTimer) - state.restoreTimer = null + if (state.restoreTimer === null) { + return } + + clearTimeout(state.restoreTimer) + state.restoreTimer = null } const deactivatePasteTrap = ( @@ -273,8 +275,8 @@ export const attachTerminalImagePaste = ( const pasteTrap = createTerminalPasteTrap(args.host) const trapState: TerminalPasteTrapState = { active: false, restoreTimer: null } const onPaste = (event: ClipboardEvent): void => { - const handled = handleTerminalClipboardTransfer(args, event.clipboardData, trapState.active) - if (!handled) { + const isHandled = didHandleTerminalClipboardTransfer(args, event.clipboardData, trapState.active) + if (!isHandled) { return } event.preventDefault() @@ -282,7 +284,7 @@ export const attachTerminalImagePaste = ( deactivatePasteTrap(args, trapState) } const onKeyDown = (event: KeyboardEvent): void => { - if (!isTerminalPasteShortcut(event) || !eventTargetInsideHost(args.host, event)) { + if (!isTerminalPasteShortcut(event) || !isEventTargetInsideHost(args.host, event)) { return } args.pasteGuard.suppressNextNativeImagePaste() @@ -299,16 +301,16 @@ export const attachTerminalImagePaste = ( handleImageFiles(args, files) } - args.host.addEventListener("paste", onPaste, true) - globalThis.addEventListener("keydown", onKeyDown, true) - args.host.addEventListener("dragover", handleTerminalImageDragOver, true) - args.host.addEventListener("drop", onDrop, true) + args.host.addEventListener("paste", onPaste, { capture: true }) + addEventListener("keydown", onKeyDown, { capture: true }) + args.host.addEventListener("dragover", handleTerminalImageDragOver, { capture: true }) + args.host.addEventListener("drop", onDrop, { capture: true }) return { dispose: () => { clearPasteTrapTimer(trapState) args.host.removeEventListener("paste", onPaste, true) - globalThis.removeEventListener("keydown", onKeyDown, true) + removeEventListener("keydown", onKeyDown, true) args.host.removeEventListener("dragover", handleTerminalImageDragOver, true) args.host.removeEventListener("drop", onDrop, true) pasteTrap.remove() diff --git a/packages/terminal/src/web/terminal-image-url.ts b/packages/terminal/src/web/terminal-image-url.ts index 3a8ed8d4..d0ef1b8c 100644 --- a/packages/terminal/src/web/terminal-image-url.ts +++ b/packages/terminal/src/web/terminal-image-url.ts @@ -9,5 +9,5 @@ export const resolveTerminalImageFetchUrl = (websocketPath: string, imagePath: s const apiUrl = resolveTerminalApiOriginUrl() apiUrl.pathname = `${apiUrl.pathname.replace(/\/$/u, "")}${resolveTerminalImageBasePath(websocketPath)}` apiUrl.searchParams.set("path", imagePath) - return apiUrl.toString() + return apiUrl.href } diff --git a/packages/terminal/src/web/terminal-inline-images-core.ts b/packages/terminal/src/web/terminal-inline-images-core.ts index 003e26f0..39b76f05 100644 --- a/packages/terminal/src/web/terminal-inline-images-core.ts +++ b/packages/terminal/src/web/terminal-inline-images-core.ts @@ -22,7 +22,7 @@ export type TerminalOutputSegmentWriteArgs = { const lineBreakPattern = /\r\n|\r|\n/gu -const endsWithLineBreak = (text: string): boolean => /\r\n$|\r$|\n$/u.test(text) +const hasTrailingLineBreak = (text: string): boolean => /\r\n$|\r$|\n$/u.test(text) export const splitTerminalInlineImageOutput = ( data: string @@ -45,7 +45,7 @@ export const splitTerminalInlineImageOutput = ( if (startIndex < data.length) { const text = data.slice(startIndex) segments.push({ - endedWithLineBreak: endsWithLineBreak(text), + endedWithLineBreak: hasTrailingLineBreak(text), imagePaths: detectTerminalImagePaths(text), text }) diff --git a/packages/terminal/src/web/terminal-inline-images.ts b/packages/terminal/src/web/terminal-inline-images.ts index 8581de9d..48c5d245 100644 --- a/packages/terminal/src/web/terminal-inline-images.ts +++ b/packages/terminal/src/web/terminal-inline-images.ts @@ -204,7 +204,7 @@ const renderInlineImageElement = ( element.replaceChildren(link) } -export const appendTerminalInlineImagePreview = ( +export const didAppendTerminalInlineImagePreview = ( terminal: Terminal, lifecycle: TerminalLifecycleState, entry: TerminalInlineImageEntry diff --git a/packages/terminal/src/web/terminal-mobile-controls.ts b/packages/terminal/src/web/terminal-mobile-controls.ts index b713e417..10a932f9 100644 --- a/packages/terminal/src/web/terminal-mobile-controls.ts +++ b/packages/terminal/src/web/terminal-mobile-controls.ts @@ -1,13 +1,13 @@ export type MobileTerminalKey = "escape" | "left" | "right" | "tab" | "up" | "down" | "ctrl-c" const mobileTerminalKeyInputs: Record = { - escape: "\u001B", - left: "\u001B[D", - right: "\u001B[C", + escape: "\u{1B}", + left: "\u{1B}[D", + right: "\u{1B}[C", tab: "\t", - up: "\u001B[A", - down: "\u001B[B", - "ctrl-c": "\u0003" + up: "\u{1B}[A", + down: "\u{1B}[B", + "ctrl-c": "\u{3}" } const modifierOnlyKeys = new Set([ @@ -22,12 +22,12 @@ const modifierOnlyKeys = new Set([ ]) const terminalControlSymbolInputs: Readonly> = { - "@": "\u0000", - "[": "\u001B", - "\\": "\u001C", - "]": "\u001D", - "^": "\u001E", - _: "\u001F" + "@": "\u{0}", + "[": "\u{1B}", + "\\": "\u{1C}", + "]": "\u{1D}", + "^": "\u{1E}", + _: "\u{1F}" } export const mobileTerminalKeyInput = (key: MobileTerminalKey): string => mobileTerminalKeyInputs[key] diff --git a/packages/terminal/src/web/terminal-mobile-layout.ts b/packages/terminal/src/web/terminal-mobile-layout.ts index 9c75da8c..7db4c1ec 100644 --- a/packages/terminal/src/web/terminal-mobile-layout.ts +++ b/packages/terminal/src/web/terminal-mobile-layout.ts @@ -1,7 +1,7 @@ -export const shouldShowTerminalTabs = (mobileMode: boolean, sessionCount: number): boolean => - !mobileMode || sessionCount > 1 +export const shouldShowTerminalTabs = (isMobileMode: boolean, sessionCount: number): boolean => + !isMobileMode || sessionCount > 1 -export const resolveTerminalCompactHeaderMode = (mobileMode: boolean): boolean => mobileMode +export const isTerminalCompactHeaderMode = (isMobileMode: boolean): boolean => isMobileMode -export const resolveTerminalTypingMode = (mobileMode: boolean, keyboardOpen: boolean): boolean => - mobileMode && keyboardOpen +export const isTerminalTypingMode = (isMobileMode: boolean, isKeyboardOpen: boolean): boolean => + isMobileMode && isKeyboardOpen diff --git a/packages/terminal/src/web/terminal-panel-cleanup-runtime.ts b/packages/terminal/src/web/terminal-panel-cleanup-runtime.ts index 345c6ebc..8677d49e 100644 --- a/packages/terminal/src/web/terminal-panel-cleanup-runtime.ts +++ b/packages/terminal/src/web/terminal-panel-cleanup-runtime.ts @@ -12,10 +12,12 @@ const closeSocket = (socket: WebSocket | null): void => { } const clearReconnectTimer = (args: TerminalCleanupArgs): void => { - if (args.lifecycle.reconnectTimer !== null) { - clearTimeout(args.lifecycle.reconnectTimer) - args.lifecycle.reconnectTimer = null + if (args.lifecycle.reconnectTimer === null) { + return } + + clearTimeout(args.lifecycle.reconnectTimer) + args.lifecycle.reconnectTimer = null } export const cleanupTerminalResources = ( diff --git a/packages/terminal/src/web/terminal-panel-inline-images-runtime.ts b/packages/terminal/src/web/terminal-panel-inline-images-runtime.ts index d0deb6c2..7a464bb7 100644 --- a/packages/terminal/src/web/terminal-panel-inline-images-runtime.ts +++ b/packages/terminal/src/web/terminal-panel-inline-images-runtime.ts @@ -9,9 +9,9 @@ import { writeTerminalOutputSegment } from "./terminal-inline-images-core.js" import { - appendTerminalInlineImagePreview, cachedTerminalInlineImageEntry, cacheTerminalInlineImageBlob, + didAppendTerminalInlineImagePreview, terminalInlineImageSpacer, unavailableTerminalInlineImageEntry } from "./terminal-inline-images.js" @@ -60,7 +60,7 @@ const readContentLength = (headers: Readonly> if (value === undefined) { return null } - const parsed = Number.parseInt(value, 10) + const parsed = Math.trunc(Number(value)) return Number.isFinite(parsed) && parsed >= 0 ? parsed : null } @@ -192,12 +192,12 @@ const writeInlineImagePreviewEntry = ( entry: TerminalInlineImageEntry, onComplete: () => void ): void => { - const appended = appendTerminalInlineImagePreview( + const isAppended = didAppendTerminalInlineImagePreview( handlers.terminal, handlers.lifecycle, entry ) - if (!appended) { + if (!isAppended) { onComplete() return } diff --git a/packages/terminal/src/web/terminal-panel-input.ts b/packages/terminal/src/web/terminal-panel-input.ts index 4fade175..99cc9282 100644 --- a/packages/terminal/src/web/terminal-panel-input.ts +++ b/packages/terminal/src/web/terminal-panel-input.ts @@ -16,7 +16,7 @@ type TerminalInputTarget = { readonly scrollToBottom: () => void } -const csiPrefix = "\u001B[" +const csiPrefix = "\u{1B}[" const x10MouseReportPrefix = `${csiPrefix}M` const x10MouseReportLength = 6 const sgrMouseReportBodyPattern = /^<\d+;\d+;\d+[Mm]$/u diff --git a/packages/terminal/src/web/terminal-panel-runtime-core.ts b/packages/terminal/src/web/terminal-panel-runtime-core.ts index f82267b9..ab5a6685 100644 --- a/packages/terminal/src/web/terminal-panel-runtime-core.ts +++ b/packages/terminal/src/web/terminal-panel-runtime-core.ts @@ -41,10 +41,12 @@ export const createLifecycleState = (): TerminalLifecycleState => ({ }) const clearReconnectTimer = (lifecycle: TerminalLifecycleState): void => { - if (lifecycle.reconnectTimer !== null) { - clearTimeout(lifecycle.reconnectTimer) - lifecycle.reconnectTimer = null + if (lifecycle.reconnectTimer === null) { + return } + + clearTimeout(lifecycle.reconnectTimer) + lifecycle.reconnectTimer = null } export const createTerminalRuntime = ( diff --git a/packages/terminal/src/web/terminal-panel-runtime.ts b/packages/terminal/src/web/terminal-panel-runtime.ts index d3cbda1c..954ad7ab 100644 --- a/packages/terminal/src/web/terminal-panel-runtime.ts +++ b/packages/terminal/src/web/terminal-panel-runtime.ts @@ -66,7 +66,7 @@ const createTerminalCleanup = ( wheelScrollDisposable.dispose() }, removeResize: () => { - globalThis.removeEventListener("resize", sendResize) + removeEventListener("resize", sendResize) globalThis.visualViewport?.removeEventListener("resize", sendResize) globalThis.visualViewport?.removeEventListener("scroll", sendResize) } @@ -83,7 +83,7 @@ const createConnectSocket = ( } const attachGlobalResizeListeners = (sendResize: () => void): void => { - globalThis.addEventListener("resize", sendResize) + addEventListener("resize", sendResize) globalThis.visualViewport?.addEventListener("resize", sendResize) globalThis.visualViewport?.addEventListener("scroll", sendResize) } diff --git a/packages/terminal/src/web/terminal-query-suppression.ts b/packages/terminal/src/web/terminal-query-suppression.ts index a6417b7b..0983dac1 100644 --- a/packages/terminal/src/web/terminal-query-suppression.ts +++ b/packages/terminal/src/web/terminal-query-suppression.ts @@ -21,15 +21,15 @@ export type TerminalQuerySuppressionTarget = { readonly parser: { readonly registerCsiHandler: ( id: FunctionIdentifier, - callback: (params: CsiParams) => boolean + shouldHandle: (params: CsiParams) => boolean ) => Disposable readonly registerDcsHandler: ( id: FunctionIdentifier, - callback: (data: string, params: CsiParams) => boolean + shouldHandle: (data: string, params: CsiParams) => boolean ) => Disposable readonly registerOscHandler: ( ident: number, - callback: (data: string) => boolean + shouldHandle: (data: string) => boolean ) => Disposable } } @@ -81,7 +81,7 @@ const shouldSuppressPrivateMode = ( (options.allowMouseTracking !== true && MOUSE_TRACKING_PRIVATE_MODES.has(mode)) || (options.suppressAlternateScreen === true && ALTERNATE_SCREEN_PRIVATE_MODES.has(mode)) -const containsSuppressedPrivateMode = ( +const hasSuppressedPrivateMode = ( params: CsiParams, options: TerminalQuerySuppressionOptions ): boolean => { @@ -116,7 +116,7 @@ const registerSelectivePrivateModeSuppressor = ( ): Disposable => terminal.parser.registerCsiHandler( { final, prefix: "?" }, - (params) => containsSuppressedPrivateMode(params, options) + (params) => hasSuppressedPrivateMode(params, options) ) export const installTerminalQuerySuppression = ( diff --git a/packages/terminal/src/web/terminal-wheel-scroll.ts b/packages/terminal/src/web/terminal-wheel-scroll.ts index d038615a..0e768faa 100644 --- a/packages/terminal/src/web/terminal-wheel-scroll.ts +++ b/packages/terminal/src/web/terminal-wheel-scroll.ts @@ -37,7 +37,7 @@ type TerminalWheelScrollTarget = { readonly removeEventListener: ( type: "wheel", listener: (event: TerminalWheelScrollEvent) => void, - options: boolean + isCapture: boolean ) => void } diff --git a/packages/terminal/src/web/terminal.ts b/packages/terminal/src/web/terminal.ts index ff229aa0..3d27a91e 100644 --- a/packages/terminal/src/web/terminal.ts +++ b/packages/terminal/src/web/terminal.ts @@ -37,7 +37,9 @@ export type TerminalApiBaseUrlResolver = () => string const defaultApiBaseUrl = "/api" -let terminalApiBaseUrlResolver: TerminalApiBaseUrlResolver | null = null +const terminalApiBaseUrlResolverState: { resolver: TerminalApiBaseUrlResolver | null } = { + resolver: null +} export const trimTerminalTrailingSlash = (value: string): string => { let next = value @@ -50,7 +52,7 @@ export const trimTerminalTrailingSlash = (value: string): string => { export const setTerminalApiBaseUrlResolver = ( resolver: TerminalApiBaseUrlResolver | null ): void => { - terminalApiBaseUrlResolver = resolver + terminalApiBaseUrlResolverState.resolver = resolver } type ProjectActiveTerminalSessionArgs = { @@ -165,8 +167,8 @@ export const buildProjectActiveTerminalSession = ( return { ...base, exitMessage: "SSH session ended.", - ...(onExit === undefined ? {} : { onExit }), - ...(onReady === undefined ? {} : { onReady }), + ...(onExit !== undefined && { onExit }), + ...(onReady !== undefined && { onReady }), pendingDeleteMessage: `Terminal session was closed before attach: ${projectDisplayName}.`, session, sessionPath: projectSshRoutePath(projectKey, session.id), @@ -209,7 +211,7 @@ export const buildPendingProjectActiveTerminalSession = ( return { ...base, exitMessage: "Pending SSH session closed.", - ...(onExit === undefined ? {} : { onExit }), + ...(onExit !== undefined && { onExit }), pendingConnection: { message: resolvedMessage, phase @@ -229,9 +231,10 @@ export const buildPendingProjectActiveTerminalSession = ( } export const resolveTerminalApiBaseUrl = (): string => { - return terminalApiBaseUrlResolver === null + const resolver = terminalApiBaseUrlResolverState.resolver + return resolver === null ? defaultApiBaseUrl - : trimTerminalTrailingSlash(terminalApiBaseUrlResolver()) + : trimTerminalTrailingSlash(resolver()) } export const resolveTerminalApiOriginUrl = (): URL => { @@ -239,7 +242,7 @@ export const resolveTerminalApiOriginUrl = (): URL => { if (configured.startsWith("http://") || configured.startsWith("https://")) { return new URL(configured) } - return new URL(configured, globalThis.location.origin) + return new URL(configured, location.origin) } export const resolveTerminalWebSocketUrl = (websocketPath: string, cols: number, rows: number): string => { @@ -248,7 +251,7 @@ export const resolveTerminalWebSocketUrl = (websocketPath: string, cols: number, apiUrl.pathname = `${apiUrl.pathname.replace(/\/$/u, "")}${websocketPath}` apiUrl.searchParams.set("cols", String(cols)) apiUrl.searchParams.set("rows", String(rows)) - return apiUrl.toString() + return apiUrl.href } export const parseTerminalServerMessage = (value: string): ParsedTerminalServerMessage | null => diff --git a/packages/terminal/tests/architecture/boundaries.test.ts b/packages/terminal/tests/architecture/boundaries.test.ts index beb6e2e9..c318aed4 100644 --- a/packages/terminal/tests/architecture/boundaries.test.ts +++ b/packages/terminal/tests/architecture/boundaries.test.ts @@ -90,7 +90,7 @@ const importsFrom = ( return importsFromSourceFile(sourceFile) }) -const bannedCoreImport = (source: string): boolean => +const isBannedCoreImport = (source: string): boolean => source.includes("/shell") || source.includes("/server") || source.includes("/web") || @@ -105,25 +105,27 @@ const boundaryViolationsForFile = ( ): Effect.Effect, PlatformError, FileSystem.FileSystem> => Effect.map(importsFrom(file), (sources) => sources - .filter((source) => bannedCoreImport(source)) + .filter((source) => isBannedCoreImport(source)) .map((source) => `${path.relative(sourceRootPath, file)} -> ${source}`)) describe("terminal package boundaries", () => { it.effect("keeps contracts and core free of runtime adapter imports", () => Effect.gen(function*(_) { const path = yield* _(Path.Path) + const contractsFiles = sourceFiles(`${sourceRootPath}/contracts`) + const coreFiles = sourceFiles(`${sourceRootPath}/core`) + const groupedFiles = Effect.all([contractsFiles, coreFiles]) const files = yield* _( Effect.map( - Effect.all([ - sourceFiles(`${sourceRootPath}/contracts`), - sourceFiles(`${sourceRootPath}/core`) - ]), - ([contractFiles, coreFiles]) => [...contractFiles, ...coreFiles] + groupedFiles, + ([contractFileList, coreFileList]) => [...contractFileList, ...coreFileList] ) ) + const fileViolationEffects = files.map((file) => boundaryViolationsForFile(path, file)) + const allViolations = Effect.all(fileViolationEffects) const violations = yield* _( Effect.map( - Effect.all(files.map((file) => boundaryViolationsForFile(path, file))), + allViolations, (fileViolations) => flattenReadonly(fileViolations) ) ) diff --git a/packages/terminal/tests/core/output-buffer.test.ts b/packages/terminal/tests/core/output-buffer.test.ts index 9881d513..1d29d806 100644 --- a/packages/terminal/tests/core/output-buffer.test.ts +++ b/packages/terminal/tests/core/output-buffer.test.ts @@ -56,9 +56,10 @@ describe("terminal output replay buffer", () => { it.effect("preserves replay budget and newest suffix for arbitrary chunks", () => Effect.sync(() => { + const chunkArbitrary = fc.array(fc.string({ maxLength: 32 }), { maxLength: 24 }) fc.assert( fc.property( - fc.array(fc.string({ maxLength: 32 }), { maxLength: 24 }), + chunkArbitrary, fc.integer({ min: 0, max: 256 }), (chunks, budget) => { const buffer = appendChunks(chunks, budget) diff --git a/packages/terminal/tests/web/fixtures/terminal-copy-interaction.ts b/packages/terminal/tests/web/fixtures/terminal-copy-interaction.ts index ba5449d6..d5886f5d 100644 --- a/packages/terminal/tests/web/fixtures/terminal-copy-interaction.ts +++ b/packages/terminal/tests/web/fixtures/terminal-copy-interaction.ts @@ -71,8 +71,6 @@ const isTerminalCopyTestMouseEvent = (event: Event): event is TerminalCopyTestMo "screenY" in event && "shiftKey" in event -const optionalBoolean = (value: boolean | undefined): boolean => value ?? false - const optionalNumber = (value: number | undefined): number => value ?? 0 const pressedButtonsByMouseButton: ReadonlyArray = [1, 4, 2] @@ -89,13 +87,13 @@ const resolveMouseOptions = ( button: number, options: Partial ): TerminalCopyTestMouseOptions => ({ - altKey: optionalBoolean(options.altKey), + altKey: options.altKey ?? false, buttons: options.buttons ?? defaultButtons(type, button), clientX: optionalNumber(options.clientX), clientY: optionalNumber(options.clientY), screenX: optionalNumber(options.screenX), screenY: optionalNumber(options.screenY), - shiftKey: optionalBoolean(options.shiftKey) + shiftKey: options.shiftKey ?? false }) export class FakeTerminalCopyMouseEvent extends Event { @@ -173,12 +171,20 @@ export class FakeTerminalCopyEventTarget { private listeners: Array = [] readonly dispatchedEvents: Array = [] - addEventListener(type: "copy", listener: TerminalCopyTestCopyListener, options: true): void - addEventListener(type: TerminalCopyTestMouseType, listener: TerminalCopyTestMouseListener, options: true): void + addEventListener( + type: "copy", + listener: TerminalCopyTestCopyListener, + isCapture: true | { readonly capture: true } + ): void + addEventListener( + type: TerminalCopyTestMouseType, + listener: TerminalCopyTestMouseListener, + isCapture: true | { readonly capture: true } + ): void addEventListener( type: TerminalCopyTestEventType, listener: TerminalCopyTestAnyListener, - _options: true + _isCapture: true | { readonly capture: true } ): void { if (isCopyTestListener(type, listener)) { this.listeners.push({ listener, type: "copy" }) @@ -193,12 +199,20 @@ export class FakeTerminalCopyEventTarget { this.listeners.push({ listener, phase: "bubble", type }) } - removeEventListener(type: "copy", listener: TerminalCopyTestCopyListener, options: true): void - removeEventListener(type: TerminalCopyTestMouseType, listener: TerminalCopyTestMouseListener, options: true): void + removeEventListener( + type: "copy", + listener: TerminalCopyTestCopyListener, + isCapture: true | { readonly capture: true } + ): void + removeEventListener( + type: TerminalCopyTestMouseType, + listener: TerminalCopyTestMouseListener, + isCapture: true | { readonly capture: true } + ): void removeEventListener( type: TerminalCopyTestEventType, listener: TerminalCopyTestAnyListener, - _options: true + _isCapture: true | { readonly capture: true } ): void { this.listeners = this.listeners.filter((entry) => entry.type !== type || entry.listener !== listener) } diff --git a/packages/terminal/tests/web/terminal-copy-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-interaction.test.ts index 00253b25..a7ab8f93 100644 --- a/packages/terminal/tests/web/terminal-copy-interaction.test.ts +++ b/packages/terminal/tests/web/terminal-copy-interaction.test.ts @@ -4,14 +4,14 @@ import * as fc from "fast-check" import { attachTerminalCopyInteraction, - forceTerminalSelectionModifier, + didForceTerminalSelectionModifier, + didWriteTerminalSelectionToClipboardData, shouldForceBrowserTerminalSelection, shouldForceTerminalSelectionContext, shouldLetBrowserHandleTerminalCopyShortcut, type TerminalCopyInteractionTerminal, type TerminalCopyKeyboardEvent, - type TerminalMouseTrackingMode, - writeTerminalSelectionToClipboardData + type TerminalMouseTrackingMode } from "../../src/web/terminal-copy-interaction.js" import { expectNoDragListeners, @@ -114,14 +114,14 @@ describe("terminal copy interaction", () => { it("uses Shift as the forced selection modifier on non-Mac platforms", () => { const event = { altKey: false, shiftKey: false } - expect(forceTerminalSelectionModifier(event, "Win32")).toBe(true) + expect(didForceTerminalSelectionModifier(event, "Win32")).toBe(true) expect(event).toEqual({ altKey: false, shiftKey: true }) }) it("uses Alt as the forced selection modifier on Mac platforms", () => { const event = { altKey: false, shiftKey: false } - expect(forceTerminalSelectionModifier(event, "MacIntel")).toBe(true) + expect(didForceTerminalSelectionModifier(event, "MacIntel")).toBe(true) expect(event).toEqual({ altKey: true, shiftKey: false }) }) @@ -133,7 +133,7 @@ describe("terminal copy interaction", () => { } } - expect(writeTerminalSelectionToClipboardData(terminalWithSelection("any", "line one\nline two"), clipboardData)) + expect(didWriteTerminalSelectionToClipboardData(terminalWithSelection("any", "line one\nline two"), clipboardData)) .toBe( true ) @@ -147,8 +147,8 @@ describe("terminal copy interaction", () => { } } - expect(writeTerminalSelectionToClipboardData(terminalWithSelection("any", ""), clipboardData)).toBe(false) - expect(writeTerminalSelectionToClipboardData(terminalWithSelection("any", "selected"), null)).toBe(false) + expect(didWriteTerminalSelectionToClipboardData(terminalWithSelection("any", ""), clipboardData)).toBe(false) + expect(didWriteTerminalSelectionToClipboardData(terminalWithSelection("any", "selected"), null)).toBe(false) }) it("forces the selection modifier through the full primary-button drag", () => { diff --git a/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts b/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts index 7232d053..79a07c9b 100644 --- a/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts +++ b/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts @@ -78,7 +78,8 @@ const removeSelectionHandler = ( ): void => { const handlerIndex = handlers.indexOf(handler) if (handlerIndex !== -1) { - handlers.splice(handlerIndex, 1) + handlers.copyWithin(handlerIndex, handlerIndex + 1) + handlers.length -= 1 } } @@ -161,17 +162,19 @@ const requireKeyHandler = ( ): (event: TerminalCopyKeyboardEvent) => boolean => keyHandlers[0] ?? expect.fail("Expected terminal copy key handler to be registered.") +const acquireSelectionRestoreHarness = Effect.acquireRelease( + Effect.sync(createSelectionRestoreHarness), + (harness) => + Effect.sync(() => { + harness.disposable.dispose() + }) +) + const withSelectionRestoreHarness = (assertion: (harness: SelectionRestoreHarness) => void): void => { Effect.runSync( Effect.scoped( Effect.flatMap( - Effect.acquireRelease( - Effect.sync(createSelectionRestoreHarness), - (harness) => - Effect.sync(() => { - harness.disposable.dispose() - }) - ), + acquireSelectionRestoreHarness, (harness) => Effect.sync(() => { assertion(harness) diff --git a/packages/terminal/tests/web/terminal-mobile-controls.test.ts b/packages/terminal/tests/web/terminal-mobile-controls.test.ts index 1d0c77b6..b21fa6e0 100644 --- a/packages/terminal/tests/web/terminal-mobile-controls.test.ts +++ b/packages/terminal/tests/web/terminal-mobile-controls.test.ts @@ -8,23 +8,23 @@ import { describe("terminal-mobile-controls", () => { it("maps mobile terminal buttons to terminal input sequences", () => { - expect(mobileTerminalKeyInput("escape")).toBe("\u001B") + expect(mobileTerminalKeyInput("escape")).toBe("\u{1B}") expect(mobileTerminalKeyInput("tab")).toBe("\t") - expect(mobileTerminalKeyInput("ctrl-c")).toBe("\u0003") - expect(mobileTerminalKeyInput("up")).toBe("\u001B[A") - expect(mobileTerminalKeyInput("down")).toBe("\u001B[B") - expect(mobileTerminalKeyInput("right")).toBe("\u001B[C") - expect(mobileTerminalKeyInput("left")).toBe("\u001B[D") + expect(mobileTerminalKeyInput("ctrl-c")).toBe("\u{3}") + expect(mobileTerminalKeyInput("up")).toBe("\u{1B}[A") + expect(mobileTerminalKeyInput("down")).toBe("\u{1B}[B") + expect(mobileTerminalKeyInput("right")).toBe("\u{1B}[C") + expect(mobileTerminalKeyInput("left")).toBe("\u{1B}[D") }) it("derives control characters from keyboard keys for one-shot ctrl", () => { - expect(terminalControlCharacterForKey("c")).toBe("\u0003") - expect(terminalControlCharacterForKey("C")).toBe("\u0003") - expect(terminalControlCharacterForKey("[")).toBe("\u001B") - expect(terminalControlCharacterForKey("\\")).toBe("\u001C") - expect(terminalControlCharacterForKey("]")).toBe("\u001D") - expect(terminalControlCharacterForKey("^")).toBe("\u001E") - expect(terminalControlCharacterForKey("_")).toBe("\u001F") + expect(terminalControlCharacterForKey("c")).toBe("\u{3}") + expect(terminalControlCharacterForKey("C")).toBe("\u{3}") + expect(terminalControlCharacterForKey("[")).toBe("\u{1B}") + expect(terminalControlCharacterForKey("\\")).toBe("\u{1C}") + expect(terminalControlCharacterForKey("]")).toBe("\u{1D}") + expect(terminalControlCharacterForKey("^")).toBe("\u{1E}") + expect(terminalControlCharacterForKey("_")).toBe("\u{1F}") expect(terminalControlCharacterForKey("?")).toBeNull() }) diff --git a/packages/terminal/tests/web/terminal-panel-runtime-core.test.ts b/packages/terminal/tests/web/terminal-panel-runtime-core.test.ts index 0391b5e9..5af2ce90 100644 --- a/packages/terminal/tests/web/terminal-panel-runtime-core.test.ts +++ b/packages/terminal/tests/web/terminal-panel-runtime-core.test.ts @@ -71,11 +71,11 @@ describe("terminal panel runtime core", () => { }) it("detects xterm mouse report input encodings", () => { - expect(isTerminalMouseReportInput("\u001B[M !!")).toBe(true) - expect(isTerminalMouseReportInput("\u001B[<64;10;5M")).toBe(true) - expect(isTerminalMouseReportInput("\u001B[<0;10;5m")).toBe(true) - expect(isTerminalMouseReportInput("\u001B[64;10;5M")).toBe(true) - expect(isTerminalMouseReportInput("\u001B[2M")).toBe(false) + expect(isTerminalMouseReportInput("\u{1B}[M !!")).toBe(true) + expect(isTerminalMouseReportInput("\u{1B}[<64;10;5M")).toBe(true) + expect(isTerminalMouseReportInput("\u{1B}[<0;10;5m")).toBe(true) + expect(isTerminalMouseReportInput("\u{1B}[64;10;5M")).toBe(true) + expect(isTerminalMouseReportInput("\u{1B}[2M")).toBe(false) expect(isTerminalMouseReportInput("a")).toBe(false) }) @@ -91,22 +91,22 @@ describe("terminal panel runtime core", () => { it("forwards arrow escape sequences as regular terminal input", () => { const { input, sent } = attachOpenTerminalInput() - input.emit("\u001B[C") - input.emit("\u001B[A") + input.emit("\u{1B}[C") + input.emit("\u{1B}[A") expect(input.state.scrolls).toBe(2) expect(sent).toEqual([ - JSON.stringify({ data: "\u001B[C", type: "input" }), - JSON.stringify({ data: "\u001B[A", type: "input" }) + JSON.stringify({ data: "\u{1B}[C", type: "input" }), + JSON.stringify({ data: "\u{1B}[A", type: "input" }) ]) }) it("keeps the viewport stable for terminal mouse click reports", () => { const { input, sent } = attachOpenTerminalInput() - input.emit("\u001B[<0;10;5M") + input.emit("\u{1B}[<0;10;5M") expect(input.state.scrolls).toBe(0) - expect(sent).toEqual([JSON.stringify({ data: "\u001B[<0;10;5M", type: "input" })]) + expect(sent).toEqual([JSON.stringify({ data: "\u{1B}[<0;10;5M", type: "input" })]) }) it("does not scroll or send input suppressed by the paste guard", () => { @@ -116,7 +116,7 @@ describe("terminal panel runtime core", () => { } const { input, sent } = attachOpenTerminalInput(pasteGuard) - input.emit("\u0016") + input.emit("\u{16}") expect(input.state.scrolls).toBe(0) expect(sent).toEqual([]) diff --git a/packages/terminal/tests/web/terminal-query-suppression.test.ts b/packages/terminal/tests/web/terminal-query-suppression.test.ts index 942f5b08..d9bf1ace 100644 --- a/packages/terminal/tests/web/terminal-query-suppression.test.ts +++ b/packages/terminal/tests/web/terminal-query-suppression.test.ts @@ -40,15 +40,15 @@ type MockTerminal = { readonly parser: { readonly registerCsiHandler: ( id: FunctionIdentifier, - cb: (params: CsiParams) => boolean + shouldHandle: (params: CsiParams) => boolean ) => { dispose: () => void } readonly registerDcsHandler: ( id: FunctionIdentifier, - cb: (data: string, params: CsiParams) => boolean + shouldHandle: (data: string, params: CsiParams) => boolean ) => { dispose: () => void } readonly registerOscHandler: ( id: number, - cb: (data: string) => boolean + shouldHandle: (data: string) => boolean ) => { dispose: () => void } } } @@ -143,12 +143,12 @@ const privateModeHandlers = ( const expectPrivateModesHandled = ( handlers: readonly [RegisteredCsiHandler, RegisteredCsiHandler], modes: ReadonlyArray, - handled: boolean + isHandled: boolean ): void => { - const [setHandler, resetHandler] = handlers + const [decSetHandler, resetHandler] = handlers for (const mode of modes) { - expect(setHandler.callback([mode])).toBe(handled) - expect(resetHandler.callback([mode])).toBe(handled) + expect(decSetHandler.callback([mode])).toBe(isHandled) + expect(resetHandler.callback([mode])).toBe(isHandled) } } @@ -228,9 +228,9 @@ describe("terminal query suppression", () => { it("blocks DEC private mode SET for focus reporting and mouse tracking", () => { const mock = createMockTerminal() installTerminalQuerySuppression(mock.terminal) - const setHandler = findCsi(mock, { final: "h", prefix: "?" }) + const decSetHandler = findCsi(mock, { final: "h", prefix: "?" }) for (const mode of SUPPRESSED_MODES) { - expect(setHandler.callback([mode])).toBe(true) + expect(decSetHandler.callback([mode])).toBe(true) } }) @@ -274,9 +274,9 @@ describe("terminal query suppression", () => { it("treats sub-parameters (nested arrays) as the parameter head", () => { const mock = createMockTerminal() installTerminalQuerySuppression(mock.terminal) - const setHandler = findCsi(mock, { final: "h", prefix: "?" }) - expect(setHandler.callback([[1004, 0]])).toBe(true) - expect(setHandler.callback([[25, 0]])).toBe(false) + const decSetHandler = findCsi(mock, { final: "h", prefix: "?" }) + expect(decSetHandler.callback([[1004, 0]])).toBe(true) + expect(decSetHandler.callback([[25, 0]])).toBe(false) }) it("exposes the suppressed private mode set", () => { diff --git a/packages/terminal/tests/web/terminal-state.test.ts b/packages/terminal/tests/web/terminal-state.test.ts index 6b959703..f7d92163 100644 --- a/packages/terminal/tests/web/terminal-state.test.ts +++ b/packages/terminal/tests/web/terminal-state.test.ts @@ -106,9 +106,10 @@ describe("terminal workspace state", () => { }) it("removes the active session and selects the right neighbor first", () => { + const stateWithA = addTerminalSessionState(emptyTerminalWorkspaceState, makeSession("a")) const state = addTerminalSessionState( addTerminalSessionState( - addTerminalSessionState(emptyTerminalWorkspaceState, makeSession("a")), + stateWithA, makeSession("b") ), makeSession("c") diff --git a/packages/terminal/tests/web/terminal-wheel-scroll.test.ts b/packages/terminal/tests/web/terminal-wheel-scroll.test.ts index 5d919b6e..67b1060b 100644 --- a/packages/terminal/tests/web/terminal-wheel-scroll.test.ts +++ b/packages/terminal/tests/web/terminal-wheel-scroll.test.ts @@ -73,11 +73,11 @@ const createWheelHost = () => { removeEventListener: ( type: "wheel", next: TestWheelListener, - options: boolean + isCapture: boolean ) => { expect(type).toBe("wheel") expect(next).toBe(listener) - expect(options).toBe(true) + expect(isCapture).toBe(true) state.removed += 1 } }, @@ -200,7 +200,7 @@ describe("terminal wheel scroll", () => { })).toEqual({ lines: -2, nextPixelDeltaY: 0 }) expect(resolveTerminalWheelScrollDelta({ deltaMode: 0, - deltaY: Number.NaN, + deltaY: NaN, previousPixelDeltaY: 0, rows: 24 })).toEqual({ lines: 0, nextPixelDeltaY: 0 }) diff --git a/packages/terminal/tests/web/terminal.test.ts b/packages/terminal/tests/web/terminal.test.ts index 8594bb10..9f21ad0c 100644 --- a/packages/terminal/tests/web/terminal.test.ts +++ b/packages/terminal/tests/web/terminal.test.ts @@ -8,8 +8,8 @@ import { } from "../../src/web/terminal-image-paste.js" import { resolveTerminalImageBasePath, resolveTerminalImageFetchUrl } from "../../src/web/terminal-image-url.js" import { - resolveTerminalCompactHeaderMode, - resolveTerminalTypingMode, + isTerminalCompactHeaderMode, + isTerminalTypingMode, shouldShowTerminalTabs } from "../../src/web/terminal-mobile-layout.js" import { resolveTerminalReconnectDelay } from "../../src/web/terminal-reconnect.js" @@ -104,7 +104,7 @@ describe("browser terminal helpers", () => { ...terminalTitleById([ { createdAt: "2026-04-08T10:02:00.000Z", id: "session-b" }, { createdAt: "2026-04-08T10:01:00.000Z", id: "session-a" } - ]).entries() + ]) ] ).toEqual([ ["session-a", "Terminal 1"], @@ -144,23 +144,23 @@ describe("browser terminal helpers", () => { let currentTimeMillis = 1000 const pasteGuard = createTerminalPasteGuard(() => currentTimeMillis) - expect(pasteGuard.shouldSuppressTerminalInput("\u0016")).toBe(false) + expect(pasteGuard.shouldSuppressTerminalInput("\u{16}")).toBe(false) pasteGuard.suppressNextNativeImagePaste() expect(pasteGuard.shouldSuppressTerminalInput("text")).toBe(false) - expect(pasteGuard.shouldSuppressTerminalInput("\u0016")).toBe(true) - expect(pasteGuard.shouldSuppressTerminalInput("\u0016")).toBe(false) + expect(pasteGuard.shouldSuppressTerminalInput("\u{16}")).toBe(true) + expect(pasteGuard.shouldSuppressTerminalInput("\u{16}")).toBe(false) pasteGuard.suppressNextNativeImagePaste() currentTimeMillis = 2000 - expect(pasteGuard.shouldSuppressTerminalInput("\u0016")).toBe(false) + expect(pasteGuard.shouldSuppressTerminalInput("\u{16}")).toBe(false) }) it("uses compact terminal chrome on mobile and only enables typing mode with the keyboard open", () => { - expect(resolveTerminalCompactHeaderMode(true)).toBe(true) - expect(resolveTerminalCompactHeaderMode(false)).toBe(false) - expect(resolveTerminalTypingMode(true, true)).toBe(true) - expect(resolveTerminalTypingMode(true, false)).toBe(false) - expect(resolveTerminalTypingMode(false, true)).toBe(false) + expect(isTerminalCompactHeaderMode(true)).toBe(true) + expect(isTerminalCompactHeaderMode(false)).toBe(false) + expect(isTerminalTypingMode(true, true)).toBe(true) + expect(isTerminalTypingMode(true, false)).toBe(false) + expect(isTerminalTypingMode(false, true)).toBe(false) }) it("hides terminal tabs for a single mobile session and keeps them for multi-session or desktop layouts", () => {