From 96463df8da1f50af8e8ce4d99c9d1029db34478b Mon Sep 17 00:00:00 2001 From: Haile <118998054+blackmammoth@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:26:12 +0300 Subject: [PATCH 01/11] Feature/backend ts support andunification of auth settings on frontend (#654) * fix: remove project dependency from settings controller and onboarding * fix(settings): remove onClose prop from useSettingsController args * chore: tailwind classes order * refactor: move provider auth status management to custom hook * refactor: rename SessionProvider to LLMProvider * feat(frontend): support for @ alias based imports) * fix: replace init.sql with schema.js * fix: refactor database initialization to use schema.js for SQL statements * feat(server): add a real backend TypeScript build and enforce module boundaries The backend had started to grow beyond what the frontend-only tooling setup could support safely. We were still running server code directly from /server, linting mainly the client, and relying on path assumptions such as "../.." that only worked in the source layout. That created three problems: - backend alias imports were hard to resolve consistently in the editor, ESLint, and the runtime - server code had no enforced module boundary rules, so cross-module deep imports could bypass intended public entry points - building the backend into a separate output directory would break repo-level lookups for package.json, .env, dist, and public assets because those paths were derived from source-only relative assumptions This change makes the backend tooling explicit and runtime-safe. A dedicated backend TypeScript config now lives in server/tsconfig.json, with tsconfig.server.json reduced to a compatibility shim. This gives the language service and backend tooling a canonical project rooted in /server while still preserving top-level compatibility for any existing references. The backend alias mapping now resolves relative to /server, which avoids colliding with the frontend's "@/..." -> "src/*" mapping. The package scripts were updated so development runs through tsx with the backend tsconfig, build now produces a compiled backend in dist-server, and typecheck/lint cover both client and server. A new build-server.mjs script runs TypeScript and tsc-alias and cleans dist-server first, which prevents stale compiled files from shadowing current source files after refactors. To make the compiled backend behave the same as the source backend, runtime path resolution was centralized in server/utils/runtime-paths.js. Instead of assuming fixed relative paths from each module, server entry points now resolve the actual app root and server root at runtime. That keeps package.json, .env, dist, public, and default database paths stable whether code is executed from /server or from /dist-server/server. ESLint was expanded from a frontend-only setup into a backend-aware one. The backend now uses import resolution tied to the backend tsconfig so aliased imports resolve correctly in linting, import ordering matches the frontend style, and unused/duplicate imports are surfaced consistently. Most importantly, eslint-plugin-boundaries now enforces server module boundaries. Files under server/modules can no longer import another module's internals directly. Cross-module imports must go through that module's barrel file (index.ts/index.js). boundaries/no-unknown was also enabled so alias-resolution gaps cannot silently bypass the rule. Together, these changes make the backend buildable, keep runtime path resolution stable after compilation, align server tooling with the client where appropriate, and enforce a stricter modular architecture for server code. * fix: update package.json to include dist-server in files and remove tsconfig.server.json * refactor: remove build-server.mjs and inline its logic into package.json scripts * fix: update paths in package.json and bin.js to use dist-server directory * feat(eslint): add backend shared types and enforce compile-time contract for imports * fix(eslint): update shared types pattern --------- Co-authored-by: Haileyesus --- .gitignore | 3 +- eslint.config.js | 132 ++- package-lock.json | 846 +++++++++++++++++- package.json | 29 +- redirect-package/bin.js | 2 +- server/cli.js | 22 +- server/database/db.js | 83 +- server/database/init.sql | 99 -- server/database/schema.js | 102 +++ server/index.js | 24 +- server/load-env.js | 17 +- server/routes/commands.js | 10 +- server/tsconfig.json | 33 + server/utils/runtime-paths.js | 37 + .../chat/hooks/useChatComposerState.ts | 4 +- .../chat/hooks/useChatProviderState.ts | 6 +- .../chat/hooks/useChatRealtimeHandlers.ts | 4 +- .../chat/hooks/useChatSessionState.ts | 18 +- src/components/chat/types/types.ts | 4 +- src/components/chat/view/ChatInterface.tsx | 6 +- .../view/subcomponents/ChatMessagesPane.tsx | 6 +- .../ProviderSelectionEmptyState.tsx | 14 +- .../llm-logo-provider/SessionProviderLogo.tsx | 4 +- src/components/onboarding/view/Onboarding.tsx | 87 +- .../subcomponents/AgentConnectionCard.tsx | 5 +- .../subcomponents/AgentConnectionsStep.tsx | 7 +- src/components/onboarding/view/types.ts | 12 - src/components/onboarding/view/utils.ts | 19 - .../hooks/useProviderAuthStatus.ts | 109 +++ src/components/provider-auth/types.ts | 28 +- .../provider-auth/view/ProviderLoginModal.tsx | 35 +- .../settings/constants/constants.ts | 14 - .../settings/hooks/useSettingsController.ts | 113 +-- src/components/settings/types/types.ts | 12 +- src/components/settings/view/Settings.tsx | 31 +- .../agents-settings/AgentsSettingsTab.tsx | 39 +- .../view/tabs/agents-settings/types.ts | 11 +- .../sidebar/hooks/useSidebarController.ts | 4 +- src/components/sidebar/types/types.ts | 6 +- src/components/sidebar/view/Sidebar.tsx | 6 +- .../view/subcomponents/SidebarProjectItem.tsx | 6 +- .../view/subcomponents/SidebarProjectList.tsx | 6 +- .../subcomponents/SidebarProjectSessions.tsx | 6 +- .../view/subcomponents/SidebarSessionItem.tsx | 6 +- src/constants/config.ts | 14 +- src/stores/useSessionStore.ts | 12 +- src/types/app.ts | 4 +- tsconfig.json | 10 +- vite.config.js | 6 + 49 files changed, 1531 insertions(+), 582 deletions(-) delete mode 100644 server/database/init.sql create mode 100644 server/database/schema.js create mode 100644 server/tsconfig.json create mode 100644 server/utils/runtime-paths.js delete mode 100644 src/components/onboarding/view/types.ts create mode 100644 src/components/provider-auth/hooks/useProviderAuthStatus.ts 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')} )} +
diff --git a/src/i18n/locales/de/sidebar.json b/src/i18n/locales/de/sidebar.json index 8b26756c..b2df5190 100644 --- a/src/i18n/locales/de/sidebar.json +++ b/src/i18n/locales/de/sidebar.json @@ -2,7 +2,7 @@ "projects": { "title": "Projekte", "newProject": "Neues Projekt", - "deleteProject": "Projekt löschen", + "deleteProject": "Projekt entfernen", "renameProject": "Projekt umbenennen", "noProjects": "Keine Projekte gefunden", "loadingProjects": "Projekte werden geladen...", @@ -40,7 +40,7 @@ "createProject": "Neues Projekt erstellen", "refresh": "Projekte und Sitzungen aktualisieren (Strg+R)", "renameProject": "Projekt umbenennen (F2)", - "deleteProject": "Leeres Projekt löschen (Entf)", + "deleteProject": "Projekt aus Seitenleiste entfernen (Entf)", "addToFavorites": "Zu Favoriten hinzufügen", "removeFromFavorites": "Aus Favoriten entfernen", "editSessionName": "Sitzungsname manuell bearbeiten", @@ -95,14 +95,14 @@ "deleteSuccess": "Erfolgreich gelöscht", "errorOccurred": "Ein Fehler ist aufgetreten", "deleteSessionConfirm": "Möchtest du diese Sitzung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", - "deleteProjectConfirm": "Möchtest du dieses leere Projekt wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteProjectConfirm": "Projekt aus der Seitenleiste entfernen? Deine Projektdateien, Erinnerungen und Sitzungsdaten werden nicht gelöscht.", "enterProjectPath": "Bitte gib einen Projektpfad ein", "deleteSessionFailed": "Sitzung konnte nicht gelöscht werden. Bitte erneut versuchen.", "deleteSessionError": "Fehler beim Löschen der Sitzung. Bitte erneut versuchen.", "renameSessionFailed": "Sitzung konnte nicht umbenannt werden. Bitte erneut versuchen.", "renameSessionError": "Fehler beim Umbenennen der Sitzung. Bitte erneut versuchen.", - "deleteProjectFailed": "Projekt konnte nicht gelöscht werden. Bitte erneut versuchen.", - "deleteProjectError": "Fehler beim Löschen des Projekts. Bitte erneut versuchen.", + "deleteProjectFailed": "Projekt konnte nicht entfernt werden. Bitte erneut versuchen.", + "deleteProjectError": "Fehler beim Entfernen des Projekts. Bitte erneut versuchen.", "createProjectFailed": "Projekt konnte nicht erstellt werden. Bitte erneut versuchen.", "createProjectError": "Fehler beim Erstellen des Projekts. Bitte erneut versuchen." }, @@ -122,12 +122,14 @@ "projectsScanned_other": "{{count}} Projekte durchsucht" }, "deleteConfirmation": { - "deleteProject": "Projekt löschen", + "deleteProject": "Projekt entfernen", "deleteSession": "Sitzung löschen", - "confirmDelete": "Möchtest du wirklich löschen", + "confirmDelete": "Was möchtest du mit", "sessionCount_one": "Dieses Projekt enthält {{count}} Unterhaltung.", "sessionCount_other": "Dieses Projekt enthält {{count}} Unterhaltungen.", - "allConversationsDeleted": "Alle Unterhaltungen werden dauerhaft gelöscht.", - "cannotUndo": "Diese Aktion kann nicht rückgängig gemacht werden." + "removeFromSidebar": "Nur aus der Seitenleiste entfernen", + "deleteAllData": "Alle Daten dauerhaft löschen", + "allConversationsDeleted": "Das Projekt wird aus der Seitenleiste entfernt. Deine Dateien, Erinnerungen und Sitzungsdaten bleiben erhalten.", + "cannotUndo": "Du kannst das Projekt später erneut hinzufügen." } } diff --git a/src/i18n/locales/en/sidebar.json b/src/i18n/locales/en/sidebar.json index eab0a3f3..7b3452cb 100644 --- a/src/i18n/locales/en/sidebar.json +++ b/src/i18n/locales/en/sidebar.json @@ -2,7 +2,7 @@ "projects": { "title": "Projects", "newProject": "New Project", - "deleteProject": "Delete Project", + "deleteProject": "Remove Project", "renameProject": "Rename Project", "noProjects": "No projects found", "loadingProjects": "Loading projects...", @@ -40,7 +40,7 @@ "createProject": "Create new project", "refresh": "Refresh projects and sessions (Ctrl+R)", "renameProject": "Rename project (F2)", - "deleteProject": "Delete empty project (Delete)", + "deleteProject": "Remove project from sidebar (Delete)", "addToFavorites": "Add to favorites", "removeFromFavorites": "Remove from favorites", "editSessionName": "Manually edit session name", @@ -95,14 +95,14 @@ "deleteSuccess": "Deleted successfully", "errorOccurred": "An error occurred", "deleteSessionConfirm": "Are you sure you want to delete this session? This action cannot be undone.", - "deleteProjectConfirm": "Are you sure you want to delete this empty project? This action cannot be undone.", + "deleteProjectConfirm": "Remove this project from the sidebar? Your project files, memories, and session data will not be deleted.", "enterProjectPath": "Please enter a project path", "deleteSessionFailed": "Failed to delete session. Please try again.", "deleteSessionError": "Error deleting session. Please try again.", "renameSessionFailed": "Failed to rename session. Please try again.", "renameSessionError": "Error renaming session. Please try again.", - "deleteProjectFailed": "Failed to delete project. Please try again.", - "deleteProjectError": "Error deleting project. Please try again.", + "deleteProjectFailed": "Failed to remove project. Please try again.", + "deleteProjectError": "Error removing project. Please try again.", "createProjectFailed": "Failed to create project. Please try again.", "createProjectError": "Error creating project. Please try again." }, @@ -122,12 +122,14 @@ "projectsScanned_other": "{{count}} projects scanned" }, "deleteConfirmation": { - "deleteProject": "Delete Project", + "deleteProject": "Remove Project", "deleteSession": "Delete Session", - "confirmDelete": "Are you sure you want to delete", + "confirmDelete": "What would you like to do with", "sessionCount_one": "This project contains {{count}} conversation.", "sessionCount_other": "This project contains {{count}} conversations.", - "allConversationsDeleted": "All conversations will be permanently deleted.", - "cannotUndo": "This action cannot be undone." + "removeFromSidebar": "Remove from sidebar only", + "deleteAllData": "Delete all data permanently", + "allConversationsDeleted": "The project will be removed from the sidebar. Your files, memories, and session data will be preserved.", + "cannotUndo": "You can re-add the project later." } } diff --git a/src/i18n/locales/ja/sidebar.json b/src/i18n/locales/ja/sidebar.json index 4590c752..33c17399 100644 --- a/src/i18n/locales/ja/sidebar.json +++ b/src/i18n/locales/ja/sidebar.json @@ -2,7 +2,7 @@ "projects": { "title": "プロジェクト", "newProject": "新規プロジェクト", - "deleteProject": "プロジェクトを削除", + "deleteProject": "プロジェクトを除去", "renameProject": "プロジェクト名を変更", "noProjects": "プロジェクトが見つかりません", "loadingProjects": "プロジェクトを読み込んでいます...", @@ -40,7 +40,7 @@ "createProject": "新しいプロジェクトを作成", "refresh": "プロジェクトとセッションを更新 (Ctrl+R)", "renameProject": "プロジェクト名を変更 (F2)", - "deleteProject": "空のプロジェクトを削除 (Delete)", + "deleteProject": "サイドバーからプロジェクトを除去 (Delete)", "addToFavorites": "お気に入りに追加", "removeFromFavorites": "お気に入りから削除", "editSessionName": "セッション名を手動で編集", @@ -94,14 +94,14 @@ "deleteSuccess": "削除しました", "errorOccurred": "エラーが発生しました", "deleteSessionConfirm": "このセッションを削除してもよろしいですか?この操作は取り消せません。", - "deleteProjectConfirm": "この空のプロジェクトを削除してもよろしいですか?この操作は取り消せません。", + "deleteProjectConfirm": "サイドバーからこのプロジェクトを除去しますか?プロジェクトファイル、メモリ、セッションデータは削除されません。", "enterProjectPath": "プロジェクトのパスを入力してください", "deleteSessionFailed": "セッションの削除に失敗しました。もう一度お試しください。", "deleteSessionError": "セッションの削除でエラーが発生しました。もう一度お試しください。", "renameSessionFailed": "セッション名の変更に失敗しました。もう一度お試しください。", "renameSessionError": "セッション名の変更でエラーが発生しました。もう一度お試しください。", - "deleteProjectFailed": "プロジェクトの削除に失敗しました。もう一度お試しください。", - "deleteProjectError": "プロジェクトの削除でエラーが発生しました。もう一度お試しください。", + "deleteProjectFailed": "プロジェクトの除去に失敗しました。もう一度お試しください。", + "deleteProjectError": "プロジェクトの除去でエラーが発生しました。もう一度お試しください。", "createProjectFailed": "プロジェクトの作成に失敗しました。もう一度お試しください。", "createProjectError": "プロジェクトの作成でエラーが発生しました。もう一度お試しください。" }, @@ -109,11 +109,13 @@ "updateAvailable": "アップデートあり" }, "deleteConfirmation": { - "deleteProject": "プロジェクトを削除", + "deleteProject": "プロジェクトを除去", "deleteSession": "セッションを削除", - "confirmDelete": "本当に削除しますか?", + "confirmDelete": "このプロジェクトをどうしますか:", "sessionCount": "このプロジェクトには{{count}}件の会話があります。", - "allConversationsDeleted": "すべての会話が完全に削除されます。", - "cannotUndo": "この操作は取り消せません。" + "removeFromSidebar": "サイドバーからのみ除去", + "deleteAllData": "すべてのデータを完全に削除", + "allConversationsDeleted": "プロジェクトはサイドバーから除去されます。ファイル、メモリ、セッションデータは保持されます。", + "cannotUndo": "後からプロジェクトを再追加できます。" } } diff --git a/src/i18n/locales/ko/sidebar.json b/src/i18n/locales/ko/sidebar.json index cda7eab2..d7fcafaa 100644 --- a/src/i18n/locales/ko/sidebar.json +++ b/src/i18n/locales/ko/sidebar.json @@ -2,7 +2,7 @@ "projects": { "title": "프로젝트", "newProject": "새 프로젝트", - "deleteProject": "프로젝트 삭제", + "deleteProject": "프로젝트 제거", "renameProject": "프로젝트 이름 변경", "noProjects": "프로젝트가 없습니다", "loadingProjects": "프로젝트 로딩 중...", @@ -40,7 +40,7 @@ "createProject": "새 프로젝트 생성", "refresh": "프로젝트 및 세션 새로고침 (Ctrl+R)", "renameProject": "프로젝트 이름 변경 (F2)", - "deleteProject": "빈 프로젝트 삭제 (Delete)", + "deleteProject": "사이드바에서 프로젝트 제거 (Delete)", "addToFavorites": "즐겨찾기에 추가", "removeFromFavorites": "즐겨찾기에서 제거", "editSessionName": "세션 이름 직접 편집", @@ -94,14 +94,14 @@ "deleteSuccess": "삭제되었습니다", "errorOccurred": "오류가 발생했습니다", "deleteSessionConfirm": "이 세션을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", - "deleteProjectConfirm": "이 빈 프로젝트를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "deleteProjectConfirm": "사이드바에서 이 프로젝트를 제거하시겠습니까? 프로젝트 파일, 메모리 및 세션 데이터는 삭제되지 않습니다.", "enterProjectPath": "프로젝트 경로를 입력해주세요", "deleteSessionFailed": "세션 삭제 실패. 다시 시도해주세요.", "deleteSessionError": "세션 삭제 오류. 다시 시도해주세요.", "renameSessionFailed": "세션 이름 변경 실패. 다시 시도해주세요.", "renameSessionError": "세션 이름 변경 오류. 다시 시도해주세요.", - "deleteProjectFailed": "프로젝트 삭제 실패. 다시 시도해주세요.", - "deleteProjectError": "프로젝트 삭제 오류. 다시 시도해주세요.", + "deleteProjectFailed": "프로젝트 제거 실패. 다시 시도해주세요.", + "deleteProjectError": "프로젝트 제거 오류. 다시 시도해주세요.", "createProjectFailed": "프로젝트 생성 실패. 다시 시도해주세요.", "createProjectError": "프로젝트 생성 오류. 다시 시도해주세요." }, @@ -109,12 +109,14 @@ "updateAvailable": "업데이트 가능" }, "deleteConfirmation": { - "deleteProject": "프로젝트 삭제", + "deleteProject": "프로젝트 제거", "deleteSession": "세션 삭제", - "confirmDelete": "정말 삭제하시겠습니까", + "confirmDelete": "이 프로젝트를 어떻게 하시겠습니까:", "sessionCount_one": "이 프로젝트에는 {{count}}개의 대화가 있습니다.", "sessionCount_other": "이 프로젝트에는 {{count}}개의 대화가 있습니다.", - "allConversationsDeleted": "모든 대화가 영구적으로 삭제됩니다.", - "cannotUndo": "이 작업은 취소할 수 없습니다." + "removeFromSidebar": "사이드바에서만 제거", + "deleteAllData": "모든 데이터 영구 삭제", + "allConversationsDeleted": "프로젝트가 사이드바에서 제거됩니다. 파일, 메모리 및 세션 데이터는 보존됩니다.", + "cannotUndo": "나중에 프로젝트를 다시 추가할 수 있습니다." } } \ No newline at end of file diff --git a/src/i18n/locales/ru/sidebar.json b/src/i18n/locales/ru/sidebar.json index 71250d2f..9af33d54 100644 --- a/src/i18n/locales/ru/sidebar.json +++ b/src/i18n/locales/ru/sidebar.json @@ -2,7 +2,7 @@ "projects": { "title": "Проекты", "newProject": "Новый проект", - "deleteProject": "Удалить проект", + "deleteProject": "Убрать проект", "renameProject": "Переименовать проект", "noProjects": "Проекты не найдены", "loadingProjects": "Загрузка проектов...", @@ -40,7 +40,7 @@ "createProject": "Создать новый проект", "refresh": "Обновить проекты и сеансы (Ctrl+R)", "renameProject": "Переименовать проект (F2)", - "deleteProject": "Удалить пустой проект (Delete)", + "deleteProject": "Убрать проект из боковой панели (Delete)", "addToFavorites": "Добавить в избранное", "removeFromFavorites": "Удалить из избранного", "editSessionName": "Вручную редактировать имя сеанса", @@ -95,14 +95,14 @@ "deleteSuccess": "Успешно удалено", "errorOccurred": "Произошла ошибка", "deleteSessionConfirm": "Вы уверены, что хотите удалить этот сеанс? Это действие нельзя отменить.", - "deleteProjectConfirm": "Вы уверены, что хотите удалить этот пустой проект? Это действие нельзя отменить.", + "deleteProjectConfirm": "Убрать этот проект из боковой панели? Файлы проекта, воспоминания и данные сеансов не будут удалены.", "enterProjectPath": "Пожалуйста, введите путь к проекту", "deleteSessionFailed": "Не удалось удалить сеанс. Попробуйте снова.", "deleteSessionError": "Ошибка при удалении сеанса. Попробуйте снова.", "renameSessionFailed": "Не удалось переименовать сеанс. Попробуйте снова.", "renameSessionError": "Ошибка при переименовании сеанса. Попробуйте снова.", - "deleteProjectFailed": "Не удалось удалить проект. Попробуйте снова.", - "deleteProjectError": "Ошибка при удалении проекта. Попробуйте снова.", + "deleteProjectFailed": "Не удалось убрать проект. Попробуйте снова.", + "deleteProjectError": "Ошибка при удалении проекта из списка. Попробуйте снова.", "createProjectFailed": "Не удалось создать проект. Попробуйте снова.", "createProjectError": "Ошибка при создании проекта. Попробуйте снова." }, @@ -126,14 +126,16 @@ "projectsScanned_other": "{{count}} проектов просканировано" }, "deleteConfirmation": { - "deleteProject": "Удалить проект", + "deleteProject": "Убрать проект", "deleteSession": "Удалить сеанс", - "confirmDelete": "Вы уверены, что хотите удалить", + "confirmDelete": "Что вы хотите сделать с", "sessionCount_one": "Этот проект содержит {{count}} разговор.", "sessionCount_few": "Этот проект содержит {{count}} разговора.", "sessionCount_many": "Этот проект содержит {{count}} разговоров.", "sessionCount_other": "Этот проект содержит {{count}} разговоров.", - "allConversationsDeleted": "Все разговоры будут удалены навсегда.", - "cannotUndo": "Это действие нельзя отменить." + "removeFromSidebar": "Убрать только из боковой панели", + "deleteAllData": "Удалить все данные навсегда", + "allConversationsDeleted": "Проект будет убран из боковой панели. Ваши файлы, воспоминания и данные сеансов сохранятся.", + "cannotUndo": "Вы сможете добавить проект позже." } } diff --git a/src/i18n/locales/zh-CN/sidebar.json b/src/i18n/locales/zh-CN/sidebar.json index 3a28778c..85053d92 100644 --- a/src/i18n/locales/zh-CN/sidebar.json +++ b/src/i18n/locales/zh-CN/sidebar.json @@ -2,7 +2,7 @@ "projects": { "title": "项目", "newProject": "新建项目", - "deleteProject": "删除项目", + "deleteProject": "移除项目", "renameProject": "重命名项目", "noProjects": "未找到项目", "loadingProjects": "加载项目中...", @@ -40,7 +40,7 @@ "createProject": "创建新项目", "refresh": "刷新项目和会话 (Ctrl+R)", "renameProject": "重命名项目 (F2)", - "deleteProject": "删除空项目 (Delete)", + "deleteProject": "从侧边栏移除项目 (Delete)", "addToFavorites": "添加到收藏", "removeFromFavorites": "从收藏移除", "editSessionName": "手动编辑会话名称", @@ -95,14 +95,14 @@ "deleteSuccess": "删除成功", "errorOccurred": "发生错误", "deleteSessionConfirm": "确定要删除此会话吗?此操作无法撤销。", - "deleteProjectConfirm": "确定要删除此空项目吗?此操作无法撤销。", + "deleteProjectConfirm": "从侧边栏移除此项目?您的项目文件、记忆和会话数据不会被删除。", "enterProjectPath": "请输入项目路径", "deleteSessionFailed": "删除会话失败,请重试。", "deleteSessionError": "删除会话时出错,请重试。", "renameSessionFailed": "重命名会话失败,请重试。", "renameSessionError": "重命名会话时出错,请重试。", - "deleteProjectFailed": "删除项目失败,请重试。", - "deleteProjectError": "删除项目时出错,请重试。", + "deleteProjectFailed": "移除项目失败,请重试。", + "deleteProjectError": "移除项目时出错,请重试。", "createProjectFailed": "创建项目失败,请重试。", "createProjectError": "创建项目时出错,请重试。" }, @@ -122,12 +122,14 @@ "projectsScanned_other": "{{count}} 个项目已扫描" }, "deleteConfirmation": { - "deleteProject": "删除项目", + "deleteProject": "移除项目", "deleteSession": "删除会话", - "confirmDelete": "您确定要删除", + "confirmDelete": "您想如何处理", "sessionCount_one": "此项目包含 {{count}} 个对话。", "sessionCount_other": "此项目包含 {{count}} 个对话。", - "allConversationsDeleted": "所有对话将被永久删除。", - "cannotUndo": "此操作无法撤销。" + "removeFromSidebar": "仅从侧边栏移除", + "deleteAllData": "永久删除所有数据", + "allConversationsDeleted": "项目将从侧边栏中移除。您的文件、记忆和会话数据将会保留。", + "cannotUndo": "您可以稍后重新添加此项目。" } } diff --git a/src/utils/api.js b/src/utils/api.js index 438cab82..9132c16f 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -89,10 +89,15 @@ export const api = { authenticatedFetch(`/api/gemini/sessions/${sessionId}`, { method: 'DELETE', }), - deleteProject: (projectName, force = false) => - authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, { + deleteProject: (projectName, force = false, deleteData = false) => { + const params = new URLSearchParams(); + if (force) params.set('force', 'true'); + if (deleteData) params.set('deleteData', 'true'); + const qs = params.toString(); + return authenticatedFetch(`/api/projects/${projectName}${qs ? `?${qs}` : ''}`, { method: 'DELETE', - }), + }); + }, searchConversationsUrl: (query, limit = 50) => { const token = localStorage.getItem('auth-token'); const params = new URLSearchParams({ q: query, limit: String(limit) }); From 9ef1ab533de5aa725575e755bb6cbe98b9ba7f66 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Thu, 16 Apr 2026 12:32:25 +0200 Subject: [PATCH 08/11] Refactor CLI authentication module location (#660) * refactor: move cli-auth.js to the providers folder * fix: expired oauth token returns no error message --- server/claude-sdk.js | 13 +- server/cursor-cli.js | 9 +- server/gemini-cli.js | 18 +- server/openai-codex.js | 10 +- server/providers/claude/status.js | 136 ++++++ server/providers/codex/status.js | 78 ++++ server/providers/cursor/status.js | 128 +++++ server/providers/gemini/status.js | 111 +++++ server/providers/registry.js | 27 +- server/providers/types.js | 13 + server/routes/cli-auth.js | 441 +----------------- .../chat/view/subcomponents/ChatComposer.tsx | 5 +- 12 files changed, 555 insertions(+), 434 deletions(-) create mode 100644 server/providers/claude/status.js create mode 100644 server/providers/codex/status.js create mode 100644 server/providers/cursor/status.js create mode 100644 server/providers/gemini/status.js diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 919d8632..1489962b 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -26,13 +26,14 @@ import { } from './services/notification-orchestrator.js'; import { claudeAdapter } from './providers/claude/adapter.js'; import { createNormalizedMessage } from './providers/types.js'; +import { getStatusChecker } from './providers/registry.js'; const activeSessions = new Map(); const pendingToolApprovals = new Map(); const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000; -const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']); +const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']); function createRequestId() { if (typeof crypto.randomUUID === 'function') { @@ -705,8 +706,14 @@ async function queryClaudeSDK(command, options = {}, ws) { // Clean up temporary image files on error await cleanupTempFiles(tempImagePaths, tempDir); + // Check if Claude CLI is installed for a clearer error message + const installed = getStatusChecker('claude')?.checkInstalled() ?? true; + const errorContent = !installed + ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code' + : error.message; + // Send error to WebSocket - ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); + ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); notifyRunFailed({ userId: ws?.userId || null, provider: 'claude', @@ -714,8 +721,6 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionName: sessionSummary, error }); - - throw error; } } diff --git a/server/cursor-cli.js b/server/cursor-cli.js index aedd7e0b..f6193369 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -3,6 +3,7 @@ import crossSpawn from 'cross-spawn'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { cursorAdapter } from './providers/cursor/adapter.js'; import { createNormalizedMessage } from './providers/types.js'; +import { getStatusChecker } from './providers/registry.js'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; @@ -294,7 +295,13 @@ async function spawnCursor(command, options = {}, ws) { const finalSessionId = capturedSessionId || sessionId || processKey; activeCursorProcesses.delete(finalSessionId); - ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); + // Check if Cursor CLI is installed for a clearer error message + const installed = getStatusChecker('cursor')?.checkInstalled() ?? true; + const errorContent = !installed + ? 'Cursor CLI is not installed. Please install it from https://cursor.com' + : error.message; + + ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); notifyTerminalState({ error }); settleOnce(() => reject(error)); diff --git a/server/gemini-cli.js b/server/gemini-cli.js index 86472707..62aa5307 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -10,6 +10,7 @@ import sessionManager from './sessionManager.js'; import GeminiResponseHandler from './gemini-response-handler.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { createNormalizedMessage } from './providers/types.js'; +import { getStatusChecker } from './providers/registry.js'; let activeGeminiProcesses = new Map(); // Track active processes by session ID @@ -380,6 +381,15 @@ async function spawnGemini(command, options = {}, ws) { notifyTerminalState({ code }); resolve(); } else { + // code 127 = shell "command not found" — check installation + if (code === 127) { + const installed = getStatusChecker('gemini')?.checkInstalled() ?? true; + if (!installed) { + const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; + ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' })); + } + } + notifyTerminalState({ code, error: code === null ? 'Gemini CLI process was terminated or timed out' : null @@ -394,8 +404,14 @@ async function spawnGemini(command, options = {}, ws) { const finalSessionId = capturedSessionId || sessionId || processKey; activeGeminiProcesses.delete(finalSessionId); + // Check if Gemini CLI is installed for a clearer error message + const installed = getStatusChecker('gemini')?.checkInstalled() ?? true; + const errorContent = !installed + ? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli' + : error.message; + const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; - ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' })); + ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' })); notifyTerminalState({ error }); reject(error); diff --git a/server/openai-codex.js b/server/openai-codex.js index 0169a3b6..99a8e435 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -17,6 +17,7 @@ import { Codex } from '@openai/codex-sdk'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { codexAdapter } from './providers/codex/adapter.js'; import { createNormalizedMessage } from './providers/types.js'; +import { getStatusChecker } from './providers/registry.js'; // Track active sessions const activeCodexSessions = new Map(); @@ -308,7 +309,14 @@ export async function queryCodex(command, options = {}, ws) { if (!wasAborted) { console.error('[Codex] Error:', error); - sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' })); + + // Check if Codex SDK is available for a clearer error message + const installed = getStatusChecker('codex')?.checkInstalled() ?? true; + const errorContent = !installed + ? 'Codex CLI is not configured. Please set up authentication first.' + : error.message; + + sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: currentSessionId, provider: 'codex' })); if (!terminalFailure) { notifyRunFailed({ userId: ws?.userId || null, diff --git a/server/providers/claude/status.js b/server/providers/claude/status.js new file mode 100644 index 00000000..c0d7d231 --- /dev/null +++ b/server/providers/claude/status.js @@ -0,0 +1,136 @@ +/** + * Claude Provider Status + * + * Checks whether Claude Code CLI is installed and whether the user + * has valid authentication credentials. + * + * @module providers/claude/status + */ + +import { execFileSync } from 'child_process'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +/** + * Check if Claude Code CLI is installed and available. + * Uses CLAUDE_CLI_PATH env var if set, otherwise looks for 'claude' in PATH. + * @returns {boolean} + */ +export function checkInstalled() { + const cliPath = process.env.CLAUDE_CLI_PATH || 'claude'; + try { + execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } +} + +/** + * Full status check: installation + authentication. + * @returns {Promise} + */ +export async function checkStatus() { + const installed = checkInstalled(); + + if (!installed) { + return { + installed, + authenticated: false, + email: null, + method: null, + error: 'Claude Code CLI is not installed' + }; + } + + const credentialsResult = await checkCredentials(); + + if (credentialsResult.authenticated) { + return { + installed, + authenticated: true, + email: credentialsResult.email || 'Authenticated', + method: credentialsResult.method || null, + error: null + }; + } + + return { + installed, + authenticated: false, + email: credentialsResult.email || null, + method: credentialsResult.method || null, + error: credentialsResult.error || 'Not authenticated' + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +async function loadSettingsEnv() { + try { + const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); + const content = await fs.readFile(settingsPath, 'utf8'); + const settings = JSON.parse(content); + + if (settings?.env && typeof settings.env === 'object') { + return settings.env; + } + } catch { + // Ignore missing or malformed settings. + } + + return {}; +} + +/** + * Checks Claude authentication credentials. + * + * Priority 1: ANTHROPIC_API_KEY environment variable + * Priority 1b: ~/.claude/settings.json env values + * Priority 2: ~/.claude/.credentials.json OAuth tokens + */ +async function checkCredentials() { + if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) { + return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; + } + + const settingsEnv = await loadSettingsEnv(); + + if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) { + return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; + } + + if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) { + return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' }; + } + + try { + const credPath = path.join(os.homedir(), '.claude', '.credentials.json'); + const content = await fs.readFile(credPath, 'utf8'); + const creds = JSON.parse(content); + + const oauth = creds.claudeAiOauth; + if (oauth && oauth.accessToken) { + const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt; + if (!isExpired) { + return { + authenticated: true, + email: creds.email || creds.user || null, + method: 'credentials_file' + }; + } + + return { + authenticated: false, + email: creds.email || creds.user || null, + method: 'credentials_file', + error: 'OAuth token has expired. Please re-authenticate with claude login' + }; + } + + return { authenticated: false, email: null, method: null }; + } catch { + return { authenticated: false, email: null, method: null }; + } +} diff --git a/server/providers/codex/status.js b/server/providers/codex/status.js new file mode 100644 index 00000000..cf1c273f --- /dev/null +++ b/server/providers/codex/status.js @@ -0,0 +1,78 @@ +/** + * Codex Provider Status + * + * Checks whether the user has valid Codex authentication credentials. + * Codex uses an SDK that makes direct API calls (no external binary), + * so installation check always returns true if the server is running. + * + * @module providers/codex/status + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +/** + * Check if Codex is installed. + * Codex SDK is bundled with this application — no external binary needed. + * @returns {boolean} + */ +export function checkInstalled() { + return true; +} + +/** + * Full status check: installation + authentication. + * @returns {Promise} + */ +export async function checkStatus() { + const installed = checkInstalled(); + const result = await checkCredentials(); + + return { + installed, + authenticated: result.authenticated, + email: result.email || null, + error: result.error || null + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +async function checkCredentials() { + try { + const authPath = path.join(os.homedir(), '.codex', 'auth.json'); + const content = await fs.readFile(authPath, 'utf8'); + const auth = JSON.parse(content); + + const tokens = auth.tokens || {}; + + if (tokens.id_token || tokens.access_token) { + let email = 'Authenticated'; + if (tokens.id_token) { + try { + const parts = tokens.id_token.split('.'); + if (parts.length >= 2) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + email = payload.email || payload.user || 'Authenticated'; + } + } catch { + email = 'Authenticated'; + } + } + + return { authenticated: true, email }; + } + + if (auth.OPENAI_API_KEY) { + return { authenticated: true, email: 'API Key Auth' }; + } + + return { authenticated: false, email: null, error: 'No valid tokens found' }; + } catch (error) { + if (error.code === 'ENOENT') { + return { authenticated: false, email: null, error: 'Codex not configured' }; + } + return { authenticated: false, email: null, error: error.message }; + } +} diff --git a/server/providers/cursor/status.js b/server/providers/cursor/status.js new file mode 100644 index 00000000..127e35b7 --- /dev/null +++ b/server/providers/cursor/status.js @@ -0,0 +1,128 @@ +/** + * Cursor Provider Status + * + * Checks whether cursor-agent CLI is installed and whether the user + * is logged in. + * + * @module providers/cursor/status + */ + +import { execFileSync, spawn } from 'child_process'; + +/** + * Check if cursor-agent CLI is installed. + * @returns {boolean} + */ +export function checkInstalled() { + try { + execFileSync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } +} + +/** + * Full status check: installation + authentication. + * @returns {Promise} + */ +export async function checkStatus() { + const installed = checkInstalled(); + + if (!installed) { + return { + installed, + authenticated: false, + email: null, + error: 'Cursor CLI is not installed' + }; + } + + const result = await checkCursorLogin(); + + return { + installed, + authenticated: result.authenticated, + email: result.email || null, + error: result.error || null + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +function checkCursorLogin() { + return new Promise((resolve) => { + let processCompleted = false; + + const timeout = setTimeout(() => { + if (!processCompleted) { + processCompleted = true; + if (childProcess) { + childProcess.kill(); + } + resolve({ + authenticated: false, + email: null, + error: 'Command timeout' + }); + } + }, 5000); + + let childProcess; + try { + childProcess = spawn('cursor-agent', ['status']); + } catch { + clearTimeout(timeout); + processCompleted = true; + resolve({ + authenticated: false, + email: null, + error: 'Cursor CLI not found or not installed' + }); + return; + } + + let stdout = ''; + let stderr = ''; + + childProcess.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + childProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + childProcess.on('close', (code) => { + if (processCompleted) return; + processCompleted = true; + clearTimeout(timeout); + + if (code === 0) { + const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); + + if (emailMatch) { + resolve({ authenticated: true, email: emailMatch[1] }); + } else if (stdout.includes('Logged in')) { + resolve({ authenticated: true, email: 'Logged in' }); + } else { + resolve({ authenticated: false, email: null, error: 'Not logged in' }); + } + } else { + resolve({ authenticated: false, email: null, error: stderr || 'Not logged in' }); + } + }); + + childProcess.on('error', () => { + if (processCompleted) return; + processCompleted = true; + clearTimeout(timeout); + + resolve({ + authenticated: false, + email: null, + error: 'Cursor CLI not found or not installed' + }); + }); + }); +} diff --git a/server/providers/gemini/status.js b/server/providers/gemini/status.js new file mode 100644 index 00000000..385f889f --- /dev/null +++ b/server/providers/gemini/status.js @@ -0,0 +1,111 @@ +/** + * Gemini Provider Status + * + * Checks whether Gemini CLI is installed and whether the user + * has valid authentication credentials. + * + * @module providers/gemini/status + */ + +import { execFileSync } from 'child_process'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +/** + * Check if Gemini CLI is installed. + * Uses GEMINI_PATH env var if set, otherwise looks for 'gemini' in PATH. + * @returns {boolean} + */ +export function checkInstalled() { + const cliPath = process.env.GEMINI_PATH || 'gemini'; + try { + execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } +} + +/** + * Full status check: installation + authentication. + * @returns {Promise} + */ +export async function checkStatus() { + const installed = checkInstalled(); + + if (!installed) { + return { + installed, + authenticated: false, + email: null, + error: 'Gemini CLI is not installed' + }; + } + + const result = await checkCredentials(); + + return { + installed, + authenticated: result.authenticated, + email: result.email || null, + error: result.error || null + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +async function checkCredentials() { + if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) { + return { authenticated: true, email: 'API Key Auth' }; + } + + try { + const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + const content = await fs.readFile(credsPath, 'utf8'); + const creds = JSON.parse(content); + + if (creds.access_token) { + let email = 'OAuth Session'; + + try { + const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`); + if (tokenRes.ok) { + const tokenInfo = await tokenRes.json(); + if (tokenInfo.email) { + email = tokenInfo.email; + } + } else if (!creds.refresh_token) { + return { + authenticated: false, + email: null, + error: 'Access token invalid and no refresh token found' + }; + } else { + // Token might be expired but we have a refresh token, so CLI will refresh it + email = await getActiveAccountEmail() || email; + } + } catch { + // Network error, fallback to checking local accounts file + email = await getActiveAccountEmail() || email; + } + + return { authenticated: true, email }; + } + + return { authenticated: false, email: null, error: 'No valid tokens found in oauth_creds' }; + } catch { + return { authenticated: false, email: null, error: 'Gemini CLI not configured' }; + } +} + +async function getActiveAccountEmail() { + try { + const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); + const accContent = await fs.readFile(accPath, 'utf8'); + const accounts = JSON.parse(accContent); + return accounts.active || null; + } catch { + return null; + } +} diff --git a/server/providers/registry.js b/server/providers/registry.js index 236c909e..4f62b60b 100644 --- a/server/providers/registry.js +++ b/server/providers/registry.js @@ -1,8 +1,9 @@ /** * Provider Registry * - * Centralizes provider adapter lookup. All code that needs a provider adapter - * should go through this registry instead of importing individual adapters directly. + * Centralizes provider adapter and status checker lookup. All code that needs + * a provider adapter or status checker should go through this registry instead + * of importing individual modules directly. * * @module providers/registry */ @@ -12,6 +13,11 @@ import { cursorAdapter } from './cursor/adapter.js'; import { codexAdapter } from './codex/adapter.js'; import { geminiAdapter } from './gemini/adapter.js'; +import * as claudeStatus from './claude/status.js'; +import * as cursorStatus from './cursor/status.js'; +import * as codexStatus from './codex/status.js'; +import * as geminiStatus from './gemini/status.js'; + /** * @typedef {import('./types.js').ProviderAdapter} ProviderAdapter * @typedef {import('./types.js').SessionProvider} SessionProvider @@ -20,12 +26,20 @@ import { geminiAdapter } from './gemini/adapter.js'; /** @type {Map} */ const providers = new Map(); +/** @type {Map boolean, checkStatus: () => Promise }>} */ +const statusCheckers = new Map(); + // Register built-in providers providers.set('claude', claudeAdapter); providers.set('cursor', cursorAdapter); providers.set('codex', codexAdapter); providers.set('gemini', geminiAdapter); +statusCheckers.set('claude', claudeStatus); +statusCheckers.set('cursor', cursorStatus); +statusCheckers.set('codex', codexStatus); +statusCheckers.set('gemini', geminiStatus); + /** * Get a provider adapter by name. * @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini') @@ -35,6 +49,15 @@ export function getProvider(name) { return providers.get(name); } +/** + * Get a provider status checker by name. + * @param {string} name - Provider name + * @returns {{ checkInstalled: () => boolean, checkStatus: () => Promise } | undefined} + */ +export function getStatusChecker(name) { + return statusCheckers.get(name); +} + /** * Get all registered provider names. * @returns {string[]} diff --git a/server/providers/types.js b/server/providers/types.js index 5541525b..9867b077 100644 --- a/server/providers/types.js +++ b/server/providers/types.js @@ -69,6 +69,19 @@ * @property {object} [tokenUsage] - Token usage data (provider-specific) */ +// ─── Provider Status ──────────────────────────────────────────────────────── + +/** + * Result of a provider status check (installation + authentication). + * + * @typedef {Object} ProviderStatus + * @property {boolean} installed - Whether the provider's CLI/SDK is available + * @property {boolean} authenticated - Whether valid credentials exist + * @property {string|null} email - User email or auth method identifier + * @property {string|null} [method] - Auth method (e.g. 'api_key', 'credentials_file') + * @property {string|null} [error] - Error message if not installed or not authenticated + */ + // ─── Provider Adapter Interface ────────────────────────────────────────────── /** diff --git a/server/routes/cli-auth.js b/server/routes/cli-auth.js index 78ffa30b..4183e83f 100644 --- a/server/routes/cli-auth.js +++ b/server/routes/cli-auth.js @@ -1,434 +1,27 @@ +/** + * CLI Auth Routes + * + * Thin router that delegates to per-provider status checkers + * registered in the provider registry. + * + * @module routes/cli-auth + */ + import express from 'express'; -import { spawn } from 'child_process'; -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; +import { getAllProviders, getStatusChecker } from '../providers/registry.js'; const router = express.Router(); -router.get('/claude/status', async (req, res) => { - try { - const credentialsResult = await checkClaudeCredentials(); - - if (credentialsResult.authenticated) { - return res.json({ - authenticated: true, - email: credentialsResult.email || 'Authenticated', - method: credentialsResult.method // 'api_key' or 'credentials_file' - }); - } - - return res.json({ - authenticated: false, - email: null, - method: null, - error: credentialsResult.error || 'Not authenticated' - }); - - } catch (error) { - console.error('Error checking Claude auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - method: null, - error: error.message - }); - } -}); - -router.get('/cursor/status', async (req, res) => { - try { - const result = await checkCursorStatus(); - - res.json({ - authenticated: result.authenticated, - email: result.email, - error: result.error - }); - - } catch (error) { - console.error('Error checking Cursor auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - error: error.message - }); - } -}); - -router.get('/codex/status', async (req, res) => { - try { - const result = await checkCodexCredentials(); - - res.json({ - authenticated: result.authenticated, - email: result.email, - error: result.error - }); - - } catch (error) { - console.error('Error checking Codex auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - error: error.message - }); - } -}); - -router.get('/gemini/status', async (req, res) => { - try { - const result = await checkGeminiCredentials(); - - res.json({ - authenticated: result.authenticated, - email: result.email, - error: result.error - }); - - } catch (error) { - console.error('Error checking Gemini auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - error: error.message - }); - } -}); - -async function loadClaudeSettingsEnv() { - try { - const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); - const content = await fs.readFile(settingsPath, 'utf8'); - const settings = JSON.parse(content); - - if (settings?.env && typeof settings.env === 'object') { - return settings.env; - } - } catch (error) { - // Ignore missing or malformed settings and fall back to other auth sources. - } - - return {}; -} - -/** - * Checks Claude authentication credentials using two methods with priority order: - * - * Priority 1: ANTHROPIC_API_KEY environment variable - * Priority 1b: ~/.claude/settings.json env values - * Priority 2: ~/.claude/.credentials.json OAuth tokens - * - * The Claude Agent SDK prioritizes environment variables over authenticated subscriptions. - * This matching behavior ensures consistency with how the SDK authenticates. - * - * References: - * - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code - * "Claude Code prioritizes environment variable API keys over authenticated subscriptions" - * - https://platform.claude.com/docs/en/agent-sdk/overview - * SDK authentication documentation - * - * @returns {Promise} Authentication status with { authenticated, email, method } - * - authenticated: boolean indicating if valid credentials exist - * - email: user email or auth method identifier - * - method: 'api_key' for env var, 'credentials_file' for OAuth tokens - */ -async function checkClaudeCredentials() { - // Priority 1: Check for ANTHROPIC_API_KEY environment variable - // The SDK checks this first and uses it if present, even if OAuth tokens exist. - // When set, API calls are charged via pay-as-you-go rates instead of subscription. - if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) { - return { - authenticated: true, - email: 'API Key Auth', - method: 'api_key' - }; - } - - // Priority 1b: Check ~/.claude/settings.json env values. - // Claude Code can read proxy/auth values from settings.json even when the - // CloudCLI server process itself was not started with those env vars exported. - const settingsEnv = await loadClaudeSettingsEnv(); - - if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) { - return { - authenticated: true, - email: 'API Key Auth', - method: 'api_key' - }; - } - - if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) { - return { - authenticated: true, - email: 'Configured via settings.json', - method: 'api_key' - }; - } - - // Priority 2: Check ~/.claude/.credentials.json for OAuth tokens - // This is the standard authentication method used by Claude CLI after running - // 'claude /login' or 'claude setup-token' commands. - try { - const credPath = path.join(os.homedir(), '.claude', '.credentials.json'); - const content = await fs.readFile(credPath, 'utf8'); - const creds = JSON.parse(content); - - const oauth = creds.claudeAiOauth; - if (oauth && oauth.accessToken) { - const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt; - - if (!isExpired) { - return { - authenticated: true, - email: creds.email || creds.user || null, - method: 'credentials_file' - }; - } - } - - return { - authenticated: false, - email: null, - method: null - }; - } catch (error) { - return { - authenticated: false, - email: null, - method: null - }; - } -} - -function checkCursorStatus() { - return new Promise((resolve) => { - let processCompleted = false; - - const timeout = setTimeout(() => { - if (!processCompleted) { - processCompleted = true; - if (childProcess) { - childProcess.kill(); - } - resolve({ - authenticated: false, - email: null, - error: 'Command timeout' - }); - } - }, 5000); - - let childProcess; +for (const provider of getAllProviders()) { + router.get(`/${provider}/status`, async (req, res) => { try { - childProcess = spawn('cursor-agent', ['status']); - } catch (err) { - clearTimeout(timeout); - processCompleted = true; - resolve({ - authenticated: false, - email: null, - error: 'Cursor CLI not found or not installed' - }); - return; + const checker = getStatusChecker(provider); + res.json(await checker.checkStatus()); + } catch (error) { + console.error(`Error checking ${provider} status:`, error); + res.status(500).json({ authenticated: false, error: error.message }); } - - let stdout = ''; - let stderr = ''; - - childProcess.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - childProcess.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - childProcess.on('close', (code) => { - if (processCompleted) return; - processCompleted = true; - clearTimeout(timeout); - - if (code === 0) { - const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); - - if (emailMatch) { - resolve({ - authenticated: true, - email: emailMatch[1], - output: stdout - }); - } else if (stdout.includes('Logged in')) { - resolve({ - authenticated: true, - email: 'Logged in', - output: stdout - }); - } else { - resolve({ - authenticated: false, - email: null, - error: 'Not logged in' - }); - } - } else { - resolve({ - authenticated: false, - email: null, - error: stderr || 'Not logged in' - }); - } - }); - - childProcess.on('error', (err) => { - if (processCompleted) return; - processCompleted = true; - clearTimeout(timeout); - - resolve({ - authenticated: false, - email: null, - error: 'Cursor CLI not found or not installed' - }); - }); }); } -async function checkCodexCredentials() { - try { - const authPath = path.join(os.homedir(), '.codex', 'auth.json'); - const content = await fs.readFile(authPath, 'utf8'); - const auth = JSON.parse(content); - - // Tokens are nested under 'tokens' key - const tokens = auth.tokens || {}; - - // Check for valid tokens (id_token or access_token) - if (tokens.id_token || tokens.access_token) { - // Try to extract email from id_token JWT payload - let email = 'Authenticated'; - if (tokens.id_token) { - try { - // JWT is base64url encoded: header.payload.signature - const parts = tokens.id_token.split('.'); - if (parts.length >= 2) { - // Decode the payload (second part) - const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); - email = payload.email || payload.user || 'Authenticated'; - } - } catch { - // If JWT decoding fails, use fallback - email = 'Authenticated'; - } - } - - return { - authenticated: true, - email - }; - } - - // Also check for OPENAI_API_KEY as fallback auth method - if (auth.OPENAI_API_KEY) { - return { - authenticated: true, - email: 'API Key Auth' - }; - } - - return { - authenticated: false, - email: null, - error: 'No valid tokens found' - }; - } catch (error) { - if (error.code === 'ENOENT') { - return { - authenticated: false, - email: null, - error: 'Codex not configured' - }; - } - return { - authenticated: false, - email: null, - error: error.message - }; - } -} - -async function checkGeminiCredentials() { - if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) { - return { - authenticated: true, - email: 'API Key Auth' - }; - } - - try { - const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); - const content = await fs.readFile(credsPath, 'utf8'); - const creds = JSON.parse(content); - - if (creds.access_token) { - let email = 'OAuth Session'; - - try { - // Validate token against Google API - const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`); - if (tokenRes.ok) { - const tokenInfo = await tokenRes.json(); - if (tokenInfo.email) { - email = tokenInfo.email; - } - } else if (!creds.refresh_token) { - // Token invalid and no refresh token available - return { - authenticated: false, - email: null, - error: 'Access token invalid and no refresh token found' - }; - } else { - // Token might be expired but we have a refresh token, so CLI will refresh it - try { - const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); - const accContent = await fs.readFile(accPath, 'utf8'); - const accounts = JSON.parse(accContent); - if (accounts.active) { - email = accounts.active; - } - } catch (e) { } - } - } catch (e) { - // Network error, fallback to checking local accounts file - try { - const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); - const accContent = await fs.readFile(accPath, 'utf8'); - const accounts = JSON.parse(accContent); - if (accounts.active) { - email = accounts.active; - } - } catch (err) { } - } - - return { - authenticated: true, - email: email - }; - } - - return { - authenticated: false, - email: null, - error: 'No valid tokens found in oauth_creds' - }; - } catch (error) { - return { - authenticated: false, - email: null, - error: 'Gemini CLI not configured' - }; - } -} - export default router; diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index e6da236d..69e8fc3c 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -160,6 +160,9 @@ export default function ChatComposer({ (r) => r.toolName === 'AskUserQuestion' ); + // Hide the thinking/status bar while any permission request is pending + const hasPendingPermissions = pendingPermissionRequests.length > 0; + // On mobile, when input is focused, float the input box at the bottom const mobileFloatingClass = isInputFocused ? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]' @@ -167,7 +170,7 @@ export default function ChatComposer({ return (
- {!hasQuestionPanel && ( + {!hasPendingPermissions && (
Date: Thu, 16 Apr 2026 10:33:45 +0000 Subject: [PATCH 09/11] chore(release): v1.29.4 --- CHANGELOG.md | 19 +++++++++++++++++++ package-lock.json | 4 ++-- package.json | 4 ++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8eeebf..5890928b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ All notable changes to CloudCLI UI will be documented in this file. +## [1.29.4](https://github.com/siteboon/claudecodeui/compare/v1.29.3...v1.29.4) (2026-04-16) + +### New Features + +* deleting from sidebar will now ask whether to remove all data as well ([e9c7a50](https://github.com/siteboon/claudecodeui/commit/e9c7a5041c31a6f7b2032f06abe19c52d3d4cd8c)) + +### Bug Fixes + +* pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set ([4c106a5](https://github.com/siteboon/claudecodeui/commit/4c106a5083d90989bbeedaefdbb68f5b3fa6fd58)), closes [#468](https://github.com/siteboon/claudecodeui/issues/468) + +### Refactoring + +* remove the sqlite3 dependency ([2895208](https://github.com/siteboon/claudecodeui/commit/289520814cf3ca36403056739ef22021f78c6033)) +* **server:** extract URL detection and color utils from index.js ([#657](https://github.com/siteboon/claudecodeui/issues/657)) ([63e996b](https://github.com/siteboon/claudecodeui/commit/63e996bb77cfa97b1f55f6bdccc50161a75a3eee)) + +### Maintenance + +* upgrade commit lint to 20.5.0 ([0948601](https://github.com/siteboon/claudecodeui/commit/09486016e67d97358c228ebc6eb4502ccb0012e4)) + ## [1.29.3](https://github.com/siteboon/claudecodeui/compare/v1.29.2...v1.29.3) (2026-04-15) ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index 2bb0c748..043b2d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.29.3", + "version": "1.29.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cloudcli-ai/cloudcli", - "version": "1.29.3", + "version": "1.29.4", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 457d132b..12a1e36f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.29.3", + "version": "1.29.4", "description": "A web-based UI for Claude Code CLI", "type": "module", "main": "dist-server/server/index.js", @@ -117,7 +117,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", -"tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.3.1", "web-push": "^3.6.7", "ws": "^8.14.2" }, From 6a13e1773b145049ade512aa6e5cac21c2e5c4de Mon Sep 17 00:00:00 2001 From: simosmik Date: Thu, 16 Apr 2026 10:52:55 +0000 Subject: [PATCH 10/11] fix: update node-pty to latest version --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 043b2d8d..4043f34e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "mime-types": "^3.0.1", "multer": "^2.0.1", "node-fetch": "^2.7.0", - "node-pty": "^1.1.0-beta34", + "node-pty": "^1.2.0-beta.12", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", @@ -12245,9 +12245,9 @@ } }, "node_modules/node-pty": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0.tgz", - "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.12.tgz", + "integrity": "sha512-uExTCG/4VmSJa4+TjxFwPXv8BfacmfFEBL6JpxCMDghcwqzvD0yTcGmZ1fKOK6HY33tp0CelLblqTECJizc+Yw==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 12a1e36f..722cc39e 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "mime-types": "^3.0.1", "multer": "^2.0.1", "node-fetch": "^2.7.0", - "node-pty": "^1.1.0-beta34", + "node-pty": "^1.2.0-beta.12", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", From 25b00b58de907142408da292b9640dcdf7746242 Mon Sep 17 00:00:00 2001 From: viper151 Date: Thu, 16 Apr 2026 11:02:31 +0000 Subject: [PATCH 11/11] chore(release): v1.29.5 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5890928b..1f81abd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to CloudCLI UI will be documented in this file. +## [1.29.5](https://github.com/siteboon/claudecodeui/compare/v1.29.4...v1.29.5) (2026-04-16) + +### Bug Fixes + +* update node-pty to latest version ([6a13e17](https://github.com/siteboon/claudecodeui/commit/6a13e1773b145049ade512aa6e5cac21c2e5c4de)) + ## [1.29.4](https://github.com/siteboon/claudecodeui/compare/v1.29.3...v1.29.4) (2026-04-16) ### New Features diff --git a/package-lock.json b/package-lock.json index 4043f34e..7ea371ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.29.4", + "version": "1.29.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cloudcli-ai/cloudcli", - "version": "1.29.4", + "version": "1.29.5", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 722cc39e..5be1fd99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.29.4", + "version": "1.29.5", "description": "A web-based UI for Claude Code CLI", "type": "module", "main": "dist-server/server/index.js",