diff --git a/.gitignore b/.gitignore index 35394555..e9d1b52a 100755 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ lerna-debug.log* # Build outputs dist/ +dist-server/ dist-ssr/ build/ out/ @@ -138,4 +139,4 @@ tasks/ !src/i18n/locales/de/tasks.json # Git worktrees -.worktrees/ \ No newline at end of file +.worktrees/ diff --git a/eslint.config.js b/eslint.config.js index 5645f2e8..3ef25d92 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,9 @@ import tseslint from "typescript-eslint"; import react from "eslint-plugin-react"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; -import importX from "eslint-plugin-import-x"; +import { createNodeResolver, importX } from "eslint-plugin-import-x"; +import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript"; +import boundaries from "eslint-plugin-boundaries"; import tailwindcss from "eslint-plugin-tailwindcss"; import unusedImports from "eslint-plugin-unused-imports"; import globals from "globals"; @@ -82,7 +84,7 @@ export default tseslint.config( "sibling", "index", ], - "newlines-between": "never", + "newlines-between": "always", }, ], @@ -98,5 +100,131 @@ export default tseslint.config( "no-control-regex": "off", "no-useless-escape": "off", }, + }, + { + files: ["server/**/*.{js,ts}"], // apply this block only to backend source files + ignores: ["server/**/*.d.ts"], // skip generated declaration files in backend linting + plugins: { + boundaries, // enforce backend architecture boundaries (module-to-module contracts) + "import-x": importX, // keep import hygiene rules (duplicates, unresolved paths, etc.) + "unused-imports": unusedImports, // remove dead imports/variables from backend files + }, + languageOptions: { + parser: tseslint.parser, // parse both JS and TS syntax in backend files + parserOptions: { + ecmaVersion: "latest", // support modern ECMAScript syntax in backend code + sourceType: "module", // treat backend files as ESM modules + }, + globals: { + ...globals.node, // expose Node.js globals such as process, Buffer, and __dirname equivalents + }, + }, + settings: { + "boundaries/include": ["server/**/*.{js,ts}"], // only analyze dependency boundaries inside backend files + "import/resolver": { + // boundaries resolves imports through eslint-module-utils, which reads the classic + // import/resolver setting instead of import-x/resolver-next. + typescript: { + project: ["server/tsconfig.json"], // resolve backend aliases using the canonical backend tsconfig + alwaysTryTypes: true, // keep normal TS package/type resolution working alongside aliases + }, + node: { + extensions: [".mjs", ".cjs", ".js", ".json", ".node", ".ts", ".tsx"], // preserve Node-style fallback resolution for plain files + }, + }, + "import-x/resolver-next": [ + // ESLint's import plugin does not read tsconfig path aliases on its own. + // This resolver teaches import-x how to understand the backend-only "@/*" + // mapping defined in server/tsconfig.json, which fixes false no-unresolved errors in editors. + createTypeScriptImportResolver({ + project: ["server/tsconfig.json"], // point the resolver at the canonical backend tsconfig instead of the frontend one + alwaysTryTypes: true, // keep standard TypeScript package resolution working while backend aliases are enabled + }), + // Keep Node-style resolution available for normal package imports and plain relative JS files. + // The TypeScript resolver handles aliases, while the Node resolver preserves the expected fallback behavior. + createNodeResolver({ + extensions: [".mjs", ".cjs", ".js", ".json", ".node", ".ts", ".tsx"], + }), + ], + "boundaries/elements": [ + { + type: "backend-shared-types", // shared backend type contract that modules may consume without creating runtime coupling + pattern: ["server/shared/types.{js,ts}"], // support the current shared types path + mode: "file", // treat the types file itself as the boundary element instead of the whole folder + }, + { + type: "backend-module", // logical element name used by boundaries rules below + pattern: "server/modules/*", // each direct folder in server/modules is treated as one module boundary + mode: "folder", // classify dependencies at folder-module level (not per individual file) + capture: ["moduleName"], // capture the module folder name for messages/debugging/template use + }, + ], + }, + rules: { + // --- Unused imports/vars (backend) --- + "unused-imports/no-unused-imports": "warn", // warn when imports are not used so they can be cleaned up + "unused-imports/no-unused-vars": "off", // keep backend signal focused on dead imports instead of local unused variables + + // --- Import hygiene (backend) --- + "import-x/no-duplicates": "warn", // prevent duplicate import lines from the same module + "import-x/order": [ + "warn", // keep backend import grouping/order consistent with the frontend config + { + groups: [ + "builtin", // Node built-ins such as fs, path, and url come first + "external", // third-party packages come after built-ins + "internal", // aliased internal imports such as @/... come next + "parent", // ../ imports come after aliased internal imports + "sibling", // ./foo imports come after parent imports + "index", // bare ./ imports stay last + ], + "newlines-between": "always", // require a blank line between import groups in backend files too + }, + ], + "import-x/no-unresolved": "error", // fail when an import path cannot be resolved + "import-x/no-useless-path-segments": "warn", // prefer cleaner paths (remove redundant ./ and ../ segments) + "import-x/no-absolute-path": "error", // disallow absolute filesystem imports in backend files + + // --- General safety/style (backend) --- + eqeqeq: ["warn", "always", { null: "ignore" }], // avoid accidental coercion while still allowing x == null checks + + // --- Architecture boundaries (backend modules) --- + "boundaries/dependencies": [ + "error", // treat architecture violations as lint errors + { + default: "allow", // allow normal imports unless a rule below explicitly disallows them + checkInternals: false, // do not apply these cross-module rules to imports inside the same module + rules: [ + { + from: { type: "backend-module" }, // modules may depend on the shared types contract only as erased type-only imports + to: { type: "backend-shared-types" }, + disallow: { + dependency: { kind: ["value", "typeof"] }, + }, // block runtime imports so shared types stay a compile-time contract instead of a hidden shared module + message: + "Backend modules may only use `import type` when importing from server/shared/types.ts (or server/types.ts).", + }, + { + to: { type: "backend-module" }, // when importing anything that belongs to another backend module + disallow: { to: { internalPath: "**" } }, // block all direct/deep imports into module internals by default + message: + "Cross-module imports must go through that module's barrel file (server/modules//index.ts or index.js).", // explicit error message for architecture violations + }, + { + to: { type: "backend-module" }, // same target scope as the disallow rule above + allow: { + to: { + internalPath: [ + "index", // allow extensionless barrel imports resolved as module root index + "index.{js,mjs,cjs,ts,tsx}", // allow explicit index.* barrel file imports + ], + }, + }, // re-allow only public module entry points (barrel files) + }, + ], + }, + ], + "boundaries/no-unknown": "error", // fail fast if boundaries cannot classify a dependency, which prevents silent rule bypasses + }, } ); diff --git a/package-lock.json b/package-lock.json index 21bee5b6..0905f759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,7 @@ "ws": "^8.14.2" }, "bin": { - "cloudcli": "server/cli.js" + "cloudcli": "dist-server/server/cli.js" }, "devDependencies": { "@commitlint/cli": "^20.4.3", @@ -84,6 +84,8 @@ "autoprefixer": "^10.4.16", "concurrently": "^8.2.2", "eslint": "^9.39.3", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-boundaries": "^6.0.2", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", @@ -98,6 +100,8 @@ "release-it": "^19.0.5", "sharp": "^0.34.2", "tailwindcss": "^3.4.0", + "tsc-alias": "^1.8.16", + "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "vite": "^7.0.4" @@ -443,6 +447,23 @@ "node": ">=6.9.0" } }, + "node_modules/@boundaries/elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-2.0.1.tgz", + "integrity": "sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-import-resolver-node": "0.3.9", + "eslint-module-utils": "2.12.1", + "handlebars": "4.7.9", + "is-core-module": "2.16.1", + "micromatch": "4.0.8" + }, + "engines": { + "node": ">=18.18" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.18.6", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz", @@ -4815,6 +4836,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -6673,6 +6704,19 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -7217,6 +7261,112 @@ } } }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", + "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-boundaries": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-boundaries/-/eslint-plugin-boundaries-6.0.2.tgz", + "integrity": "sha512-wSHgiYeMEbziP91lH0UQ9oslgF2djG1x+LV9z/qO19ggMKZaCB8pKIGePHAY91eLF4EAgpsxQk8MRSFGRPfPzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@boundaries/elements": "2.0.1", + "chalk": "4.1.2", + "eslint-import-resolver-node": "0.3.9", + "eslint-module-utils": "2.12.1", + "handlebars": "4.7.9", + "micromatch": "4.0.8" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, "node_modules/eslint-plugin-import-x": { "version": "4.16.1", "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.1.tgz", @@ -8519,6 +8669,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -8554,9 +8725,9 @@ } }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9446,6 +9617,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -12026,6 +12220,20 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/mylas": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -12989,6 +13197,16 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -13051,6 +13269,19 @@ "pathe": "^2.0.3" } }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13412,6 +13643,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -15173,6 +15414,16 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", @@ -16479,12 +16730,599 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/tsc-alias": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz", + "integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/tsc-alias/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tsc-alias/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/tsc-alias/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tsc-alias/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 2a80d5a3..b4d2653f 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,15 @@ "version": "1.29.2", "description": "A web-based UI for Claude Code CLI", "type": "module", - "main": "server/index.js", + "main": "dist-server/server/index.js", "bin": { - "cloudcli": "server/cli.js" + "cloudcli": "dist-server/server/cli.js" }, "files": [ "server/", "shared/", "dist/", + "dist-server/", "scripts/", "README.md" ], @@ -23,14 +24,19 @@ "url": "https://github.com/siteboon/claudecodeui/issues" }, "scripts": { - "dev": "concurrently --kill-others \"npm run server\" \"npm run client\"", - "server": "node server/index.js", + "dev": "concurrently --kill-others \"npm run server:dev\" \"npm run client\"", + "server": "node dist-server/server/index.js", + "server:dev": "tsx --tsconfig server/tsconfig.json server/index.js", + "server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js", "client": "vite", - "build": "vite build", + "build": "npm run build:client && npm run build:server", + "build:client": "vite build", + "prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"", + "build:server": "tsc -p server/tsconfig.json && tsc-alias -p server/tsconfig.json", "preview": "vite preview", - "typecheck": "tsc --noEmit -p tsconfig.json", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p server/tsconfig.json", + "lint": "eslint src/ server/", + "lint:fix": "eslint src/ server/ --fix", "start": "npm run build && npm run server", "release": "./release.sh", "prepublishOnly": "npm run build", @@ -130,6 +136,8 @@ "autoprefixer": "^10.4.16", "concurrently": "^8.2.2", "eslint": "^9.39.3", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-boundaries": "^6.0.2", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", @@ -144,11 +152,14 @@ "release-it": "^19.0.5", "sharp": "^0.34.2", "tailwindcss": "^3.4.0", + "tsc-alias": "^1.8.16", + "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "vite": "^7.0.4" }, "lint-staged": { - "src/**/*.{ts,tsx,js,jsx}": "eslint" + "src/**/*.{ts,tsx,js,jsx}": "eslint", + "server/**/*.{js,ts}": "eslint" } } diff --git a/redirect-package/bin.js b/redirect-package/bin.js index 2f31c0c6..fc33eef1 100644 --- a/redirect-package/bin.js +++ b/redirect-package/bin.js @@ -1,2 +1,2 @@ #!/usr/bin/env node -import('@cloudcli-ai/cloudcli/server/cli.js'); +import('@cloudcli-ai/cloudcli/dist-server/server/cli.js'); diff --git a/server/cli.js b/server/cli.js index 3c0d1d4f..7551afc4 100755 --- a/server/cli.js +++ b/server/cli.js @@ -16,11 +16,12 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; +import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +const __dirname = getModuleDir(import.meta.url); +// The CLI is compiled into dist-server/server, but it still needs to read the top-level +// package.json and .env file. Resolving the app root once keeps those lookups stable. +const APP_ROOT = findAppRoot(__dirname); // ANSI color codes for terminal output const colors = { @@ -50,13 +51,16 @@ const c = { }; // Load package.json for version info -const packageJsonPath = path.join(__dirname, '../package.json'); +const packageJsonPath = path.join(APP_ROOT, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +// Match the runtime fallback in load-env.js so "cloudcli status" reports the same default +// database location that the backend will actually use when no DATABASE_PATH is configured. +const DEFAULT_DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db'); // Load environment variables from .env file if it exists function loadEnvFile() { try { - const envPath = path.join(__dirname, '../.env'); + const envPath = path.join(APP_ROOT, '.env'); const envFile = fs.readFileSync(envPath, 'utf8'); envFile.split('\n').forEach(line => { const trimmedLine = line.trim(); @@ -75,12 +79,12 @@ function loadEnvFile() { // Get the database path (same logic as db.js) function getDatabasePath() { loadEnvFile(); - return process.env.DATABASE_PATH || path.join(__dirname, 'database', 'auth.db'); + return process.env.DATABASE_PATH || DEFAULT_DATABASE_PATH; } // Get the installation directory function getInstallDir() { - return path.join(__dirname, '..'); + return APP_ROOT; } // Show status command @@ -124,7 +128,7 @@ function showStatus() { console.log(` Status: ${projectsExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found')}`); // Config file location - const envFilePath = path.join(__dirname, '../.env'); + const envFilePath = path.join(APP_ROOT, '.env'); const envExists = fs.existsSync(envFilePath); console.log(`\n${c.info('[INFO]')} Configuration File:`); console.log(` ${c.dim(envFilePath)}`); diff --git a/server/database/db.js b/server/database/db.js index 9ab0ad78..f43c894a 100644 --- a/server/database/db.js +++ b/server/database/db.js @@ -2,11 +2,21 @@ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import crypto from 'crypto'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; +import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js'; +import { + APP_CONFIG_TABLE_SQL, + USER_NOTIFICATION_PREFERENCES_TABLE_SQL, + VAPID_KEYS_TABLE_SQL, + PUSH_SUBSCRIPTIONS_TABLE_SQL, + SESSION_NAMES_TABLE_SQL, + SESSION_NAMES_LOOKUP_INDEX_SQL, + DATABASE_SCHEMA_SQL +} from './schema.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +const __dirname = getModuleDir(import.meta.url); +// The compiled backend lives under dist-server/server/database, but the install root we log +// should still point at the project/app root. Resolving it here avoids build-layout drift. +const APP_ROOT = findAppRoot(__dirname); // ANSI color codes for terminal output const colors = { @@ -24,7 +34,6 @@ const c = { // Use DATABASE_PATH environment variable if set, otherwise use default location const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db'); -const INIT_SQL_PATH = path.join(__dirname, 'init.sql'); // Ensure database directory exists if custom path is provided if (process.env.DATABASE_PATH) { @@ -62,14 +71,10 @@ const db = new Database(DB_PATH); // app_config must exist before any other module imports (auth.js reads the JWT secret at load time). // runMigrations() also creates this table, but it runs too late for existing installations // where auth.js is imported before initializeDatabase() is called. -db.exec(`CREATE TABLE IF NOT EXISTS app_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -)`); +db.exec(APP_CONFIG_TABLE_SQL); // Show app installation path prominently -const appInstallPath = path.join(__dirname, '../..'); +const appInstallPath = APP_ROOT; console.log(''); console.log(c.dim('═'.repeat(60))); console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`); @@ -100,53 +105,12 @@ const runMigrations = () => { db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0'); } - db.exec(` - CREATE TABLE IF NOT EXISTS user_notification_preferences ( - user_id INTEGER PRIMARY KEY, - preferences_json TEXT NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS vapid_keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - public_key TEXT NOT NULL, - private_key TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS push_subscriptions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - endpoint TEXT NOT NULL UNIQUE, - keys_p256dh TEXT NOT NULL, - keys_auth TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - // Create app_config table if it doesn't exist (for existing installations) - db.exec(`CREATE TABLE IF NOT EXISTS app_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - - // Create session_names table if it doesn't exist (for existing installations) - db.exec(`CREATE TABLE IF NOT EXISTS session_names ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - provider TEXT NOT NULL DEFAULT 'claude', - custom_name TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(session_id, provider) - )`); - db.exec('CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)'); + db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SQL); + db.exec(VAPID_KEYS_TABLE_SQL); + db.exec(PUSH_SUBSCRIPTIONS_TABLE_SQL); + db.exec(APP_CONFIG_TABLE_SQL); + db.exec(SESSION_NAMES_TABLE_SQL); + db.exec(SESSION_NAMES_LOOKUP_INDEX_SQL); console.log('Database migrations completed successfully'); } catch (error) { @@ -158,8 +122,7 @@ const runMigrations = () => { // Initialize database with schema const initializeDatabase = async () => { try { - const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8'); - db.exec(initSQL); + db.exec(DATABASE_SCHEMA_SQL); console.log('Database initialized successfully'); runMigrations(); } catch (error) { diff --git a/server/database/init.sql b/server/database/init.sql deleted file mode 100644 index 98351516..00000000 --- a/server/database/init.sql +++ /dev/null @@ -1,99 +0,0 @@ --- Initialize authentication database -PRAGMA foreign_keys = ON; - --- Users table (single user system) -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_login DATETIME, - is_active BOOLEAN DEFAULT 1, - git_name TEXT, - git_email TEXT, - has_completed_onboarding BOOLEAN DEFAULT 0 -); - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); -CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active); - --- API Keys table for external API access -CREATE TABLE IF NOT EXISTS api_keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - key_name TEXT NOT NULL, - api_key TEXT UNIQUE NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_used DATETIME, - is_active BOOLEAN DEFAULT 1, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key); -CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id); -CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active); - --- User credentials table for storing various tokens/credentials (GitHub, GitLab, etc.) -CREATE TABLE IF NOT EXISTS user_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - credential_name TEXT NOT NULL, - credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc. - credential_value TEXT NOT NULL, - description TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_active BOOLEAN DEFAULT 1, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id); -CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type); -CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active); - --- User notification preferences (backend-owned, provider-agnostic) -CREATE TABLE IF NOT EXISTS user_notification_preferences ( - user_id INTEGER PRIMARY KEY, - preferences_json TEXT NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - --- VAPID key pair for Web Push notifications -CREATE TABLE IF NOT EXISTS vapid_keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - public_key TEXT NOT NULL, - private_key TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - --- Browser push subscriptions -CREATE TABLE IF NOT EXISTS push_subscriptions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - endpoint TEXT NOT NULL UNIQUE, - keys_p256dh TEXT NOT NULL, - keys_auth TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - --- Session custom names (provider-agnostic display name overrides) -CREATE TABLE IF NOT EXISTS session_names ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - provider TEXT NOT NULL DEFAULT 'claude', - custom_name TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(session_id, provider) -); - -CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider); - --- App configuration table (auto-generated secrets, settings, etc.) -CREATE TABLE IF NOT EXISTS app_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); diff --git a/server/database/schema.js b/server/database/schema.js new file mode 100644 index 00000000..21c1b8eb --- /dev/null +++ b/server/database/schema.js @@ -0,0 +1,102 @@ +export const APP_CONFIG_TABLE_SQL = `CREATE TABLE IF NOT EXISTS app_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +);`; + +export const USER_NOTIFICATION_PREFERENCES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id INTEGER PRIMARY KEY, + preferences_json TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +);`; + +export const VAPID_KEYS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS vapid_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +);`; + +export const PUSH_SUBSCRIPTIONS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + keys_p256dh TEXT NOT NULL, + keys_auth TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +);`; + +export const SESSION_NAMES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS session_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'claude', + custom_name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(session_id, provider) +);`; + +export const SESSION_NAMES_LOOKUP_INDEX_SQL = `CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);`; + +export const DATABASE_SCHEMA_SQL = `PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME, + is_active BOOLEAN DEFAULT 1, + git_name TEXT, + git_email TEXT, + has_completed_onboarding BOOLEAN DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active); + +CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + key_name TEXT NOT NULL, + api_key TEXT UNIQUE NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used DATETIME, + is_active BOOLEAN DEFAULT 1, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key); +CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active); + +CREATE TABLE IF NOT EXISTS user_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + credential_name TEXT NOT NULL, + credential_type TEXT NOT NULL, + credential_value TEXT NOT NULL, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT 1, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id); +CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type); +CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active); + +${USER_NOTIFICATION_PREFERENCES_TABLE_SQL} + +${VAPID_KEYS_TABLE_SQL} + +${PUSH_SUBSCRIPTIONS_TABLE_SQL} + +${SESSION_NAMES_TABLE_SQL} + +${SESSION_NAMES_LOOKUP_INDEX_SQL} + +${APP_CONFIG_TABLE_SQL} +`; diff --git a/server/index.js b/server/index.js index da6ca675..3e5e657d 100755 --- a/server/index.js +++ b/server/index.js @@ -3,13 +3,13 @@ import './load-env.js'; import fs from 'fs'; import path from 'path'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; +import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm'; +const __dirname = getModuleDir(import.meta.url); +// The server source runs from /server, while the compiled output runs from /dist-server/server. +// Resolving the app root once keeps every repo-level lookup below aligned across both layouts. +const APP_ROOT = findAppRoot(__dirname); +const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; // ANSI color codes for terminal output const colors = { @@ -405,11 +405,11 @@ app.use('/api/sessions', authenticateToken, messagesRoutes); app.use('/api/agent', agentRoutes); // Serve public files (like api-docs.html) -app.use(express.static(path.join(__dirname, '../public'))); +app.use(express.static(path.join(APP_ROOT, 'public'))); // Static files served after API routes // Add cache control: HTML files should not be cached, but assets can be cached -app.use(express.static(path.join(__dirname, '../dist'), { +app.use(express.static(path.join(APP_ROOT, 'dist'), { setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) { // Prevent HTML caching to avoid service worker issues after builds @@ -431,7 +431,7 @@ app.use(express.static(path.join(__dirname, '../dist'), { app.post('/api/system/update', authenticateToken, async (req, res) => { try { // Get the project root directory (parent of server directory) - const projectRoot = path.join(__dirname, '..'); + const projectRoot = APP_ROOT; console.log('Starting system update from directory:', projectRoot); @@ -2273,7 +2273,7 @@ app.get('*', (req, res) => { // Only serve index.html for HTML routes, not for static assets // Static assets should already be handled by express.static middleware above - const indexPath = path.join(__dirname, '../dist/index.html'); + const indexPath = path.join(APP_ROOT, 'dist', 'index.html'); // Check if dist/index.html exists (production build available) if (fs.existsSync(indexPath)) { @@ -2388,7 +2388,7 @@ async function startServer() { configureWebPush(); // Check if running in production mode (dist folder exists) - const distIndexPath = path.join(__dirname, '../dist/index.html'); + const distIndexPath = path.join(APP_ROOT, 'dist', 'index.html'); const isProduction = fs.existsSync(distIndexPath); // Log Claude implementation mode @@ -2402,7 +2402,7 @@ async function startServer() { console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`); server.listen(SERVER_PORT, HOST, async () => { - const appInstallPath = path.join(__dirname, '..'); + const appInstallPath = APP_ROOT; console.log(''); console.log(c.dim('═'.repeat(63))); diff --git a/server/load-env.js b/server/load-env.js index ad9ccbb7..2c889534 100644 --- a/server/load-env.js +++ b/server/load-env.js @@ -2,14 +2,15 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; +import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +const __dirname = getModuleDir(import.meta.url); +// Resolve the repo/app root via the nearest /server folder so this file keeps finding the +// same top-level .env file from both /server/load-env.js and /dist-server/server/load-env.js. +const APP_ROOT = findAppRoot(__dirname); try { - const envPath = path.join(__dirname, '../.env'); + const envPath = path.join(APP_ROOT, '.env'); const envFile = fs.readFileSync(envPath, 'utf8'); envFile.split('\n').forEach(line => { const trimmedLine = line.trim(); @@ -24,6 +25,10 @@ try { console.log('No .env file found or error reading it:', e.message); } +// Keep the default database in a stable user-level location so rebuilding dist-server +// never changes where the backend stores auth.db when DATABASE_PATH is not set explicitly. +const DEFAULT_DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db'); + if (!process.env.DATABASE_PATH) { - process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db'); + process.env.DATABASE_PATH = DEFAULT_DATABASE_PATH; } diff --git a/server/routes/commands.js b/server/routes/commands.js index 388a8f76..4ce3c4c0 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -1,13 +1,15 @@ import express from 'express'; import { promises as fs } from 'fs'; import path from 'path'; -import { fileURLToPath } from 'url'; import os from 'os'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; import { parseFrontmatter } from '../utils/frontmatter.js'; +import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const __dirname = getModuleDir(import.meta.url); +// This route reads the top-level package.json for the status command, so it needs the real +// app root even after compilation moves the route file under dist-server/server/routes. +const APP_ROOT = findAppRoot(__dirname); const router = express.Router(); @@ -291,7 +293,7 @@ Custom commands can be created in: '/status': async (args, context) => { // Read version from package.json - const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json'); + const packageJsonPath = path.join(APP_ROOT, 'package.json'); let version = 'unknown'; let packageName = 'claude-code-ui'; diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 00000000..59a363ed --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "baseUrl": ".", + "paths": { + // In the backend config, "@" maps to the /server directory itself. + "@/*": ["*"] + }, + // The backend is still mostly JavaScript today, so allowJs lets us add a real + // TypeScript build without forcing a large rename before the tooling is usable. + "allowJs": true, + // Keep the migration incremental: existing JS keeps building, while any new TS files + // still go through the normal TypeScript pipeline and strict checks. + "checkJs": false, + "strict": true, + "noEmitOnError": true, + // The backend build emits both /server and /shared into dist-server, so rootDir must + // stay one level above this file even though the config itself now lives in /server. + "rootDir": "..", + "outDir": "../dist-server", + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["./**/*.js", "./**/*.ts", "../shared/**/*.js", "../shared/**/*.ts"], + "exclude": ["../dist", "../dist-server", "../node_modules", "../src"] +} diff --git a/server/utils/runtime-paths.js b/server/utils/runtime-paths.js new file mode 100644 index 00000000..92137187 --- /dev/null +++ b/server/utils/runtime-paths.js @@ -0,0 +1,37 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; + +export function getModuleDir(importMetaUrl) { + return path.dirname(fileURLToPath(importMetaUrl)); +} + +export function findServerRoot(startDir) { + // Source files live under /server, while compiled files live under /dist-server/server. + // Walking up to the nearest "server" folder gives every backend module one stable anchor + // that works in both layouts instead of relying on fragile "../.." assumptions. + let currentDir = startDir; + + while (path.basename(currentDir) !== 'server') { + const parentDir = path.dirname(currentDir); + + if (parentDir === currentDir) { + throw new Error(`Could not resolve the backend server root from "${startDir}".`); + } + + currentDir = parentDir; + } + + return currentDir; +} + +export function findAppRoot(startDir) { + const serverRoot = findServerRoot(startDir); + const parentOfServerRoot = path.dirname(serverRoot); + + // Source files live at /server, while compiled files live at /dist-server/server. + // When the nearest server folder sits inside dist-server we need to hop one extra level up + // so repo-level files still resolve from the real app root instead of the build directory. + return path.basename(parentOfServerRoot) === 'dist-server' + ? path.dirname(parentOfServerRoot) + : parentOfServerRoot; +} diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 858faff9..15f4b63f 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -19,7 +19,7 @@ import type { PendingPermissionRequest, PermissionMode, } from '../types/types'; -import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; +import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import { escapeRegExp } from '../utils/chatFormatting'; import { useFileMentions } from './useFileMentions'; import { type SlashCommand, useSlashCommands } from './useSlashCommands'; @@ -33,7 +33,7 @@ interface UseChatComposerStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; - provider: SessionProvider; + provider: LLMProvider; permissionMode: PermissionMode | string; cyclePermissionMode: () => void; cursorModel: string; diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 9d48ce3d..6d39d22a 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { authenticatedFetch } from '../../../utils/api'; import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants'; import type { PendingPermissionRequest, PermissionMode } from '../types/types'; -import type { ProjectSession, SessionProvider } from '../../../types/app'; +import type { ProjectSession, LLMProvider } from '../../../types/app'; interface UseChatProviderStateArgs { selectedSession: ProjectSession | null; @@ -11,8 +11,8 @@ interface UseChatProviderStateArgs { export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) { const [permissionMode, setPermissionMode] = useState('default'); const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]); - const [provider, setProvider] = useState(() => { - return (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; + const [provider, setProvider] = useState(() => { + return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; }); const [cursorModel, setCursorModel] = useState(() => { return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT; diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 6d734730..73d1a5e7 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { PendingPermissionRequest } from '../types/types'; -import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; +import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; type PendingViewSession = { @@ -48,7 +48,7 @@ type LatestChatMessage = { interface UseChatRealtimeHandlersArgs { latestMessage: LatestChatMessage | null; - provider: SessionProvider; + provider: LLMProvider; selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index e952ee1a..b551060a 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import type { MutableRefObject } from 'react'; import { authenticatedFetch } from '../../../utils/api'; import type { ChatMessage, Provider } from '../types/types'; -import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; +import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; import { normalizedToChatMessages } from './useChatMessages'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; @@ -40,7 +40,7 @@ interface ScrollRestoreState { function chatMessageToNormalized( msg: ChatMessage, sessionId: string, - provider: SessionProvider, + provider: LLMProvider, ): NormalizedMessage | null { const id = `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const ts = msg.timestamp instanceof Date @@ -151,7 +151,7 @@ export function useChatSessionState({ // When a real session ID arrives and we have a pending user message, flush it to the store const prevActiveSessionRef = useRef(null); if (activeSessionId && activeSessionId !== prevActiveSessionRef.current && pendingUserMessage) { - const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; + const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov); if (normalized) { sessionStore.appendRealtime(activeSessionId, normalized); @@ -189,7 +189,7 @@ export function useChatSessionState({ setPendingUserMessage(msg); return; } - const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; + const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; const normalized = chatMessageToNormalized(msg, activeSessionId, prov); if (normalized) { sessionStore.appendRealtime(activeSessionId, normalized); @@ -240,7 +240,7 @@ export function useChatSessionState({ try { const slot = await sessionStore.fetchMore(selectedSession.id, { - provider: sessionProvider as SessionProvider, + provider: sessionProvider as LLMProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: MESSAGES_PER_PAGE, @@ -374,7 +374,7 @@ export function useChatSessionState({ // Fetch from server → store updates → chatMessages re-derives automatically setIsLoadingSessionMessages(true); sessionStore.fetchFromServer(selectedSession.id, { - provider: (selectedSession.__provider || provider) as SessionProvider, + provider: (selectedSession.__provider || provider) as LLMProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: MESSAGES_PER_PAGE, @@ -410,7 +410,7 @@ export function useChatSessionState({ // Skip store refresh during active streaming if (!isLoading) { await sessionStore.refreshFromServer(selectedSession.id, { - provider: (selectedSession.__provider || provider) as SessionProvider, + provider: (selectedSession.__provider || provider) as LLMProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', }); @@ -468,7 +468,7 @@ export function useChatSessionState({ try { // Load all messages into the store for search navigation const slot = await sessionStore.fetchFromServer(selectedSession.id, { - provider: sessionProvider as SessionProvider, + provider: sessionProvider as LLMProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: null, @@ -655,7 +655,7 @@ export function useChatSessionState({ try { const slot = await sessionStore.fetchFromServer(requestSessionId, { - provider: sessionProvider as SessionProvider, + provider: sessionProvider as LLMProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: null, diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 66d50741..900028c0 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -1,6 +1,6 @@ -import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; +import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; -export type Provider = SessionProvider; +export type Provider = LLMProvider; export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 19483f64..44709502 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { QuickSettingsPanel } from '../../quick-settings-panel'; import type { ChatInterfaceProps, Provider } from '../types/types'; -import type { SessionProvider } from '../../../types/app'; +import type { LLMProvider } from '../../../types/app'; import { useChatProviderState } from '../hooks/useChatProviderState'; import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; @@ -206,9 +206,9 @@ function ChatInterface({ // so missed streaming events are shown. Also reset isLoading. const handleWebSocketReconnect = useCallback(async () => { if (!selectedProject || !selectedSession) return; - const providerVal = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; + const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; await sessionStore.refreshFromServer(selectedSession.id, { - provider: (selectedSession.__provider || providerVal) as SessionProvider, + provider: (selectedSession.__provider || providerVal) as LLMProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', }); diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 63ae4841..9d47a7c2 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useCallback, useRef } from 'react'; import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { ChatMessage } from '../../types/types'; -import type { Project, ProjectSession, SessionProvider } from '../../../../types/app'; +import type { Project, ProjectSession, LLMProvider } from '../../../../types/app'; import { getIntrinsicMessageKey } from '../../utils/messageKeys'; import MessageComponent from './MessageComponent'; import ProviderSelectionEmptyState from './ProviderSelectionEmptyState'; @@ -15,8 +15,8 @@ interface ChatMessagesPaneProps { chatMessages: ChatMessage[]; selectedSession: ProjectSession | null; currentSessionId: string | null; - provider: SessionProvider; - setProvider: (provider: SessionProvider) => void; + provider: LLMProvider; + setProvider: (provider: LLMProvider) => void; textareaRef: RefObject; claudeModel: string; setClaudeModel: (model: string) => void; diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 792b12c7..9eaf690e 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -8,14 +8,14 @@ import { CODEX_MODELS, GEMINI_MODELS, } from "../../../../../shared/modelConstants"; -import type { ProjectSession, SessionProvider } from "../../../../types/app"; +import type { ProjectSession, LLMProvider } from "../../../../types/app"; import { NextTaskBanner } from "../../../task-master"; type ProviderSelectionEmptyStateProps = { selectedSession: ProjectSession | null; currentSessionId: string | null; - provider: SessionProvider; - setProvider: (next: SessionProvider) => void; + provider: LLMProvider; + setProvider: (next: LLMProvider) => void; textareaRef: React.RefObject; claudeModel: string; setClaudeModel: (model: string) => void; @@ -32,7 +32,7 @@ type ProviderSelectionEmptyStateProps = { }; type ProviderDef = { - id: SessionProvider; + id: LLMProvider; name: string; infoKey: string; accent: string; @@ -75,7 +75,7 @@ const PROVIDERS: ProviderDef[] = [ }, ]; -function getModelConfig(p: SessionProvider) { +function getModelConfig(p: LLMProvider) { if (p === "claude") return CLAUDE_MODELS; if (p === "codex") return CODEX_MODELS; if (p === "gemini") return GEMINI_MODELS; @@ -83,7 +83,7 @@ function getModelConfig(p: SessionProvider) { } function getModelValue( - p: SessionProvider, + p: LLMProvider, c: string, cu: string, co: string, @@ -119,7 +119,7 @@ export default function ProviderSelectionEmptyState({ defaultValue: "Start the next task", }); - const selectProvider = (next: SessionProvider) => { + const selectProvider = (next: LLMProvider) => { setProvider(next); localStorage.setItem("selected-provider", next); setTimeout(() => textareaRef.current?.focus(), 100); diff --git a/src/components/llm-logo-provider/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx index 1eaef523..53c02291 100644 --- a/src/components/llm-logo-provider/SessionProviderLogo.tsx +++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx @@ -1,11 +1,11 @@ -import type { SessionProvider } from '../../types/app'; +import type { LLMProvider } from '../../types/app'; import ClaudeLogo from './ClaudeLogo'; import CodexLogo from './CodexLogo'; import CursorLogo from './CursorLogo'; import GeminiLogo from './GeminiLogo'; type SessionProviderLogoProps = { - provider?: SessionProvider | string | null; + provider?: LLMProvider | string | null; className?: string; }; diff --git a/src/components/onboarding/view/Onboarding.tsx b/src/components/onboarding/view/Onboarding.tsx index 58627c4c..6d9a69ca 100644 --- a/src/components/onboarding/view/Onboarding.tsx +++ b/src/components/onboarding/view/Onboarding.tsx @@ -1,17 +1,15 @@ import { Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import type { LLMProvider } from '../../../types/app'; import { authenticatedFetch } from '../../../utils/api'; +import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus'; import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal'; import AgentConnectionsStep from './subcomponents/AgentConnectionsStep'; import GitConfigurationStep from './subcomponents/GitConfigurationStep'; import OnboardingStepProgress from './subcomponents/OnboardingStepProgress'; -import type { CliProvider, ProviderStatusMap } from './types'; import { - cliProviders, - createInitialProviderStatuses, gitEmailPattern, readErrorMessageFromResponse, - selectedProject, } from './utils'; type OnboardingProps = { @@ -24,59 +22,14 @@ export default function Onboarding({ onComplete }: OnboardingProps) { const [gitEmail, setGitEmail] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [errorMessage, setErrorMessage] = useState(''); - const [activeLoginProvider, setActiveLoginProvider] = useState(null); - const [providerStatuses, setProviderStatuses] = useState(createInitialProviderStatuses); + const [activeLoginProvider, setActiveLoginProvider] = useState(null); + const { + providerAuthStatus, + checkProviderAuthStatus, + refreshProviderAuthStatuses, + } = useProviderAuthStatus(); - const previousActiveLoginProviderRef = useRef(undefined); - - const checkProviderAuthStatus = useCallback(async (provider: CliProvider) => { - try { - const response = await authenticatedFetch(`/api/cli/${provider}/status`); - if (!response.ok) { - setProviderStatuses((previous) => ({ - ...previous, - [provider]: { - authenticated: false, - email: null, - loading: false, - error: 'Failed to check authentication status', - }, - })); - return; - } - - const payload = (await response.json()) as { - authenticated?: boolean; - email?: string | null; - error?: string | null; - }; - - setProviderStatuses((previous) => ({ - ...previous, - [provider]: { - authenticated: Boolean(payload.authenticated), - email: payload.email ?? null, - loading: false, - error: payload.error ?? null, - }, - })); - } catch (caughtError) { - console.error(`Error checking ${provider} auth status:`, caughtError); - setProviderStatuses((previous) => ({ - ...previous, - [provider]: { - authenticated: false, - email: null, - loading: false, - error: caughtError instanceof Error ? caughtError.message : 'Unknown error', - }, - })); - } - }, []); - - const refreshAllProviderStatuses = useCallback(async () => { - await Promise.all(cliProviders.map((provider) => checkProviderAuthStatus(provider))); - }, [checkProviderAuthStatus]); + const previousActiveLoginProviderRef = useRef(undefined); const loadGitConfig = useCallback(async () => { try { @@ -99,23 +52,24 @@ export default function Onboarding({ onComplete }: OnboardingProps) { useEffect(() => { void loadGitConfig(); - void refreshAllProviderStatuses(); - }, [loadGitConfig, refreshAllProviderStatuses]); + void refreshProviderAuthStatuses(); + }, [loadGitConfig, refreshProviderAuthStatuses]); useEffect(() => { const previousProvider = previousActiveLoginProviderRef.current; previousActiveLoginProviderRef.current = activeLoginProvider; - const isInitialMount = previousProvider === undefined; - const didCloseModal = previousProvider !== null && activeLoginProvider === null; + const didCloseModal = previousProvider !== undefined + && previousProvider !== null + && activeLoginProvider === null; - // Refresh statuses once on mount and again after the login modal is closed. - if (isInitialMount || didCloseModal) { - void refreshAllProviderStatuses(); + // Refresh statuses after the login modal is closed. + if (didCloseModal) { + void refreshProviderAuthStatuses(); } - }, [activeLoginProvider, refreshAllProviderStatuses]); + }, [activeLoginProvider, refreshProviderAuthStatuses]); - const handleProviderLoginOpen = (provider: CliProvider) => { + const handleProviderLoginOpen = (provider: LLMProvider) => { setActiveLoginProvider(provider); }; @@ -209,7 +163,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) { /> ) : ( )} @@ -279,7 +233,6 @@ export default function Onboarding({ onComplete }: OnboardingProps) { isOpen={Boolean(activeLoginProvider)} onClose={() => setActiveLoginProvider(null)} provider={activeLoginProvider} - project={selectedProject} onComplete={handleLoginComplete} /> )} diff --git a/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx b/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx index edf2ef0e..3737ad54 100644 --- a/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx +++ b/src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx @@ -1,9 +1,10 @@ import { Check } from 'lucide-react'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; -import type { CliProvider, ProviderAuthStatus } from '../types'; +import type { LLMProvider } from '../../../../types/app'; +import type { ProviderAuthStatus } from '../../../provider-auth/types'; type AgentConnectionCardProps = { - provider: CliProvider; + provider: LLMProvider; title: string; status: ProviderAuthStatus; connectedClassName: string; diff --git a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx index 5bca5d33..3dfb25a9 100644 --- a/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx +++ b/src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx @@ -1,9 +1,10 @@ -import type { CliProvider, ProviderStatusMap } from '../types'; +import type { LLMProvider } from '../../../../types/app'; +import type { ProviderAuthStatusMap } from '../../../provider-auth/types'; import AgentConnectionCard from './AgentConnectionCard'; type AgentConnectionsStepProps = { - providerStatuses: ProviderStatusMap; - onOpenProviderLogin: (provider: CliProvider) => void; + providerStatuses: ProviderAuthStatusMap; + onOpenProviderLogin: (provider: LLMProvider) => void; }; const providerCards = [ diff --git a/src/components/onboarding/view/types.ts b/src/components/onboarding/view/types.ts deleted file mode 100644 index 46800813..00000000 --- a/src/components/onboarding/view/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CliProvider } from '../../provider-auth/types'; - -export type { CliProvider }; - -export type ProviderAuthStatus = { - authenticated: boolean; - email: string | null; - loading: boolean; - error: string | null; -}; - -export type ProviderStatusMap = Record; diff --git a/src/components/onboarding/view/utils.ts b/src/components/onboarding/view/utils.ts index adb3c4bc..0b40ab02 100644 --- a/src/components/onboarding/view/utils.ts +++ b/src/components/onboarding/view/utils.ts @@ -1,24 +1,5 @@ -import { IS_PLATFORM } from '../../../constants/config'; -import type { CliProvider, ProviderStatusMap } from './types'; - -export const cliProviders: CliProvider[] = ['claude', 'cursor', 'codex', 'gemini']; - export const gitEmailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -export const selectedProject = { - name: 'default', - displayName: 'default', - fullPath: IS_PLATFORM ? '/workspace' : '', - path: IS_PLATFORM ? '/workspace' : '', -}; - -export const createInitialProviderStatuses = (): ProviderStatusMap => ({ - claude: { authenticated: false, email: null, loading: true, error: null }, - cursor: { authenticated: false, email: null, loading: true, error: null }, - codex: { authenticated: false, email: null, loading: true, error: null }, - gemini: { authenticated: false, email: null, loading: true, error: null }, -}); - export const readErrorMessageFromResponse = async (response: Response, fallback: string) => { try { const payload = (await response.json()) as { error?: string }; diff --git a/src/components/provider-auth/hooks/useProviderAuthStatus.ts b/src/components/provider-auth/hooks/useProviderAuthStatus.ts new file mode 100644 index 00000000..83692a2a --- /dev/null +++ b/src/components/provider-auth/hooks/useProviderAuthStatus.ts @@ -0,0 +1,109 @@ +import { useCallback, useState } from 'react'; +import { authenticatedFetch } from '../../../utils/api'; +import type { LLMProvider } from '../../../types/app'; +import { + CLI_AUTH_STATUS_ENDPOINTS, + CLI_PROVIDERS, + createInitialProviderAuthStatusMap, +} from '../types'; +import type { + ProviderAuthStatus, + ProviderAuthStatusMap, +} from '../types'; + +type ProviderAuthStatusPayload = { + authenticated?: boolean; + email?: string | null; + method?: string | null; + error?: string | null; +}; + +const FALLBACK_STATUS_ERROR = 'Failed to check authentication status'; +const FALLBACK_UNKNOWN_ERROR = 'Unknown error'; + +const toErrorMessage = (error: unknown): string => ( + error instanceof Error ? error.message : FALLBACK_UNKNOWN_ERROR +); + +const toProviderAuthStatus = ( + payload: ProviderAuthStatusPayload, + fallbackError: string | null = null, +): ProviderAuthStatus => ({ + authenticated: Boolean(payload.authenticated), + email: payload.email ?? null, + method: payload.method ?? null, + error: payload.error ?? fallbackError, + loading: false, +}); + +type UseProviderAuthStatusOptions = { + initialLoading?: boolean; +}; + +export function useProviderAuthStatus( + { initialLoading = true }: UseProviderAuthStatusOptions = {}, +) { + const [providerAuthStatus, setProviderAuthStatus] = useState(() => ( + createInitialProviderAuthStatusMap(initialLoading) + )); + + const setProviderLoading = useCallback((provider: LLMProvider) => { + setProviderAuthStatus((previous) => ({ + ...previous, + [provider]: { + ...previous[provider], + loading: true, + error: null, + }, + })); + }, []); + + const setProviderStatus = useCallback((provider: LLMProvider, status: ProviderAuthStatus) => { + setProviderAuthStatus((previous) => ({ + ...previous, + [provider]: status, + })); + }, []); + + const checkProviderAuthStatus = useCallback(async (provider: LLMProvider) => { + setProviderLoading(provider); + + try { + const response = await authenticatedFetch(CLI_AUTH_STATUS_ENDPOINTS[provider]); + + if (!response.ok) { + setProviderStatus(provider, { + authenticated: false, + email: null, + method: null, + loading: false, + error: FALLBACK_STATUS_ERROR, + }); + return; + } + + const payload = (await response.json()) as ProviderAuthStatusPayload; + setProviderStatus(provider, toProviderAuthStatus(payload)); + } catch (caughtError) { + console.error(`Error checking ${provider} auth status:`, caughtError); + setProviderStatus(provider, { + authenticated: false, + email: null, + method: null, + loading: false, + error: toErrorMessage(caughtError), + }); + } + }, [setProviderLoading, setProviderStatus]); + + const refreshProviderAuthStatuses = useCallback(async (providers: LLMProvider[] = CLI_PROVIDERS) => { + await Promise.all(providers.map((provider) => checkProviderAuthStatus(provider))); + }, [checkProviderAuthStatus]); + + return { + providerAuthStatus, + setProviderAuthStatus, + checkProviderAuthStatus, + refreshProviderAuthStatuses, + }; +} diff --git a/src/components/provider-auth/types.ts b/src/components/provider-auth/types.ts index e39a9796..bb93199c 100644 --- a/src/components/provider-auth/types.ts +++ b/src/components/provider-auth/types.ts @@ -1 +1,27 @@ -export type CliProvider = 'claude' | 'cursor' | 'codex' | 'gemini'; +import type { LLMProvider } from '../../types/app'; + +export type ProviderAuthStatus = { + authenticated: boolean; + email: string | null; + method: string | null; + error: string | null; + loading: boolean; +}; + +export type ProviderAuthStatusMap = Record; + +export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini']; + +export const CLI_AUTH_STATUS_ENDPOINTS: Record = { + claude: '/api/cli/claude/status', + cursor: '/api/cli/cursor/status', + codex: '/api/cli/codex/status', + gemini: '/api/cli/gemini/status', +}; + +export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({ + claude: { authenticated: false, email: null, method: null, error: null, loading }, + cursor: { authenticated: false, email: null, method: null, error: null, loading }, + codex: { authenticated: false, email: null, method: null, error: null, loading }, + gemini: { authenticated: false, email: null, method: null, error: null, loading }, +}); diff --git a/src/components/provider-auth/view/ProviderLoginModal.tsx b/src/components/provider-auth/view/ProviderLoginModal.tsx index 9e80302e..fa1d4407 100644 --- a/src/components/provider-auth/view/ProviderLoginModal.tsx +++ b/src/components/provider-auth/view/ProviderLoginModal.tsx @@ -1,21 +1,12 @@ import { ExternalLink, KeyRound, X } from 'lucide-react'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; -import { IS_PLATFORM } from '../../../constants/config'; -import type { CliProvider } from '../types'; - -type LoginModalProject = { - name?: string; - displayName?: string; - fullPath?: string; - path?: string; - [key: string]: unknown; -}; +import { DEFAULT_PROJECT_FOR_EMPTY_SHELL, IS_PLATFORM } from '../../../constants/config'; +import type { LLMProvider } from '../../../types/app'; type ProviderLoginModalProps = { isOpen: boolean; onClose: () => void; - provider?: CliProvider; - project?: LoginModalProject | null; + provider?: LLMProvider; onComplete?: (exitCode: number) => void; customCommand?: string; isAuthenticated?: boolean; @@ -26,7 +17,7 @@ const getProviderCommand = ({ customCommand, isAuthenticated: _isAuthenticated, }: { - provider: CliProvider; + provider: LLMProvider; customCommand?: string; isAuthenticated: boolean; }) => { @@ -49,30 +40,17 @@ const getProviderCommand = ({ return 'gemini status'; }; -const getProviderTitle = (provider: CliProvider) => { +const getProviderTitle = (provider: LLMProvider) => { if (provider === 'claude') return 'Claude CLI Login'; if (provider === 'cursor') return 'Cursor CLI Login'; if (provider === 'codex') return 'Codex CLI Login'; return 'Gemini CLI Configuration'; }; -const normalizeProject = (project?: LoginModalProject | null) => { - const normalizedName = project?.name || 'default'; - const normalizedFullPath = project?.fullPath ?? project?.path ?? (IS_PLATFORM ? '/workspace' : ''); - - return { - name: normalizedName, - displayName: project?.displayName || normalizedName, - fullPath: normalizedFullPath, - path: project?.path ?? normalizedFullPath, - }; -}; - export default function ProviderLoginModal({ isOpen, onClose, provider = 'claude', - project = null, onComplete, customCommand, isAuthenticated = false, @@ -83,7 +61,6 @@ export default function ProviderLoginModal({ const command = getProviderCommand({ provider, customCommand, isAuthenticated }); const title = getProviderTitle(provider); - const shellProject = normalizeProject(project); const handleComplete = (exitCode: number) => { onComplete?.(exitCode); @@ -158,7 +135,7 @@ export default function ProviderLoginModal({ ) : ( - + )} diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index 36f45392..52f16e10 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -1,7 +1,6 @@ import type { AgentCategory, AgentProvider, - AuthStatus, ClaudeMcpFormState, CodexMcpFormState, CodeEditorSettingsState, @@ -34,13 +33,6 @@ export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = { fontSize: '14', }; -export const DEFAULT_AUTH_STATUS: AuthStatus = { - authenticated: false, - email: null, - loading: true, - error: null, -}; - export const DEFAULT_MCP_TEST_RESULT: McpTestResult = { success: false, message: '', @@ -88,9 +80,3 @@ export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = { skipPermissions: false, }; -export const AUTH_STATUS_ENDPOINTS: Record = { - claude: '/api/cli/claude/status', - cursor: '/api/cli/cursor/status', - codex: '/api/cli/codex/status', - gemini: '/api/cli/gemini/status', -}; diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index 293cbceb..09550265 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -1,15 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTheme } from '../../../contexts/ThemeContext'; import { authenticatedFetch } from '../../../utils/api'; +import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus'; import { - AUTH_STATUS_ENDPOINTS, - DEFAULT_AUTH_STATUS, DEFAULT_CODE_EDITOR_SETTINGS, DEFAULT_CURSOR_PERMISSIONS, } from '../constants/constants'; import type { AgentProvider, - AuthStatus, ClaudeMcpFormState, ClaudePermissionsState, CodeEditorSettingsState, @@ -23,7 +21,6 @@ import type { NotificationPreferencesState, ProjectSortOrder, SettingsMainTab, - SettingsProject, } from '../types/types'; type ThemeContextValue = { @@ -34,15 +31,6 @@ type ThemeContextValue = { type UseSettingsControllerArgs = { isOpen: boolean; initialTab: string; - projects: SettingsProject[]; - onClose: () => void; -}; - -type StatusApiResponse = { - authenticated?: boolean; - email?: string | null; - error?: string | null; - method?: string; }; type JsonResult = { @@ -166,20 +154,6 @@ const mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] => })) ); -const getDefaultProject = (projects: SettingsProject[]): SettingsProject => { - if (projects.length > 0) { - return projects[0]; - } - - const cwd = typeof process !== 'undefined' && process.cwd ? process.cwd() : ''; - return { - name: 'default', - displayName: 'default', - fullPath: cwd, - path: cwd, - }; -}; - const toResponseJson = async (response: Response): Promise => response.json() as Promise; const createEmptyClaudePermissions = (): ClaudePermissionsState => ({ @@ -204,7 +178,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState => }, }); -export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) { +export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) { const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue; const closeTimerRef = useRef(null); @@ -242,64 +216,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: const [showLoginModal, setShowLoginModal] = useState(false); const [loginProvider, setLoginProvider] = useState(''); - const [selectedProject, setSelectedProject] = useState(null); - - const [claudeAuthStatus, setClaudeAuthStatus] = useState(DEFAULT_AUTH_STATUS); - const [cursorAuthStatus, setCursorAuthStatus] = useState(DEFAULT_AUTH_STATUS); - const [codexAuthStatus, setCodexAuthStatus] = useState(DEFAULT_AUTH_STATUS); - const [geminiAuthStatus, setGeminiAuthStatus] = useState(DEFAULT_AUTH_STATUS); - - const setAuthStatusByProvider = useCallback((provider: AgentProvider, status: AuthStatus) => { - if (provider === 'claude') { - setClaudeAuthStatus(status); - return; - } - - if (provider === 'cursor') { - setCursorAuthStatus(status); - return; - } - - if (provider === 'gemini') { - setGeminiAuthStatus(status); - return; - } - - setCodexAuthStatus(status); - }, []); - - const checkAuthStatus = useCallback(async (provider: AgentProvider) => { - try { - const response = await authenticatedFetch(AUTH_STATUS_ENDPOINTS[provider]); - - if (!response.ok) { - setAuthStatusByProvider(provider, { - authenticated: false, - email: null, - loading: false, - error: 'Failed to check authentication status', - }); - return; - } - - const data = await toResponseJson(response); - setAuthStatusByProvider(provider, { - authenticated: Boolean(data.authenticated), - email: data.email || null, - loading: false, - error: data.error || null, - method: data.method, - }); - } catch (error) { - console.error(`Error checking ${provider} auth status:`, error); - setAuthStatusByProvider(provider, { - authenticated: false, - email: null, - loading: false, - error: getErrorMessage(error), - }); - } - }, [setAuthStatusByProvider]); + const { + providerAuthStatus, + checkProviderAuthStatus, + refreshProviderAuthStatuses, + } = useProviderAuthStatus(); const fetchCursorMcpServers = useCallback(async () => { try { @@ -724,9 +645,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: const openLoginForProvider = useCallback((provider: AgentProvider) => { setLoginProvider(provider); - setSelectedProject(getDefaultProject(projects)); setShowLoginModal(true); - }, [projects]); + }, []); const handleLoginComplete = useCallback((exitCode: number) => { if (exitCode !== 0 || !loginProvider) { @@ -734,8 +654,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: } setSaveStatus('success'); - void checkAuthStatus(loginProvider); - }, [checkAuthStatus, loginProvider]); + void checkProviderAuthStatus(loginProvider); + }, [checkProviderAuthStatus, loginProvider]); const saveSettings = useCallback(async () => { setSaveStatus(null); @@ -827,11 +747,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: setActiveTab(normalizeMainTab(initialTab)); void loadSettings(); - void checkAuthStatus('claude'); - void checkAuthStatus('cursor'); - void checkAuthStatus('codex'); - void checkAuthStatus('gemini'); - }, [checkAuthStatus, initialTab, isOpen, loadSettings]); + void refreshProviderAuthStatuses(); + }, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]); useEffect(() => { localStorage.setItem('codeEditorTheme', codeEditorSettings.theme); @@ -935,17 +852,13 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }: closeCodexMcpForm, submitCodexMcpForm, handleCodexMcpDelete, - claudeAuthStatus, - cursorAuthStatus, - codexAuthStatus, - geminiAuthStatus, + providerAuthStatus, geminiPermissionMode, setGeminiPermissionMode, openLoginForProvider, showLoginModal, setShowLoginModal, loginProvider, - selectedProject, handleLoginComplete, }; } diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index e3af730b..235d0b47 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -1,7 +1,9 @@ import type { Dispatch, SetStateAction } from 'react'; +import type { LLMProvider } from '../../../types/app'; +import type { ProviderAuthStatus } from '../../provider-auth/types'; export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins' | 'about'; -export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini'; +export type AgentProvider = LLMProvider; export type AgentCategory = 'account' | 'permissions' | 'mcp'; export type ProjectSortOrder = 'name' | 'date'; export type SaveStatus = 'success' | 'error' | null; @@ -18,13 +20,7 @@ export type SettingsProject = { path?: string; }; -export type AuthStatus = { - authenticated: boolean; - email: string | null; - loading: boolean; - error: string | null; - method?: string; -}; +export type AuthStatus = ProviderAuthStatus; export type KeyValueMap = Record; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 70d3ea4d..c1977cf2 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -56,23 +56,17 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set closeCodexMcpForm, submitCodexMcpForm, handleCodexMcpDelete, - claudeAuthStatus, - cursorAuthStatus, - codexAuthStatus, - geminiAuthStatus, + providerAuthStatus, geminiPermissionMode, setGeminiPermissionMode, openLoginForProvider, showLoginModal, setShowLoginModal, loginProvider, - selectedProject, handleLoginComplete, } = useSettingsController({ isOpen, - initialTab, - projects, - onClose, + initialTab }); const { @@ -105,13 +99,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set return null; } - const isAuthenticated = loginProvider === 'claude' - ? claudeAuthStatus.authenticated - : loginProvider === 'cursor' - ? cursorAuthStatus.authenticated - : loginProvider === 'codex' - ? codexAuthStatus.authenticated - : false; + const isAuthenticated = Boolean(loginProvider && providerAuthStatus[loginProvider].authenticated); return (
@@ -121,7 +109,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set

{t('title')}

{saveStatus === 'success' && ( - {t('saveStatus.success')} + {t('saveStatus.success')} )}