mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Move @roo-code/cloud to the Roo-Code repo (#7503)
This commit is contained in:
parent
aee531a143
commit
cd9e92fa9b
163 changed files with 8749 additions and 649 deletions
|
|
@ -22,7 +22,7 @@
|
|||
"embla-carousel-auto-scroll": "^8.6.0",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.15.0",
|
||||
"framer-motion": "12.15.0",
|
||||
"lucide-react": "^0.518.0",
|
||||
"next": "^15.2.5",
|
||||
"next-themes": "^0.4.6",
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@
|
|||
"knip": "knip --include files",
|
||||
"update-contributors": "node scripts/update-contributors.js",
|
||||
"evals": "dotenvx run -f packages/evals/.env.development packages/evals/.env.local -- docker compose -f packages/evals/docker-compose.yml --profile server --profile runner up --build --scale runner=0",
|
||||
"npm:publish:types": "pnpm --filter @roo-code/types npm:publish",
|
||||
"link-workspace-packages": "tsx scripts/link-packages.ts",
|
||||
"unlink-workspace-packages": "tsx scripts/link-packages.ts --unlink"
|
||||
"npm:publish:types": "pnpm --filter @roo-code/types npm:publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.10",
|
||||
|
|
@ -47,7 +45,7 @@
|
|||
"prettier": "^3.4.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"tsx": "^4.19.3",
|
||||
"turbo": "^2.5.3",
|
||||
"turbo": "^2.5.6",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
|
|
|||
20
packages/cloud/eslint.config.mjs
Normal file
20
packages/cloud/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { config } from "@roo-code/config-eslint/base"
|
||||
import globals from "globals"
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default [
|
||||
...config,
|
||||
{
|
||||
files: ["**/*.cjs"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.commonjs,
|
||||
},
|
||||
sourceType: "commonjs",
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
},
|
||||
},
|
||||
]
|
||||
29
packages/cloud/package.json
Normal file
29
packages/cloud/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "@roo-code/cloud",
|
||||
"description": "Roo Code Cloud services.",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src --ext=ts --max-warnings=0",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rimraf .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@roo-code/types": "workspace:^",
|
||||
"ioredis": "^5.6.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"p-wait-for": "^5.0.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@roo-code/config-eslint": "workspace:^",
|
||||
"@roo-code/config-typescript": "workspace:^",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/vscode": "^1.102.0",
|
||||
"globals": "^16.3.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
137
packages/cloud/src/CloudAPI.ts
Normal file
137
packages/cloud/src/CloudAPI.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { z } from "zod"
|
||||
|
||||
import { type AuthService, type ShareVisibility, type ShareResponse, shareResponseSchema } from "@roo-code/types"
|
||||
|
||||
import { getRooCodeApiUrl } from "./config.js"
|
||||
import { getUserAgent } from "./utils.js"
|
||||
import { AuthenticationError, CloudAPIError, NetworkError, TaskNotFoundError } from "./errors.js"
|
||||
|
||||
interface CloudAPIRequestOptions extends Omit<RequestInit, "headers"> {
|
||||
timeout?: number
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export class CloudAPI {
|
||||
private authService: AuthService
|
||||
private log: (...args: unknown[]) => void
|
||||
private baseUrl: string
|
||||
|
||||
constructor(authService: AuthService, log?: (...args: unknown[]) => void) {
|
||||
this.authService = authService
|
||||
this.log = log || console.log
|
||||
this.baseUrl = getRooCodeApiUrl()
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: CloudAPIRequestOptions & {
|
||||
parseResponse?: (data: unknown) => T
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const { timeout = 30_000, parseResponse, headers = {}, ...fetchOptions } = options
|
||||
|
||||
const sessionToken = this.authService.getSessionToken()
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new AuthenticationError()
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
|
||||
const requestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
"User-Agent": getUserAgent(),
|
||||
...headers,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: requestHeaders,
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handleErrorResponse(response, endpoint)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (parseResponse) {
|
||||
return parseResponse(data)
|
||||
}
|
||||
|
||||
return data as T
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
throw new NetworkError(`Network error while calling ${endpoint}`)
|
||||
}
|
||||
|
||||
if (error instanceof CloudAPIError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new CloudAPIError(`Request to ${endpoint} timed out`, undefined, undefined)
|
||||
}
|
||||
|
||||
throw new CloudAPIError(
|
||||
`Unexpected error while calling ${endpoint}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleErrorResponse(response: Response, endpoint: string): Promise<never> {
|
||||
let responseBody: unknown
|
||||
|
||||
try {
|
||||
responseBody = await response.json()
|
||||
} catch {
|
||||
responseBody = await response.text()
|
||||
}
|
||||
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
throw new AuthenticationError()
|
||||
case 404:
|
||||
if (endpoint.includes("/share")) {
|
||||
throw new TaskNotFoundError()
|
||||
}
|
||||
throw new CloudAPIError(`Resource not found: ${endpoint}`, 404, responseBody)
|
||||
default:
|
||||
throw new CloudAPIError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
responseBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async shareTask(taskId: string, visibility: ShareVisibility = "organization"): Promise<ShareResponse> {
|
||||
this.log(`[CloudAPI] Sharing task ${taskId} with visibility: ${visibility}`)
|
||||
|
||||
const response = await this.request("/api/extension/share", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ taskId, visibility }),
|
||||
parseResponse: (data) => shareResponseSchema.parse(data),
|
||||
})
|
||||
|
||||
this.log("[CloudAPI] Share response:", response)
|
||||
return response
|
||||
}
|
||||
|
||||
async bridgeConfig() {
|
||||
return this.request("/api/extension/bridge/config", {
|
||||
method: "GET",
|
||||
parseResponse: (data) =>
|
||||
z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
socketBridgeUrl: z.string(),
|
||||
token: z.string(),
|
||||
})
|
||||
.parse(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
363
packages/cloud/src/CloudService.ts
Normal file
363
packages/cloud/src/CloudService.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import type { Disposable, ExtensionContext } from "vscode"
|
||||
import EventEmitter from "events"
|
||||
|
||||
import type {
|
||||
TelemetryEvent,
|
||||
ClineMessage,
|
||||
CloudServiceEvents,
|
||||
AuthService,
|
||||
SettingsService,
|
||||
CloudUserInfo,
|
||||
OrganizationAllowList,
|
||||
OrganizationSettings,
|
||||
ShareVisibility,
|
||||
UserSettingsConfig,
|
||||
UserSettingsData,
|
||||
UserFeatures,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { TaskNotFoundError } from "./errors.js"
|
||||
import { WebAuthService } from "./WebAuthService.js"
|
||||
import { StaticTokenAuthService } from "./StaticTokenAuthService.js"
|
||||
import { CloudSettingsService } from "./CloudSettingsService.js"
|
||||
import { StaticSettingsService } from "./StaticSettingsService.js"
|
||||
import { CloudTelemetryClient as TelemetryClient } from "./TelemetryClient.js"
|
||||
import { CloudShareService } from "./CloudShareService.js"
|
||||
import { CloudAPI } from "./CloudAPI.js"
|
||||
|
||||
type AuthStateChangedPayload = CloudServiceEvents["auth-state-changed"][0]
|
||||
type AuthUserInfoPayload = CloudServiceEvents["user-info"][0]
|
||||
type SettingsPayload = CloudServiceEvents["settings-updated"][0]
|
||||
|
||||
export class CloudService extends EventEmitter<CloudServiceEvents> implements Disposable {
|
||||
private static _instance: CloudService | null = null
|
||||
|
||||
private context: ExtensionContext
|
||||
|
||||
private authStateListener: (data: AuthStateChangedPayload) => void
|
||||
private authUserInfoListener: (data: AuthUserInfoPayload) => void
|
||||
private settingsListener: (data: SettingsPayload) => void
|
||||
|
||||
private isInitialized = false
|
||||
private log: (...args: unknown[]) => void
|
||||
|
||||
/**
|
||||
* Services
|
||||
*/
|
||||
|
||||
private _authService: AuthService | null = null
|
||||
|
||||
public get authService() {
|
||||
return this._authService
|
||||
}
|
||||
|
||||
private _settingsService: SettingsService | null = null
|
||||
|
||||
public get settingsService() {
|
||||
return this._settingsService
|
||||
}
|
||||
|
||||
private _telemetryClient: TelemetryClient | null = null
|
||||
|
||||
public get telemetryClient() {
|
||||
return this._telemetryClient
|
||||
}
|
||||
|
||||
private _shareService: CloudShareService | null = null
|
||||
|
||||
public get shareService() {
|
||||
return this._shareService
|
||||
}
|
||||
|
||||
private _cloudAPI: CloudAPI | null = null
|
||||
|
||||
public get cloudAPI() {
|
||||
return this._cloudAPI
|
||||
}
|
||||
|
||||
private constructor(context: ExtensionContext, log?: (...args: unknown[]) => void) {
|
||||
super()
|
||||
|
||||
this.context = context
|
||||
this.log = log || console.log
|
||||
|
||||
this.authStateListener = (data: AuthStateChangedPayload) => {
|
||||
this.emit("auth-state-changed", data)
|
||||
}
|
||||
|
||||
this.authUserInfoListener = (data: AuthUserInfoPayload) => {
|
||||
this.emit("user-info", data)
|
||||
}
|
||||
|
||||
this.settingsListener = (data: SettingsPayload) => {
|
||||
this.emit("settings-updated", data)
|
||||
}
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// For testing you can create a token with:
|
||||
// `pnpm --filter @roo-code-cloud/roomote-cli development auth job-token --job-id 1 --user-id user_2xmBhejNeDTwanM8CgIOnMgVxzC --org-id org_2wbhchVXZMQl8OS1yt0mrDazCpW`
|
||||
// The token will last for 1 hour.
|
||||
const cloudToken = process.env.ROO_CODE_CLOUD_TOKEN
|
||||
|
||||
if (cloudToken && cloudToken.length > 0) {
|
||||
this._authService = new StaticTokenAuthService(this.context, cloudToken, this.log)
|
||||
} else {
|
||||
this._authService = new WebAuthService(this.context, this.log)
|
||||
}
|
||||
|
||||
this._authService.on("auth-state-changed", this.authStateListener)
|
||||
this._authService.on("user-info", this.authUserInfoListener)
|
||||
await this._authService.initialize()
|
||||
|
||||
// Check for static settings environment variable.
|
||||
const staticOrgSettings = process.env.ROO_CODE_CLOUD_ORG_SETTINGS
|
||||
|
||||
if (staticOrgSettings && staticOrgSettings.length > 0) {
|
||||
this._settingsService = new StaticSettingsService(staticOrgSettings, this.log)
|
||||
} else {
|
||||
const cloudSettingsService = new CloudSettingsService(this.context, this._authService, this.log)
|
||||
|
||||
cloudSettingsService.on("settings-updated", this.settingsListener)
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
this._settingsService = cloudSettingsService
|
||||
}
|
||||
|
||||
this._cloudAPI = new CloudAPI(this._authService, this.log)
|
||||
|
||||
this._telemetryClient = new TelemetryClient(this._authService, this._settingsService)
|
||||
|
||||
this._shareService = new CloudShareService(this._cloudAPI, this._settingsService, this.log)
|
||||
|
||||
this.isInitialized = true
|
||||
} catch (error) {
|
||||
this.log("[CloudService] Failed to initialize:", error)
|
||||
throw new Error(`Failed to initialize CloudService: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// AuthService
|
||||
|
||||
public async login(): Promise<void> {
|
||||
this.ensureInitialized()
|
||||
return this.authService!.login()
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
this.ensureInitialized()
|
||||
return this.authService!.logout()
|
||||
}
|
||||
|
||||
public isAuthenticated(): boolean {
|
||||
this.ensureInitialized()
|
||||
return this.authService!.isAuthenticated()
|
||||
}
|
||||
|
||||
public hasActiveSession(): boolean {
|
||||
this.ensureInitialized()
|
||||
return this.authService!.hasActiveSession()
|
||||
}
|
||||
|
||||
public hasOrIsAcquiringActiveSession(): boolean {
|
||||
this.ensureInitialized()
|
||||
return this.authService!.hasOrIsAcquiringActiveSession()
|
||||
}
|
||||
|
||||
public getUserInfo(): CloudUserInfo | null {
|
||||
this.ensureInitialized()
|
||||
return this.authService!.getUserInfo()
|
||||
}
|
||||
|
||||
public getOrganizationId(): string | null {
|
||||
this.ensureInitialized()
|
||||
const userInfo = this.authService!.getUserInfo()
|
||||
return userInfo?.organizationId || null
|
||||
}
|
||||
|
||||
public getOrganizationName(): string | null {
|
||||
this.ensureInitialized()
|
||||
const userInfo = this.authService!.getUserInfo()
|
||||
return userInfo?.organizationName || null
|
||||
}
|
||||
|
||||
public getOrganizationRole(): string | null {
|
||||
this.ensureInitialized()
|
||||
const userInfo = this.authService!.getUserInfo()
|
||||
return userInfo?.organizationRole || null
|
||||
}
|
||||
|
||||
public hasStoredOrganizationId(): boolean {
|
||||
this.ensureInitialized()
|
||||
return this.authService!.getStoredOrganizationId() !== null
|
||||
}
|
||||
|
||||
public getStoredOrganizationId(): string | null {
|
||||
this.ensureInitialized()
|
||||
return this.authService!.getStoredOrganizationId()
|
||||
}
|
||||
|
||||
public getAuthState(): string {
|
||||
this.ensureInitialized()
|
||||
return this.authService!.getState()
|
||||
}
|
||||
|
||||
public async handleAuthCallback(
|
||||
code: string | null,
|
||||
state: string | null,
|
||||
organizationId?: string | null,
|
||||
): Promise<void> {
|
||||
this.ensureInitialized()
|
||||
return this.authService!.handleCallback(code, state, organizationId)
|
||||
}
|
||||
|
||||
// SettingsService
|
||||
|
||||
public getAllowList(): OrganizationAllowList {
|
||||
this.ensureInitialized()
|
||||
return this.settingsService!.getAllowList()
|
||||
}
|
||||
|
||||
public getOrganizationSettings(): OrganizationSettings | undefined {
|
||||
this.ensureInitialized()
|
||||
return this.settingsService!.getSettings()
|
||||
}
|
||||
|
||||
public getUserSettings(): UserSettingsData | undefined {
|
||||
this.ensureInitialized()
|
||||
return this.settingsService!.getUserSettings()
|
||||
}
|
||||
|
||||
public getUserFeatures(): UserFeatures {
|
||||
this.ensureInitialized()
|
||||
return this.settingsService!.getUserFeatures()
|
||||
}
|
||||
|
||||
public getUserSettingsConfig(): UserSettingsConfig {
|
||||
this.ensureInitialized()
|
||||
return this.settingsService!.getUserSettingsConfig()
|
||||
}
|
||||
|
||||
public async updateUserSettings(settings: Partial<UserSettingsConfig>): Promise<boolean> {
|
||||
this.ensureInitialized()
|
||||
return this.settingsService!.updateUserSettings(settings)
|
||||
}
|
||||
|
||||
// TelemetryClient
|
||||
|
||||
public captureEvent(event: TelemetryEvent): void {
|
||||
this.ensureInitialized()
|
||||
this.telemetryClient!.capture(event)
|
||||
}
|
||||
|
||||
// ShareService
|
||||
|
||||
public async shareTask(
|
||||
taskId: string,
|
||||
visibility: ShareVisibility = "organization",
|
||||
clineMessages?: ClineMessage[],
|
||||
) {
|
||||
this.ensureInitialized()
|
||||
|
||||
try {
|
||||
return await this.shareService!.shareTask(taskId, visibility)
|
||||
} catch (error) {
|
||||
if (error instanceof TaskNotFoundError && clineMessages) {
|
||||
// Backfill messages and retry.
|
||||
await this.telemetryClient!.backfillMessages(clineMessages, taskId)
|
||||
return await this.shareService!.shareTask(taskId, visibility)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async canShareTask(): Promise<boolean> {
|
||||
this.ensureInitialized()
|
||||
return this.shareService!.canShareTask()
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
public dispose(): void {
|
||||
if (this.authService) {
|
||||
this.authService.off("auth-state-changed", this.authStateListener)
|
||||
this.authService.off("user-info", this.authUserInfoListener)
|
||||
}
|
||||
|
||||
if (this.settingsService) {
|
||||
if (this.settingsService instanceof CloudSettingsService) {
|
||||
this.settingsService.off("settings-updated", this.settingsListener)
|
||||
}
|
||||
|
||||
this.settingsService.dispose()
|
||||
}
|
||||
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
||||
private ensureInitialized(): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error("CloudService not initialized.")
|
||||
}
|
||||
}
|
||||
|
||||
static get instance(): CloudService {
|
||||
if (!this._instance) {
|
||||
throw new Error("CloudService not initialized")
|
||||
}
|
||||
|
||||
return this._instance
|
||||
}
|
||||
|
||||
static async createInstance(
|
||||
context: ExtensionContext,
|
||||
log?: (...args: unknown[]) => void,
|
||||
eventHandlers?: Partial<{
|
||||
[K in keyof CloudServiceEvents]: (...args: CloudServiceEvents[K]) => void
|
||||
}>,
|
||||
): Promise<CloudService> {
|
||||
if (this._instance) {
|
||||
throw new Error("CloudService instance already created")
|
||||
}
|
||||
|
||||
this._instance = new CloudService(context, log)
|
||||
|
||||
await this._instance.initialize()
|
||||
|
||||
if (eventHandlers) {
|
||||
for (const [event, handler] of Object.entries(eventHandlers)) {
|
||||
if (handler) {
|
||||
this._instance.on(
|
||||
event as keyof CloudServiceEvents,
|
||||
handler as (...args: CloudServiceEvents[keyof CloudServiceEvents]) => void,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this._instance.authService?.broadcast()
|
||||
|
||||
return this._instance
|
||||
}
|
||||
|
||||
static hasInstance(): boolean {
|
||||
return this._instance !== null && this._instance.isInitialized
|
||||
}
|
||||
|
||||
static resetInstance(): void {
|
||||
if (this._instance) {
|
||||
this._instance.dispose()
|
||||
this._instance = null
|
||||
}
|
||||
}
|
||||
|
||||
static isEnabled(): boolean {
|
||||
return !!this._instance?.isAuthenticated()
|
||||
}
|
||||
}
|
||||
282
packages/cloud/src/CloudSettingsService.ts
Normal file
282
packages/cloud/src/CloudSettingsService.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import EventEmitter from "events"
|
||||
|
||||
import type { ExtensionContext } from "vscode"
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
type SettingsService,
|
||||
type SettingsServiceEvents,
|
||||
type AuthService,
|
||||
type AuthState,
|
||||
type UserFeatures,
|
||||
type UserSettingsConfig,
|
||||
type UserSettingsData,
|
||||
OrganizationAllowList,
|
||||
OrganizationSettings,
|
||||
organizationSettingsSchema,
|
||||
userSettingsDataSchema,
|
||||
ORGANIZATION_ALLOW_ALL,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { getRooCodeApiUrl } from "./config.js"
|
||||
import { RefreshTimer } from "./RefreshTimer.js"
|
||||
|
||||
const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings"
|
||||
const USER_SETTINGS_CACHE_KEY = "user-settings"
|
||||
|
||||
const parseExtensionSettingsResponse = (data: unknown) => {
|
||||
const shapeResult = z.object({ organization: z.unknown(), user: z.unknown() }).safeParse(data)
|
||||
|
||||
if (!shapeResult.success) {
|
||||
return { success: false, error: shapeResult.error } as const
|
||||
}
|
||||
|
||||
const orgResult = organizationSettingsSchema.safeParse(shapeResult.data.organization)
|
||||
|
||||
if (!orgResult.success) {
|
||||
return { success: false, error: orgResult.error } as const
|
||||
}
|
||||
|
||||
const userResult = userSettingsDataSchema.safeParse(shapeResult.data.user)
|
||||
|
||||
if (!userResult.success) {
|
||||
return { success: false, error: userResult.error } as const
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { organization: orgResult.data, user: userResult.data },
|
||||
} as const
|
||||
}
|
||||
|
||||
export class CloudSettingsService extends EventEmitter<SettingsServiceEvents> implements SettingsService {
|
||||
private context: ExtensionContext
|
||||
private authService: AuthService
|
||||
private settings: OrganizationSettings | undefined = undefined
|
||||
private userSettings: UserSettingsData | undefined = undefined
|
||||
private timer: RefreshTimer
|
||||
private log: (...args: unknown[]) => void
|
||||
|
||||
constructor(context: ExtensionContext, authService: AuthService, log?: (...args: unknown[]) => void) {
|
||||
super()
|
||||
|
||||
this.context = context
|
||||
this.authService = authService
|
||||
this.log = log || console.log
|
||||
|
||||
this.timer = new RefreshTimer({
|
||||
callback: async () => {
|
||||
return await this.fetchSettings()
|
||||
},
|
||||
successInterval: 30000,
|
||||
initialBackoffMs: 1000,
|
||||
maxBackoffMs: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.loadCachedSettings()
|
||||
|
||||
// Clear cached settings if we have missed a log out.
|
||||
if (this.authService.getState() == "logged-out" && (this.settings || this.userSettings)) {
|
||||
await this.removeSettings()
|
||||
}
|
||||
|
||||
this.authService.on("auth-state-changed", async (data: { state: AuthState; previousState: AuthState }) => {
|
||||
try {
|
||||
if (data.state === "active-session") {
|
||||
this.timer.start()
|
||||
} else if (data.previousState === "active-session") {
|
||||
this.timer.stop()
|
||||
|
||||
if (data.state === "logged-out") {
|
||||
await this.removeSettings()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`[cloud-settings] error processing auth-state-changed: ${error}`, error)
|
||||
}
|
||||
})
|
||||
|
||||
if (this.authService.hasActiveSession()) {
|
||||
this.timer.start()
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchSettings(): Promise<boolean> {
|
||||
const token = this.authService.getSessionToken()
|
||||
|
||||
if (!token) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getRooCodeApiUrl()}/api/extension-settings`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
this.log("[cloud-settings] Failed to fetch extension settings:", response.status, response.statusText)
|
||||
return false
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const result = parseExtensionSettingsResponse(data)
|
||||
|
||||
if (!result.success) {
|
||||
this.log("[cloud-settings] Invalid extension settings format:", result.error)
|
||||
return false
|
||||
}
|
||||
|
||||
const { organization: newOrgSettings, user: newUserSettings } = result.data
|
||||
|
||||
let orgChanged = false
|
||||
let userChanged = false
|
||||
|
||||
// Check for organization settings changes
|
||||
if (!this.settings || this.settings.version !== newOrgSettings.version) {
|
||||
this.settings = newOrgSettings
|
||||
orgChanged = true
|
||||
}
|
||||
|
||||
// Check for user settings changes
|
||||
if (!this.userSettings || this.userSettings.version !== newUserSettings.version) {
|
||||
this.userSettings = newUserSettings
|
||||
userChanged = true
|
||||
}
|
||||
|
||||
// Emit a single event if either settings changed
|
||||
if (orgChanged || userChanged) {
|
||||
this.emit("settings-updated", {} as Record<string, never>)
|
||||
}
|
||||
|
||||
const hasChanges = orgChanged || userChanged
|
||||
|
||||
if (hasChanges) {
|
||||
await this.cacheSettings()
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
this.log("[cloud-settings] Error fetching extension settings:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async cacheSettings(): Promise<void> {
|
||||
// Store settings in separate globalState values
|
||||
if (this.settings) {
|
||||
await this.context.globalState.update(ORGANIZATION_SETTINGS_CACHE_KEY, this.settings)
|
||||
}
|
||||
|
||||
if (this.userSettings) {
|
||||
await this.context.globalState.update(USER_SETTINGS_CACHE_KEY, this.userSettings)
|
||||
}
|
||||
}
|
||||
|
||||
private loadCachedSettings(): void {
|
||||
// Load settings from separate globalState values
|
||||
this.settings = this.context.globalState.get<OrganizationSettings>(ORGANIZATION_SETTINGS_CACHE_KEY)
|
||||
this.userSettings = this.context.globalState.get<UserSettingsData>(USER_SETTINGS_CACHE_KEY)
|
||||
}
|
||||
|
||||
public getAllowList(): OrganizationAllowList {
|
||||
return this.settings?.allowList || ORGANIZATION_ALLOW_ALL
|
||||
}
|
||||
|
||||
public getSettings(): OrganizationSettings | undefined {
|
||||
return this.settings
|
||||
}
|
||||
|
||||
public getUserSettings(): UserSettingsData | undefined {
|
||||
return this.userSettings
|
||||
}
|
||||
|
||||
public getUserFeatures(): UserFeatures {
|
||||
return this.userSettings?.features || {}
|
||||
}
|
||||
|
||||
public getUserSettingsConfig(): UserSettingsConfig {
|
||||
return this.userSettings?.settings || {}
|
||||
}
|
||||
|
||||
public async updateUserSettings(settings: Partial<UserSettingsConfig>): Promise<boolean> {
|
||||
const token = this.authService.getSessionToken()
|
||||
|
||||
if (!token) {
|
||||
this.log("[cloud-settings] No session token available for updating user settings")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const currentVersion = this.userSettings?.version
|
||||
const requestBody: {
|
||||
settings: Partial<UserSettingsConfig>
|
||||
version?: number
|
||||
} = {
|
||||
settings,
|
||||
}
|
||||
|
||||
// Include current version for optimistic locking if we have cached settings
|
||||
if (currentVersion !== undefined) {
|
||||
requestBody.version = currentVersion
|
||||
}
|
||||
|
||||
const response = await fetch(`${getRooCodeApiUrl()}/api/user-settings`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 409) {
|
||||
this.log(
|
||||
"[cloud-settings] Version conflict when updating user settings - settings may have been updated elsewhere",
|
||||
)
|
||||
} else {
|
||||
this.log("[cloud-settings] Failed to update user settings:", response.status, response.statusText)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const updatedUserSettings = await response.json()
|
||||
const result = userSettingsDataSchema.safeParse(updatedUserSettings)
|
||||
|
||||
if (!result.success) {
|
||||
this.log("[cloud-settings] Invalid user settings response format:", result.error)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.userSettings || result.data.version > this.userSettings.version) {
|
||||
this.userSettings = result.data
|
||||
await this.cacheSettings()
|
||||
this.emit("settings-updated", {} as Record<string, never>)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
this.log("[cloud-settings] Error updating user settings:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async removeSettings(): Promise<void> {
|
||||
this.settings = undefined
|
||||
this.userSettings = undefined
|
||||
|
||||
// Clear both cache keys
|
||||
await this.context.globalState.update(ORGANIZATION_SETTINGS_CACHE_KEY, undefined)
|
||||
await this.context.globalState.update(USER_SETTINGS_CACHE_KEY, undefined)
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.removeAllListeners()
|
||||
this.timer.stop()
|
||||
}
|
||||
}
|
||||
50
packages/cloud/src/CloudShareService.ts
Normal file
50
packages/cloud/src/CloudShareService.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { SettingsService, ShareResponse, ShareVisibility } from "@roo-code/types"
|
||||
|
||||
import { importVscode } from "./importVscode.js"
|
||||
import type { CloudAPI } from "./CloudAPI.js"
|
||||
|
||||
export class CloudShareService {
|
||||
private cloudAPI: CloudAPI
|
||||
private settingsService: SettingsService
|
||||
private log: (...args: unknown[]) => void
|
||||
|
||||
constructor(cloudAPI: CloudAPI, settingsService: SettingsService, log?: (...args: unknown[]) => void) {
|
||||
this.cloudAPI = cloudAPI
|
||||
this.settingsService = settingsService
|
||||
this.log = log || console.log
|
||||
}
|
||||
|
||||
async shareTask(taskId: string, visibility: ShareVisibility = "organization"): Promise<ShareResponse> {
|
||||
try {
|
||||
const response = await this.cloudAPI.shareTask(taskId, visibility)
|
||||
|
||||
if (response.success && response.shareUrl) {
|
||||
const vscode = await importVscode()
|
||||
|
||||
if (vscode?.env?.clipboard?.writeText) {
|
||||
try {
|
||||
await vscode.env.clipboard.writeText(response.shareUrl)
|
||||
} catch (copyErr) {
|
||||
this.log("[ShareService] Clipboard write failed (non-fatal):", copyErr)
|
||||
}
|
||||
} else {
|
||||
this.log("[ShareService] VS Code clipboard unavailable; running outside extension host.")
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
this.log("[ShareService] Error sharing task:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async canShareTask(): Promise<boolean> {
|
||||
try {
|
||||
return !!this.settingsService.getSettings()?.cloudSettings?.enableTaskSharing
|
||||
} catch (error) {
|
||||
this.log("[ShareService] Error checking if task can be shared:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
154
packages/cloud/src/RefreshTimer.ts
Normal file
154
packages/cloud/src/RefreshTimer.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* RefreshTimer - A utility for executing a callback with configurable retry behavior
|
||||
*
|
||||
* This timer executes a callback function and schedules the next execution based on the result:
|
||||
* - If the callback succeeds (returns true), it schedules the next attempt after a fixed interval
|
||||
* - If the callback fails (returns false), it uses exponential backoff up to a maximum interval
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration options for the RefreshTimer
|
||||
*/
|
||||
export interface RefreshTimerOptions {
|
||||
/**
|
||||
* The callback function to execute
|
||||
* Should return a Promise that resolves to a boolean indicating success (true) or failure (false)
|
||||
*/
|
||||
callback: () => Promise<boolean>
|
||||
|
||||
/**
|
||||
* Time in milliseconds to wait before next attempt after success
|
||||
* @default 50000 (50 seconds)
|
||||
*/
|
||||
successInterval?: number
|
||||
|
||||
/**
|
||||
* Initial backoff time in milliseconds for the first failure
|
||||
* @default 1000 (1 second)
|
||||
*/
|
||||
initialBackoffMs?: number
|
||||
|
||||
/**
|
||||
* Maximum backoff time in milliseconds
|
||||
* @default 300000 (5 minutes)
|
||||
*/
|
||||
maxBackoffMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A timer utility that executes a callback with configurable retry behavior
|
||||
*/
|
||||
export class RefreshTimer {
|
||||
private callback: () => Promise<boolean>
|
||||
private successInterval: number
|
||||
private initialBackoffMs: number
|
||||
private maxBackoffMs: number
|
||||
private currentBackoffMs: number
|
||||
private attemptCount: number
|
||||
private timerId: NodeJS.Timeout | null
|
||||
private isRunning: boolean
|
||||
|
||||
/**
|
||||
* Creates a new RefreshTimer
|
||||
*
|
||||
* @param options Configuration options for the timer
|
||||
*/
|
||||
constructor(options: RefreshTimerOptions) {
|
||||
this.callback = options.callback
|
||||
this.successInterval = options.successInterval ?? 50000 // 50 seconds
|
||||
this.initialBackoffMs = options.initialBackoffMs ?? 1000 // 1 second
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 300000 // 5 minutes
|
||||
this.currentBackoffMs = this.initialBackoffMs
|
||||
this.attemptCount = 0
|
||||
this.timerId = null
|
||||
this.isRunning = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the timer and executes the callback immediately
|
||||
*/
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isRunning = true
|
||||
|
||||
// Execute the callback immediately
|
||||
this.executeCallback()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the timer and cancels any pending execution
|
||||
*/
|
||||
public stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.timerId) {
|
||||
clearTimeout(this.timerId)
|
||||
this.timerId = null
|
||||
}
|
||||
|
||||
this.isRunning = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the backoff state and attempt count
|
||||
* Does not affect whether the timer is running
|
||||
*/
|
||||
public reset(): void {
|
||||
this.currentBackoffMs = this.initialBackoffMs
|
||||
this.attemptCount = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next attempt based on the success/failure of the current attempt
|
||||
*
|
||||
* @param wasSuccessful Whether the current attempt was successful
|
||||
*/
|
||||
private scheduleNextAttempt(wasSuccessful: boolean): void {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
if (wasSuccessful) {
|
||||
// Reset backoff on success
|
||||
this.currentBackoffMs = this.initialBackoffMs
|
||||
this.attemptCount = 0
|
||||
|
||||
this.timerId = setTimeout(() => this.executeCallback(), this.successInterval)
|
||||
} else {
|
||||
// Increment attempt count
|
||||
this.attemptCount++
|
||||
|
||||
// Calculate backoff time with exponential increase
|
||||
// Formula: initialBackoff * 2^(attemptCount - 1)
|
||||
this.currentBackoffMs = Math.min(
|
||||
this.initialBackoffMs * Math.pow(2, this.attemptCount - 1),
|
||||
this.maxBackoffMs,
|
||||
)
|
||||
|
||||
this.timerId = setTimeout(() => this.executeCallback(), this.currentBackoffMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the callback and handles the result
|
||||
*/
|
||||
private async executeCallback(): Promise<void> {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.callback()
|
||||
|
||||
this.scheduleNextAttempt(result)
|
||||
} catch (_error) {
|
||||
// Treat errors as failed attempts
|
||||
this.scheduleNextAttempt(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
packages/cloud/src/StaticSettingsService.ts
Normal file
78
packages/cloud/src/StaticSettingsService.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import {
|
||||
type SettingsService,
|
||||
type UserFeatures,
|
||||
type UserSettingsConfig,
|
||||
type UserSettingsData,
|
||||
OrganizationAllowList,
|
||||
OrganizationSettings,
|
||||
organizationSettingsSchema,
|
||||
ORGANIZATION_ALLOW_ALL,
|
||||
} from "@roo-code/types"
|
||||
|
||||
export class StaticSettingsService implements SettingsService {
|
||||
private settings: OrganizationSettings
|
||||
private log: (...args: unknown[]) => void
|
||||
|
||||
constructor(envValue: string, log?: (...args: unknown[]) => void) {
|
||||
this.log = log || console.log
|
||||
this.settings = this.parseEnvironmentSettings(envValue)
|
||||
}
|
||||
|
||||
private parseEnvironmentSettings(envValue: string): OrganizationSettings {
|
||||
try {
|
||||
const decodedValue = Buffer.from(envValue, "base64").toString("utf-8")
|
||||
const parsedJson = JSON.parse(decodedValue)
|
||||
return organizationSettingsSchema.parse(parsedJson)
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[StaticSettingsService] failed to parse static settings: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error,
|
||||
)
|
||||
|
||||
throw new Error("Failed to parse static settings", { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
public getAllowList(): OrganizationAllowList {
|
||||
return this.settings?.allowList || ORGANIZATION_ALLOW_ALL
|
||||
}
|
||||
|
||||
public getSettings(): OrganizationSettings | undefined {
|
||||
return this.settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns static user settings with roomoteControlEnabled and extensionBridgeEnabled as true
|
||||
*/
|
||||
public getUserSettings(): UserSettingsData | undefined {
|
||||
return {
|
||||
features: {
|
||||
roomoteControlEnabled: true,
|
||||
},
|
||||
settings: {
|
||||
extensionBridgeEnabled: true,
|
||||
},
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
public getUserFeatures(): UserFeatures {
|
||||
return {
|
||||
roomoteControlEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
public getUserSettingsConfig(): UserSettingsConfig {
|
||||
return {
|
||||
extensionBridgeEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
public async updateUserSettings(_settings: Partial<UserSettingsConfig>): Promise<boolean> {
|
||||
throw new Error("User settings updates are not supported in static mode")
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
// No resources to clean up for static settings.
|
||||
}
|
||||
}
|
||||
93
packages/cloud/src/StaticTokenAuthService.ts
Normal file
93
packages/cloud/src/StaticTokenAuthService.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import EventEmitter from "events"
|
||||
|
||||
import { jwtDecode } from "jwt-decode"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
|
||||
import type { JWTPayload, CloudUserInfo, AuthService, AuthServiceEvents, AuthState } from "@roo-code/types"
|
||||
|
||||
export class StaticTokenAuthService extends EventEmitter<AuthServiceEvents> implements AuthService {
|
||||
private state: AuthState = "active-session"
|
||||
private token: string
|
||||
private log: (...args: unknown[]) => void
|
||||
private userInfo: CloudUserInfo
|
||||
|
||||
constructor(context: ExtensionContext, token: string, log?: (...args: unknown[]) => void) {
|
||||
super()
|
||||
|
||||
this.token = token
|
||||
this.log = log || console.log
|
||||
|
||||
this.log("[auth] Using StaticTokenAuthService")
|
||||
|
||||
let payload
|
||||
|
||||
try {
|
||||
payload = jwtDecode<JWTPayload>(token)
|
||||
} catch (error) {
|
||||
this.log("[auth] Failed to parse JWT:", error)
|
||||
}
|
||||
|
||||
this.userInfo = {
|
||||
id: payload?.r?.u || payload?.sub || undefined,
|
||||
organizationId: payload?.r?.o || undefined,
|
||||
extensionBridgeEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.state = "active-session"
|
||||
}
|
||||
|
||||
public broadcast(): void {
|
||||
this.emit("auth-state-changed", {
|
||||
state: this.state,
|
||||
previousState: "initializing",
|
||||
})
|
||||
|
||||
this.emit("user-info", { userInfo: this.userInfo })
|
||||
}
|
||||
|
||||
public async login(): Promise<void> {
|
||||
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
|
||||
}
|
||||
|
||||
public async handleCallback(
|
||||
_code: string | null,
|
||||
_state: string | null,
|
||||
_organizationId?: string | null,
|
||||
): Promise<void> {
|
||||
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
|
||||
}
|
||||
|
||||
public getState(): AuthState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
public getSessionToken(): string | undefined {
|
||||
return this.token
|
||||
}
|
||||
|
||||
public isAuthenticated(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
public hasActiveSession(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
public hasOrIsAcquiringActiveSession(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
public getUserInfo(): CloudUserInfo | null {
|
||||
return this.userInfo
|
||||
}
|
||||
|
||||
public getStoredOrganizationId(): string | null {
|
||||
return this.userInfo?.organizationId || null
|
||||
}
|
||||
}
|
||||
246
packages/cloud/src/TelemetryClient.ts
Normal file
246
packages/cloud/src/TelemetryClient.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import {
|
||||
type TelemetryClient,
|
||||
type TelemetryEvent,
|
||||
type ClineMessage,
|
||||
type AuthService,
|
||||
type SettingsService,
|
||||
TelemetryEventName,
|
||||
rooCodeTelemetryEventSchema,
|
||||
TelemetryPropertiesProvider,
|
||||
TelemetryEventSubscription,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { getRooCodeApiUrl } from "./config.js"
|
||||
|
||||
abstract class BaseTelemetryClient implements TelemetryClient {
|
||||
protected providerRef: WeakRef<TelemetryPropertiesProvider> | null = null
|
||||
protected telemetryEnabled: boolean = false
|
||||
|
||||
constructor(
|
||||
public readonly subscription?: TelemetryEventSubscription,
|
||||
protected readonly debug = false,
|
||||
) {}
|
||||
|
||||
protected isEventCapturable(eventName: TelemetryEventName): boolean {
|
||||
if (!this.subscription) {
|
||||
return true
|
||||
}
|
||||
|
||||
return this.subscription.type === "include"
|
||||
? this.subscription.events.includes(eventName)
|
||||
: !this.subscription.events.includes(eventName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a specific property should be included in telemetry events
|
||||
* Override in subclasses to filter specific properties
|
||||
*/
|
||||
protected isPropertyCapturable(_propertyName: string): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected async getEventProperties(event: TelemetryEvent): Promise<TelemetryEvent["properties"]> {
|
||||
let providerProperties: TelemetryEvent["properties"] = {}
|
||||
const provider = this.providerRef?.deref()
|
||||
|
||||
if (provider) {
|
||||
try {
|
||||
// Get properties from the provider
|
||||
providerProperties = await provider.getTelemetryProperties()
|
||||
} catch (error) {
|
||||
// Log error but continue with capturing the event.
|
||||
console.error(
|
||||
`Error getting telemetry properties: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge provider properties with event-specific properties.
|
||||
// Event properties take precedence in case of conflicts.
|
||||
const mergedProperties = {
|
||||
...providerProperties,
|
||||
...(event.properties || {}),
|
||||
}
|
||||
|
||||
// Filter out properties that shouldn't be captured by this client
|
||||
return Object.fromEntries(Object.entries(mergedProperties).filter(([key]) => this.isPropertyCapturable(key)))
|
||||
}
|
||||
|
||||
public abstract capture(event: TelemetryEvent): Promise<void>
|
||||
|
||||
public setProvider(provider: TelemetryPropertiesProvider): void {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
}
|
||||
|
||||
public abstract updateTelemetryState(didUserOptIn: boolean): void
|
||||
|
||||
public isTelemetryEnabled(): boolean {
|
||||
return this.telemetryEnabled
|
||||
}
|
||||
|
||||
public abstract shutdown(): Promise<void>
|
||||
}
|
||||
|
||||
export class CloudTelemetryClient extends BaseTelemetryClient {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private settingsService: SettingsService,
|
||||
debug = false,
|
||||
) {
|
||||
super(
|
||||
{
|
||||
type: "exclude",
|
||||
events: [TelemetryEventName.TASK_CONVERSATION_MESSAGE],
|
||||
},
|
||||
debug,
|
||||
)
|
||||
}
|
||||
|
||||
private async fetch(path: string, options: RequestInit) {
|
||||
if (!this.authService.isAuthenticated()) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = this.authService.getSessionToken()
|
||||
|
||||
if (!token) {
|
||||
console.error(`[TelemetryClient#fetch] Unauthorized: No session token available.`)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`${getRooCodeApiUrl()}/api/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[TelemetryClient#fetch] ${options.method} ${path} -> ${response.status} ${response.statusText}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public override async capture(event: TelemetryEvent) {
|
||||
if (!this.isTelemetryEnabled() || !this.isEventCapturable(event.event)) {
|
||||
if (this.debug) {
|
||||
console.info(`[TelemetryClient#capture] Skipping event: ${event.event}`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: event.event,
|
||||
properties: await this.getEventProperties(event),
|
||||
}
|
||||
|
||||
if (this.debug) {
|
||||
console.info(`[TelemetryClient#capture] ${JSON.stringify(payload)}`)
|
||||
}
|
||||
|
||||
const result = rooCodeTelemetryEventSchema.safeParse(payload)
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`[TelemetryClient#capture] Invalid telemetry event: ${result.error.message} - ${JSON.stringify(payload)}`,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.fetch(`events`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(result.data),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[TelemetryClient#capture] Error sending telemetry event: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
public async backfillMessages(messages: ClineMessage[], taskId: string): Promise<void> {
|
||||
if (!this.authService.isAuthenticated()) {
|
||||
if (this.debug) {
|
||||
console.info(`[TelemetryClient#backfillMessages] Skipping: Not authenticated`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const token = this.authService.getSessionToken()
|
||||
|
||||
if (!token) {
|
||||
console.error(`[TelemetryClient#backfillMessages] Unauthorized: No session token available.`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const mergedProperties = await this.getEventProperties({
|
||||
event: TelemetryEventName.TASK_MESSAGE,
|
||||
properties: { taskId },
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("taskId", taskId)
|
||||
formData.append("properties", JSON.stringify(mergedProperties))
|
||||
|
||||
formData.append(
|
||||
"file",
|
||||
new File([JSON.stringify(messages)], "task.json", {
|
||||
type: "application/json",
|
||||
}),
|
||||
)
|
||||
|
||||
if (this.debug) {
|
||||
console.info(
|
||||
`[TelemetryClient#backfillMessages] Uploading ${messages.length} messages for task ${taskId}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Custom fetch for multipart - don't set Content-Type header (let browser set it)
|
||||
const response = await fetch(`${getRooCodeApiUrl()}/api/events/backfill`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
// Note: No Content-Type header - browser will set multipart/form-data with boundary
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[TelemetryClient#backfillMessages] POST events/backfill -> ${response.status} ${response.statusText}`,
|
||||
)
|
||||
} else if (this.debug) {
|
||||
console.info(`[TelemetryClient#backfillMessages] Successfully uploaded messages for task ${taskId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TelemetryClient#backfillMessages] Error uploading messages: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
public override updateTelemetryState(_didUserOptIn: boolean) {}
|
||||
|
||||
public override isTelemetryEnabled(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected override isEventCapturable(eventName: TelemetryEventName): boolean {
|
||||
// Ensure that this event type is supported by the telemetry client
|
||||
if (!super.isEventCapturable(eventName)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only record message telemetry if a cloud account is present and explicitly configured to record messages
|
||||
if (eventName === TelemetryEventName.TASK_MESSAGE) {
|
||||
return this.settingsService.getSettings()?.cloudSettings?.recordTaskMessages || false
|
||||
}
|
||||
|
||||
// Other telemetry types are capturable at this point
|
||||
return true
|
||||
}
|
||||
|
||||
public override async shutdown() {}
|
||||
}
|
||||
727
packages/cloud/src/WebAuthService.ts
Normal file
727
packages/cloud/src/WebAuthService.ts
Normal file
|
|
@ -0,0 +1,727 @@
|
|||
import crypto from "crypto"
|
||||
import EventEmitter from "events"
|
||||
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import { z } from "zod"
|
||||
|
||||
import type {
|
||||
CloudUserInfo,
|
||||
CloudOrganizationMembership,
|
||||
AuthService,
|
||||
AuthServiceEvents,
|
||||
AuthState,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { getClerkBaseUrl, getRooCodeApiUrl, PRODUCTION_CLERK_BASE_URL } from "./config.js"
|
||||
import { getUserAgent } from "./utils.js"
|
||||
import { importVscode } from "./importVscode.js"
|
||||
import { InvalidClientTokenError } from "./errors.js"
|
||||
import { RefreshTimer } from "./RefreshTimer.js"
|
||||
|
||||
const AUTH_STATE_KEY = "clerk-auth-state"
|
||||
|
||||
/**
|
||||
* AuthCredentials
|
||||
*/
|
||||
|
||||
const authCredentialsSchema = z.object({
|
||||
clientToken: z.string().min(1, "Client token cannot be empty"),
|
||||
sessionId: z.string().min(1, "Session ID cannot be empty"),
|
||||
organizationId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
type AuthCredentials = z.infer<typeof authCredentialsSchema>
|
||||
|
||||
/**
|
||||
* Clerk Schemas
|
||||
*/
|
||||
|
||||
const clerkSignInResponseSchema = z.object({
|
||||
response: z.object({
|
||||
created_session_id: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
const clerkCreateSessionTokenResponseSchema = z.object({
|
||||
jwt: z.string(),
|
||||
})
|
||||
|
||||
const clerkMeResponseSchema = z.object({
|
||||
response: z.object({
|
||||
id: z.string().optional(),
|
||||
first_name: z.string().nullish(),
|
||||
last_name: z.string().nullish(),
|
||||
image_url: z.string().optional(),
|
||||
primary_email_address_id: z.string().optional(),
|
||||
email_addresses: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
email_address: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
public_metadata: z.record(z.any()).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const clerkOrganizationMembershipsSchema = z.object({
|
||||
response: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
role: z.string(),
|
||||
permissions: z.array(z.string()).optional(),
|
||||
created_at: z.number().optional(),
|
||||
updated_at: z.number().optional(),
|
||||
organization: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string().optional(),
|
||||
image_url: z.string().optional(),
|
||||
has_image: z.boolean().optional(),
|
||||
created_at: z.number().optional(),
|
||||
updated_at: z.number().optional(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export class WebAuthService extends EventEmitter<AuthServiceEvents> implements AuthService {
|
||||
private context: ExtensionContext
|
||||
private timer: RefreshTimer
|
||||
private state: AuthState = "initializing"
|
||||
private log: (...args: unknown[]) => void
|
||||
private readonly authCredentialsKey: string
|
||||
|
||||
private credentials: AuthCredentials | null = null
|
||||
private sessionToken: string | null = null
|
||||
private userInfo: CloudUserInfo | null = null
|
||||
private isFirstRefreshAttempt: boolean = false
|
||||
|
||||
constructor(context: ExtensionContext, log?: (...args: unknown[]) => void) {
|
||||
super()
|
||||
|
||||
this.context = context
|
||||
this.log = log || console.log
|
||||
|
||||
this.log("[auth] Using WebAuthService")
|
||||
|
||||
// Calculate auth credentials key based on Clerk base URL.
|
||||
const clerkBaseUrl = getClerkBaseUrl()
|
||||
|
||||
if (clerkBaseUrl !== PRODUCTION_CLERK_BASE_URL) {
|
||||
this.authCredentialsKey = `clerk-auth-credentials-${clerkBaseUrl}`
|
||||
} else {
|
||||
this.authCredentialsKey = "clerk-auth-credentials"
|
||||
}
|
||||
|
||||
this.timer = new RefreshTimer({
|
||||
callback: async () => {
|
||||
await this.refreshSession()
|
||||
return true
|
||||
},
|
||||
successInterval: 50_000,
|
||||
initialBackoffMs: 1_000,
|
||||
maxBackoffMs: 300_000,
|
||||
})
|
||||
}
|
||||
|
||||
private changeState(newState: AuthState): void {
|
||||
const previousState = this.state
|
||||
this.state = newState
|
||||
this.emit("auth-state-changed", { state: newState, previousState })
|
||||
}
|
||||
|
||||
private async handleCredentialsChange(): Promise<void> {
|
||||
try {
|
||||
const credentials = await this.loadCredentials()
|
||||
|
||||
if (credentials) {
|
||||
if (
|
||||
this.credentials === null ||
|
||||
this.credentials.clientToken !== credentials.clientToken ||
|
||||
this.credentials.sessionId !== credentials.sessionId
|
||||
) {
|
||||
this.transitionToAttemptingSession(credentials)
|
||||
}
|
||||
} else {
|
||||
if (this.state !== "logged-out") {
|
||||
this.transitionToLoggedOut()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log("[auth] Error handling credentials change:", error)
|
||||
}
|
||||
}
|
||||
|
||||
private transitionToLoggedOut(): void {
|
||||
this.timer.stop()
|
||||
|
||||
this.credentials = null
|
||||
this.sessionToken = null
|
||||
this.userInfo = null
|
||||
|
||||
this.changeState("logged-out")
|
||||
|
||||
this.log("[auth] Transitioned to logged-out state")
|
||||
}
|
||||
|
||||
private transitionToAttemptingSession(credentials: AuthCredentials): void {
|
||||
this.credentials = credentials
|
||||
|
||||
this.sessionToken = null
|
||||
this.userInfo = null
|
||||
this.isFirstRefreshAttempt = true
|
||||
|
||||
this.changeState("attempting-session")
|
||||
|
||||
this.timer.start()
|
||||
|
||||
this.log("[auth] Transitioned to attempting-session state")
|
||||
}
|
||||
|
||||
private transitionToInactiveSession(): void {
|
||||
this.sessionToken = null
|
||||
this.userInfo = null
|
||||
|
||||
this.changeState("inactive-session")
|
||||
|
||||
this.log("[auth] Transitioned to inactive-session state")
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the auth state
|
||||
*
|
||||
* This method loads tokens from storage and determines the current auth state.
|
||||
* It also starts the refresh timer if we have an active session.
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.state !== "initializing") {
|
||||
this.log("[auth] initialize() called after already initialized")
|
||||
return
|
||||
}
|
||||
|
||||
await this.handleCredentialsChange()
|
||||
|
||||
this.context.subscriptions.push(
|
||||
this.context.secrets.onDidChange((e) => {
|
||||
if (e.key === this.authCredentialsKey) {
|
||||
this.handleCredentialsChange()
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
public broadcast(): void {}
|
||||
|
||||
private async storeCredentials(credentials: AuthCredentials): Promise<void> {
|
||||
await this.context.secrets.store(this.authCredentialsKey, JSON.stringify(credentials))
|
||||
}
|
||||
|
||||
private async loadCredentials(): Promise<AuthCredentials | null> {
|
||||
const credentialsJson = await this.context.secrets.get(this.authCredentialsKey)
|
||||
if (!credentialsJson) return null
|
||||
|
||||
try {
|
||||
const parsedJson = JSON.parse(credentialsJson)
|
||||
const credentials = authCredentialsSchema.parse(parsedJson)
|
||||
|
||||
// Migration: If no organizationId but we have userInfo, add it
|
||||
if (credentials.organizationId === undefined && this.userInfo?.organizationId) {
|
||||
credentials.organizationId = this.userInfo.organizationId
|
||||
await this.storeCredentials(credentials)
|
||||
this.log("[auth] Migrated credentials with organizationId")
|
||||
}
|
||||
|
||||
return credentials
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
this.log("[auth] Invalid credentials format:", error.errors)
|
||||
} else {
|
||||
this.log("[auth] Failed to parse stored credentials:", error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async clearCredentials(): Promise<void> {
|
||||
await this.context.secrets.delete(this.authCredentialsKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the login process
|
||||
*
|
||||
* This method initiates the authentication flow by generating a state parameter
|
||||
* and opening the browser to the authorization URL.
|
||||
*/
|
||||
public async login(): Promise<void> {
|
||||
try {
|
||||
const vscode = await importVscode()
|
||||
|
||||
if (!vscode) {
|
||||
throw new Error("VS Code API not available")
|
||||
}
|
||||
|
||||
// Generate a cryptographically random state parameter.
|
||||
const state = crypto.randomBytes(16).toString("hex")
|
||||
await this.context.globalState.update(AUTH_STATE_KEY, state)
|
||||
const packageJSON = this.context.extension?.packageJSON
|
||||
const publisher = packageJSON?.publisher ?? "RooVeterinaryInc"
|
||||
const name = packageJSON?.name ?? "roo-cline"
|
||||
const params = new URLSearchParams({
|
||||
state,
|
||||
auth_redirect: `${vscode.env.uriScheme}://${publisher}.${name}`,
|
||||
})
|
||||
const url = `${getRooCodeApiUrl()}/extension/sign-in?${params.toString()}`
|
||||
await vscode.env.openExternal(vscode.Uri.parse(url))
|
||||
} catch (error) {
|
||||
this.log(`[auth] Error initiating Roo Code Cloud auth: ${error}`)
|
||||
throw new Error(`Failed to initiate Roo Code Cloud authentication: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the callback from Roo Code Cloud
|
||||
*
|
||||
* This method is called when the user is redirected back to the extension
|
||||
* after authenticating with Roo Code Cloud.
|
||||
*
|
||||
* @param code The authorization code from the callback
|
||||
* @param state The state parameter from the callback
|
||||
* @param organizationId The organization ID from the callback (null for personal accounts)
|
||||
*/
|
||||
public async handleCallback(
|
||||
code: string | null,
|
||||
state: string | null,
|
||||
organizationId?: string | null,
|
||||
): Promise<void> {
|
||||
if (!code || !state) {
|
||||
const vscode = await importVscode()
|
||||
|
||||
if (vscode) {
|
||||
vscode.window.showInformationMessage("Invalid Roo Code Cloud sign in url")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate state parameter to prevent CSRF attacks.
|
||||
const storedState = this.context.globalState.get(AUTH_STATE_KEY)
|
||||
|
||||
if (state !== storedState) {
|
||||
this.log("[auth] State mismatch in callback")
|
||||
throw new Error("Invalid state parameter. Authentication request may have been tampered with.")
|
||||
}
|
||||
|
||||
const credentials = await this.clerkSignIn(code)
|
||||
|
||||
// Set organizationId (null for personal accounts)
|
||||
credentials.organizationId = organizationId || null
|
||||
|
||||
await this.storeCredentials(credentials)
|
||||
|
||||
const vscode = await importVscode()
|
||||
|
||||
if (vscode) {
|
||||
vscode.window.showInformationMessage("Successfully authenticated with Roo Code Cloud")
|
||||
}
|
||||
|
||||
this.log("[auth] Successfully authenticated with Roo Code Cloud")
|
||||
} catch (error) {
|
||||
this.log(`[auth] Error handling Roo Code Cloud callback: ${error}`)
|
||||
this.changeState("logged-out")
|
||||
throw new Error(`Failed to handle Roo Code Cloud callback: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out
|
||||
*
|
||||
* This method removes all stored tokens and stops the refresh timer.
|
||||
*/
|
||||
public async logout(): Promise<void> {
|
||||
const oldCredentials = this.credentials
|
||||
|
||||
try {
|
||||
// Clear credentials from storage - onDidChange will handle state transitions
|
||||
await this.clearCredentials()
|
||||
await this.context.globalState.update(AUTH_STATE_KEY, undefined)
|
||||
|
||||
if (oldCredentials) {
|
||||
try {
|
||||
await this.clerkLogout(oldCredentials)
|
||||
} catch (error) {
|
||||
this.log("[auth] Error calling clerkLogout:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const vscode = await importVscode()
|
||||
|
||||
if (vscode) {
|
||||
vscode.window.showInformationMessage("Logged out from Roo Code Cloud")
|
||||
}
|
||||
|
||||
this.log("[auth] Logged out from Roo Code Cloud")
|
||||
} catch (error) {
|
||||
this.log(`[auth] Error logging out from Roo Code Cloud: ${error}`)
|
||||
throw new Error(`Failed to log out from Roo Code Cloud: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
public getState(): AuthState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
public getSessionToken(): string | undefined {
|
||||
if (this.state === "active-session" && this.sessionToken) {
|
||||
return this.sessionToken
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is authenticated
|
||||
*
|
||||
* @returns True if the user is authenticated (has an active, attempting, or inactive session)
|
||||
*/
|
||||
public isAuthenticated(): boolean {
|
||||
return (
|
||||
this.state === "active-session" || this.state === "attempting-session" || this.state === "inactive-session"
|
||||
)
|
||||
}
|
||||
|
||||
public hasActiveSession(): boolean {
|
||||
return this.state === "active-session"
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has an active session or is currently attempting to acquire one
|
||||
*
|
||||
* @returns True if the user has an active session or is attempting to get one
|
||||
*/
|
||||
public hasOrIsAcquiringActiveSession(): boolean {
|
||||
return this.state === "active-session" || this.state === "attempting-session"
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the session
|
||||
*
|
||||
* This method refreshes the session token using the client token.
|
||||
*/
|
||||
private async refreshSession(): Promise<void> {
|
||||
if (!this.credentials) {
|
||||
this.log("[auth] Cannot refresh session: missing credentials")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const previousState = this.state
|
||||
this.sessionToken = await this.clerkCreateSessionToken()
|
||||
|
||||
if (previousState !== "active-session") {
|
||||
this.changeState("active-session")
|
||||
this.log("[auth] Transitioned to active-session state")
|
||||
this.fetchUserInfo()
|
||||
} else {
|
||||
this.state = "active-session"
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidClientTokenError) {
|
||||
this.log("[auth] Invalid/Expired client token: clearing credentials")
|
||||
this.clearCredentials()
|
||||
} else if (this.isFirstRefreshAttempt && this.state === "attempting-session") {
|
||||
this.isFirstRefreshAttempt = false
|
||||
this.transitionToInactiveSession()
|
||||
}
|
||||
this.log("[auth] Failed to refresh session", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchUserInfo(): Promise<void> {
|
||||
if (!this.credentials) {
|
||||
return
|
||||
}
|
||||
|
||||
this.userInfo = await this.clerkMe()
|
||||
this.emit("user-info", { userInfo: this.userInfo })
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user information from the ID token
|
||||
*
|
||||
* @returns User information from ID token claims or null if no ID token available
|
||||
*/
|
||||
public getUserInfo(): CloudUserInfo | null {
|
||||
return this.userInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored organization ID from credentials
|
||||
*
|
||||
* @returns The stored organization ID, null for personal accounts or if no credentials exist
|
||||
*/
|
||||
public getStoredOrganizationId(): string | null {
|
||||
return this.credentials?.organizationId || null
|
||||
}
|
||||
|
||||
private async clerkSignIn(ticket: string): Promise<AuthCredentials> {
|
||||
const formData = new URLSearchParams()
|
||||
formData.append("strategy", "ticket")
|
||||
formData.append("ticket", ticket)
|
||||
|
||||
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sign_ins`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": this.userAgent(),
|
||||
},
|
||||
body: formData.toString(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const {
|
||||
response: { created_session_id: sessionId },
|
||||
} = clerkSignInResponseSchema.parse(await response.json())
|
||||
|
||||
// 3. Extract the client token from the Authorization header.
|
||||
const clientToken = response.headers.get("authorization")
|
||||
|
||||
if (!clientToken) {
|
||||
throw new Error("No authorization header found in the response")
|
||||
}
|
||||
|
||||
return authCredentialsSchema.parse({ clientToken, sessionId })
|
||||
}
|
||||
|
||||
private async clerkCreateSessionToken(): Promise<string> {
|
||||
const formData = new URLSearchParams()
|
||||
formData.append("_is_native", "1")
|
||||
|
||||
// Handle 3 cases for organization_id:
|
||||
// 1. Have an org id: organization_id=THE_ORG_ID
|
||||
// 2. Have a personal account: organization_id= (empty string)
|
||||
// 3. Don't know if you have an org id (old style credentials): don't send organization_id param at all
|
||||
const organizationId = this.getStoredOrganizationId()
|
||||
if (this.credentials?.organizationId !== undefined) {
|
||||
// We have organization context info (either org id or personal account)
|
||||
formData.append("organization_id", organizationId || "")
|
||||
}
|
||||
// If organizationId is undefined, don't send the param at all (old credentials)
|
||||
|
||||
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${this.credentials!.clientToken}`,
|
||||
"User-Agent": this.userAgent(),
|
||||
},
|
||||
body: formData.toString(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (response.status === 401 || response.status === 404) {
|
||||
throw new InvalidClientTokenError()
|
||||
} else if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = clerkCreateSessionTokenResponseSchema.parse(await response.json())
|
||||
|
||||
return data.jwt
|
||||
}
|
||||
|
||||
private async clerkMe(): Promise<CloudUserInfo> {
|
||||
const response = await fetch(`${getClerkBaseUrl()}/v1/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.credentials!.clientToken}`,
|
||||
"User-Agent": this.userAgent(),
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
const { response: userData } = clerkMeResponseSchema.parse(payload)
|
||||
|
||||
const userInfo: CloudUserInfo = {
|
||||
id: userData.id,
|
||||
picture: userData.image_url,
|
||||
}
|
||||
|
||||
const names = [userData.first_name, userData.last_name].filter((name) => !!name)
|
||||
userInfo.name = names.length > 0 ? names.join(" ") : undefined
|
||||
const primaryEmailAddressId = userData.primary_email_address_id
|
||||
const emailAddresses = userData.email_addresses
|
||||
|
||||
if (primaryEmailAddressId && emailAddresses) {
|
||||
userInfo.email = emailAddresses.find(
|
||||
(email: { id: string }) => primaryEmailAddressId === email.id,
|
||||
)?.email_address
|
||||
}
|
||||
|
||||
// Check for extension_bridge_enabled in user's public metadata
|
||||
let extensionBridgeEnabled = false
|
||||
if (userData.public_metadata?.extension_bridge_enabled === true) {
|
||||
extensionBridgeEnabled = true
|
||||
}
|
||||
|
||||
// Fetch organization info if user is in organization context
|
||||
try {
|
||||
const storedOrgId = this.getStoredOrganizationId()
|
||||
|
||||
if (this.credentials?.organizationId !== undefined) {
|
||||
// We have organization context info
|
||||
if (storedOrgId !== null) {
|
||||
// User is in organization context - fetch user's memberships and filter
|
||||
const orgMemberships = await this.clerkGetOrganizationMemberships()
|
||||
const userMembership = this.findOrganizationMembership(orgMemberships, storedOrgId)
|
||||
|
||||
if (userMembership) {
|
||||
this.setUserOrganizationInfo(userInfo, userMembership)
|
||||
|
||||
// Check organization public metadata for extension_bridge_enabled
|
||||
// Organization setting takes precedence over user setting
|
||||
if (await this.isExtensionBridgeEnabledForOrganization(storedOrgId)) {
|
||||
extensionBridgeEnabled = true
|
||||
}
|
||||
|
||||
this.log("[auth] User in organization context:", {
|
||||
id: userMembership.organization.id,
|
||||
name: userMembership.organization.name,
|
||||
role: userMembership.role,
|
||||
})
|
||||
} else {
|
||||
this.log("[auth] Warning: User not found in stored organization:", storedOrgId)
|
||||
}
|
||||
} else {
|
||||
this.log("[auth] User in personal account context - not setting organization info")
|
||||
}
|
||||
} else {
|
||||
// Old credentials without organization context - fetch organization info to determine context
|
||||
const orgMemberships = await this.clerkGetOrganizationMemberships()
|
||||
const primaryOrgMembership = this.findPrimaryOrganizationMembership(orgMemberships)
|
||||
|
||||
if (primaryOrgMembership) {
|
||||
this.setUserOrganizationInfo(userInfo, primaryOrgMembership)
|
||||
|
||||
// Check organization public metadata for extension_bridge_enabled
|
||||
if (await this.isExtensionBridgeEnabledForOrganization(primaryOrgMembership.organization.id)) {
|
||||
extensionBridgeEnabled = true
|
||||
}
|
||||
|
||||
this.log("[auth] Legacy credentials: Found organization membership:", {
|
||||
id: primaryOrgMembership.organization.id,
|
||||
name: primaryOrgMembership.organization.name,
|
||||
role: primaryOrgMembership.role,
|
||||
})
|
||||
} else {
|
||||
this.log("[auth] Legacy credentials: No organization memberships found")
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log("[auth] Failed to fetch organization info:", error)
|
||||
// Don't throw - organization info is optional
|
||||
}
|
||||
|
||||
// Set the extension bridge enabled flag
|
||||
userInfo.extensionBridgeEnabled = extensionBridgeEnabled
|
||||
|
||||
return userInfo
|
||||
}
|
||||
|
||||
private findOrganizationMembership(
|
||||
memberships: CloudOrganizationMembership[],
|
||||
organizationId: string,
|
||||
): CloudOrganizationMembership | undefined {
|
||||
return memberships?.find((membership) => membership.organization.id === organizationId)
|
||||
}
|
||||
|
||||
private findPrimaryOrganizationMembership(
|
||||
memberships: CloudOrganizationMembership[],
|
||||
): CloudOrganizationMembership | undefined {
|
||||
return memberships && memberships.length > 0 ? memberships[0] : undefined
|
||||
}
|
||||
|
||||
private setUserOrganizationInfo(userInfo: CloudUserInfo, membership: CloudOrganizationMembership): void {
|
||||
userInfo.organizationId = membership.organization.id
|
||||
userInfo.organizationName = membership.organization.name
|
||||
userInfo.organizationRole = membership.role
|
||||
userInfo.organizationImageUrl = membership.organization.image_url
|
||||
}
|
||||
|
||||
private async clerkGetOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
|
||||
const response = await fetch(`${getClerkBaseUrl()}/v1/me/organization_memberships`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.credentials!.clientToken}`,
|
||||
"User-Agent": this.userAgent(),
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
return clerkOrganizationMembershipsSchema.parse(await response.json()).response
|
||||
}
|
||||
|
||||
private async getOrganizationMetadata(
|
||||
organizationId: string,
|
||||
): Promise<{ public_metadata?: Record<string, unknown> } | null> {
|
||||
try {
|
||||
const response = await fetch(`${getClerkBaseUrl()}/v1/organizations/${organizationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.credentials!.clientToken}`,
|
||||
"User-Agent": this.userAgent(),
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
this.log(`[auth] Failed to fetch organization metadata: ${response.status} ${response.statusText}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.response || data
|
||||
} catch (error) {
|
||||
this.log("[auth] Error fetching organization metadata:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async isExtensionBridgeEnabledForOrganization(organizationId: string): Promise<boolean> {
|
||||
const orgMetadata = await this.getOrganizationMetadata(organizationId)
|
||||
return orgMetadata?.public_metadata?.extension_bridge_enabled === true
|
||||
}
|
||||
|
||||
private async clerkLogout(credentials: AuthCredentials): Promise<void> {
|
||||
const formData = new URLSearchParams()
|
||||
formData.append("_is_native", "1")
|
||||
|
||||
const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${credentials.clientToken}`,
|
||||
"User-Agent": this.userAgent(),
|
||||
},
|
||||
body: formData.toString(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
private userAgent(): string {
|
||||
return getUserAgent(this.context)
|
||||
}
|
||||
}
|
||||
59
packages/cloud/src/__mocks__/vscode.ts
Normal file
59
packages/cloud/src/__mocks__/vscode.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export const window = {
|
||||
showInformationMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
}
|
||||
|
||||
export const env = {
|
||||
openExternal: vi.fn(),
|
||||
}
|
||||
|
||||
export const Uri = {
|
||||
parse: vi.fn((uri: string) => ({ toString: () => uri })),
|
||||
}
|
||||
|
||||
export interface ExtensionContext {
|
||||
secrets: {
|
||||
get: (key: string) => Promise<string | undefined>
|
||||
store: (key: string, value: string) => Promise<void>
|
||||
delete: (key: string) => Promise<void>
|
||||
onDidChange: (listener: (e: { key: string }) => void) => {
|
||||
dispose: () => void
|
||||
}
|
||||
}
|
||||
globalState: {
|
||||
get: <T>(key: string) => T | undefined
|
||||
update: (key: string, value: any) => Promise<void>
|
||||
}
|
||||
subscriptions: any[]
|
||||
extension?: {
|
||||
packageJSON?: {
|
||||
version?: string
|
||||
publisher?: string
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock implementation for tests
|
||||
export const mockExtensionContext: ExtensionContext = {
|
||||
secrets: {
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
store: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
||||
},
|
||||
globalState: {
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
subscriptions: [],
|
||||
extension: {
|
||||
packageJSON: {
|
||||
version: "1.0.0",
|
||||
publisher: "RooVeterinaryInc",
|
||||
name: "roo-cline",
|
||||
},
|
||||
},
|
||||
}
|
||||
147
packages/cloud/src/__tests__/CloudService.integration.test.ts
Normal file
147
packages/cloud/src/__tests__/CloudService.integration.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// npx vitest run src/__tests__/CloudService.integration.test.ts
|
||||
|
||||
import type { ExtensionContext } from "vscode"
|
||||
|
||||
import { CloudService } from "../CloudService.js"
|
||||
import { StaticSettingsService } from "../StaticSettingsService.js"
|
||||
import { CloudSettingsService } from "../CloudSettingsService.js"
|
||||
|
||||
vi.mock("vscode", () => ({
|
||||
ExtensionContext: vi.fn(),
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
},
|
||||
env: {
|
||||
openExternal: vi.fn(),
|
||||
},
|
||||
Uri: {
|
||||
parse: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("CloudService Integration - Settings Service Selection", () => {
|
||||
let mockContext: ExtensionContext
|
||||
|
||||
beforeEach(() => {
|
||||
CloudService.resetInstance()
|
||||
|
||||
mockContext = {
|
||||
subscriptions: [],
|
||||
workspaceState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
keys: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
secrets: {
|
||||
get: vi.fn(),
|
||||
store: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
||||
},
|
||||
globalState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
setKeysForSync: vi.fn(),
|
||||
keys: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
extensionUri: { scheme: "file", path: "/mock/path" },
|
||||
extensionPath: "/mock/path",
|
||||
extensionMode: 1,
|
||||
asAbsolutePath: vi.fn((relativePath: string) => `/mock/path/${relativePath}`),
|
||||
storageUri: { scheme: "file", path: "/mock/storage" },
|
||||
extension: {
|
||||
packageJSON: {
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
CloudService.resetInstance()
|
||||
delete process.env.ROO_CODE_CLOUD_ORG_SETTINGS
|
||||
delete process.env.ROO_CODE_CLOUD_TOKEN
|
||||
})
|
||||
|
||||
it("should use CloudSettingsService when no environment variable is set", async () => {
|
||||
// Ensure no environment variables are set
|
||||
delete process.env.ROO_CODE_CLOUD_ORG_SETTINGS
|
||||
delete process.env.ROO_CODE_CLOUD_TOKEN
|
||||
|
||||
const cloudService = await CloudService.createInstance(mockContext)
|
||||
|
||||
// Access the private settingsService to check its type
|
||||
const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService
|
||||
expect(settingsService).toBeInstanceOf(CloudSettingsService)
|
||||
})
|
||||
|
||||
it("should use StaticSettingsService when ROO_CODE_CLOUD_ORG_SETTINGS is set", async () => {
|
||||
const validSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
enableTaskSharing: true,
|
||||
taskShareExpirationDays: 30,
|
||||
},
|
||||
defaultSettings: {
|
||||
enableCheckpoints: true,
|
||||
},
|
||||
allowList: {
|
||||
allowAll: true,
|
||||
providers: {},
|
||||
},
|
||||
}
|
||||
|
||||
// Set the environment variable
|
||||
process.env.ROO_CODE_CLOUD_ORG_SETTINGS = Buffer.from(JSON.stringify(validSettings)).toString("base64")
|
||||
|
||||
const cloudService = await CloudService.createInstance(mockContext)
|
||||
|
||||
// Access the private settingsService to check its type
|
||||
const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService
|
||||
expect(settingsService).toBeInstanceOf(StaticSettingsService)
|
||||
|
||||
// Verify the settings are correctly loaded
|
||||
expect(cloudService.getAllowList()).toEqual(validSettings.allowList)
|
||||
})
|
||||
|
||||
it("should throw error when ROO_CODE_CLOUD_ORG_SETTINGS contains invalid data", async () => {
|
||||
// Set invalid environment variable
|
||||
process.env.ROO_CODE_CLOUD_ORG_SETTINGS = "invalid-base64-data"
|
||||
|
||||
await expect(CloudService.createInstance(mockContext)).rejects.toThrow("Failed to initialize CloudService")
|
||||
})
|
||||
|
||||
it("should prioritize static token auth when both environment variables are set", async () => {
|
||||
const validSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
enableTaskSharing: true,
|
||||
taskShareExpirationDays: 30,
|
||||
},
|
||||
defaultSettings: {
|
||||
enableCheckpoints: true,
|
||||
},
|
||||
allowList: {
|
||||
allowAll: true,
|
||||
providers: {},
|
||||
},
|
||||
}
|
||||
|
||||
// Set both environment variables
|
||||
process.env.ROO_CODE_CLOUD_TOKEN = "test-token"
|
||||
process.env.ROO_CODE_CLOUD_ORG_SETTINGS = Buffer.from(JSON.stringify(validSettings)).toString("base64")
|
||||
|
||||
const cloudService = await CloudService.createInstance(mockContext)
|
||||
|
||||
// Should use StaticSettingsService for settings
|
||||
const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService
|
||||
expect(settingsService).toBeInstanceOf(StaticSettingsService)
|
||||
|
||||
// Should use StaticTokenAuthService for auth (from the existing logic)
|
||||
expect(cloudService.isAuthenticated()).toBe(true)
|
||||
expect(cloudService.hasActiveSession()).toBe(true)
|
||||
})
|
||||
})
|
||||
600
packages/cloud/src/__tests__/CloudService.test.ts
Normal file
600
packages/cloud/src/__tests__/CloudService.test.ts
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
// npx vitest run src/__tests__/CloudService.test.ts
|
||||
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
import { TaskNotFoundError } from "../errors.js"
|
||||
import { CloudService } from "../CloudService.js"
|
||||
import { WebAuthService } from "../WebAuthService.js"
|
||||
import { CloudSettingsService } from "../CloudSettingsService.js"
|
||||
import { CloudShareService } from "../CloudShareService.js"
|
||||
import { CloudTelemetryClient as TelemetryClient } from "../TelemetryClient.js"
|
||||
|
||||
vi.mock("vscode", () => ({
|
||||
ExtensionContext: vi.fn(),
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
},
|
||||
env: {
|
||||
openExternal: vi.fn(),
|
||||
},
|
||||
Uri: {
|
||||
parse: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../WebAuthService")
|
||||
|
||||
vi.mock("../CloudSettingsService")
|
||||
|
||||
vi.mock("../CloudShareService")
|
||||
|
||||
vi.mock("../TelemetryClient")
|
||||
|
||||
describe("CloudService", () => {
|
||||
let mockContext: vscode.ExtensionContext
|
||||
|
||||
let mockAuthService: {
|
||||
initialize: ReturnType<typeof vi.fn>
|
||||
broadcast: ReturnType<typeof vi.fn>
|
||||
login: ReturnType<typeof vi.fn>
|
||||
logout: ReturnType<typeof vi.fn>
|
||||
isAuthenticated: ReturnType<typeof vi.fn>
|
||||
hasActiveSession: ReturnType<typeof vi.fn>
|
||||
hasOrIsAcquiringActiveSession: ReturnType<typeof vi.fn>
|
||||
getUserInfo: ReturnType<typeof vi.fn>
|
||||
getState: ReturnType<typeof vi.fn>
|
||||
getSessionToken: ReturnType<typeof vi.fn>
|
||||
handleCallback: ReturnType<typeof vi.fn>
|
||||
getStoredOrganizationId: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
off: ReturnType<typeof vi.fn>
|
||||
once: ReturnType<typeof vi.fn>
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let mockSettingsService: {
|
||||
initialize: ReturnType<typeof vi.fn>
|
||||
getSettings: ReturnType<typeof vi.fn>
|
||||
getAllowList: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
off: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let mockShareService: {
|
||||
shareTask: ReturnType<typeof vi.fn>
|
||||
canShareTask: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let mockTelemetryClient: {
|
||||
backfillMessages: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
CloudService.resetInstance()
|
||||
|
||||
mockContext = {
|
||||
subscriptions: [],
|
||||
workspaceState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
keys: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
secrets: {
|
||||
get: vi.fn(),
|
||||
store: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
||||
},
|
||||
globalState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
setKeysForSync: vi.fn(),
|
||||
keys: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
extensionUri: { scheme: "file", path: "/mock/path" },
|
||||
extensionPath: "/mock/path",
|
||||
extensionMode: 1,
|
||||
asAbsolutePath: vi.fn((relativePath: string) => `/mock/path/${relativePath}`),
|
||||
storageUri: { scheme: "file", path: "/mock/storage" },
|
||||
extension: {
|
||||
packageJSON: {
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
mockAuthService = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
broadcast: vi.fn(),
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAuthenticated: vi.fn().mockReturnValue(false),
|
||||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
hasOrIsAcquiringActiveSession: vi.fn().mockReturnValue(false),
|
||||
getUserInfo: vi.fn(),
|
||||
getState: vi.fn().mockReturnValue("logged-out"),
|
||||
getSessionToken: vi.fn(),
|
||||
handleCallback: vi.fn(),
|
||||
getStoredOrganizationId: vi.fn().mockReturnValue(null),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
}
|
||||
|
||||
mockSettingsService = {
|
||||
initialize: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getAllowList: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
}
|
||||
|
||||
mockShareService = {
|
||||
shareTask: vi.fn(),
|
||||
canShareTask: vi.fn().mockResolvedValue(true),
|
||||
}
|
||||
|
||||
mockTelemetryClient = {
|
||||
backfillMessages: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
vi.mocked(WebAuthService).mockImplementation(() => mockAuthService as unknown as WebAuthService)
|
||||
|
||||
vi.mocked(CloudSettingsService).mockImplementation(() => mockSettingsService as unknown as CloudSettingsService)
|
||||
|
||||
vi.mocked(CloudShareService).mockImplementation(() => mockShareService as unknown as CloudShareService)
|
||||
|
||||
vi.mocked(TelemetryClient).mockImplementation(() => mockTelemetryClient as unknown as TelemetryClient)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
CloudService.resetInstance()
|
||||
})
|
||||
|
||||
describe("createInstance", () => {
|
||||
it("should create and initialize CloudService instance", async () => {
|
||||
const mockLog = vi.fn()
|
||||
|
||||
const cloudService = await CloudService.createInstance(mockContext, mockLog)
|
||||
|
||||
expect(cloudService).toBeInstanceOf(CloudService)
|
||||
expect(WebAuthService).toHaveBeenCalledWith(mockContext, expect.any(Function))
|
||||
expect(CloudSettingsService).toHaveBeenCalledWith(mockContext, mockAuthService, expect.any(Function))
|
||||
})
|
||||
|
||||
it("should set up event listeners for CloudSettingsService", async () => {
|
||||
const mockLog = vi.fn()
|
||||
|
||||
await CloudService.createInstance(mockContext, mockLog)
|
||||
|
||||
expect(mockSettingsService.on).toHaveBeenCalledWith("settings-updated", expect.any(Function))
|
||||
})
|
||||
|
||||
it("should throw error if instance already exists", async () => {
|
||||
await CloudService.createInstance(mockContext)
|
||||
|
||||
await expect(CloudService.createInstance(mockContext)).rejects.toThrow(
|
||||
"CloudService instance already created",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("authentication methods", () => {
|
||||
let cloudService: CloudService
|
||||
|
||||
beforeEach(async () => {
|
||||
cloudService = await CloudService.createInstance(mockContext)
|
||||
})
|
||||
|
||||
it("should delegate login to AuthService", async () => {
|
||||
await cloudService.login()
|
||||
expect(mockAuthService.login).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should delegate logout to AuthService", async () => {
|
||||
await cloudService.logout()
|
||||
expect(mockAuthService.logout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should delegate isAuthenticated to AuthService", () => {
|
||||
const result = cloudService.isAuthenticated()
|
||||
expect(mockAuthService.isAuthenticated).toHaveBeenCalled()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should delegate hasActiveSession to AuthService", () => {
|
||||
const result = cloudService.hasActiveSession()
|
||||
expect(mockAuthService.hasActiveSession).toHaveBeenCalled()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should delegate getUserInfo to AuthService", async () => {
|
||||
await cloudService.getUserInfo()
|
||||
expect(mockAuthService.getUserInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should return organization ID from user info", () => {
|
||||
const mockUserInfo = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
organizationId: "org_123",
|
||||
organizationName: "Test Org",
|
||||
organizationRole: "admin",
|
||||
}
|
||||
mockAuthService.getUserInfo.mockReturnValue(mockUserInfo)
|
||||
|
||||
const result = cloudService.getOrganizationId()
|
||||
expect(mockAuthService.getUserInfo).toHaveBeenCalled()
|
||||
expect(result).toBe("org_123")
|
||||
})
|
||||
|
||||
it("should return null when no organization ID available", () => {
|
||||
mockAuthService.getUserInfo.mockReturnValue(null)
|
||||
|
||||
const result = cloudService.getOrganizationId()
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("should return organization name from user info", () => {
|
||||
const mockUserInfo = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
organizationId: "org_123",
|
||||
organizationName: "Test Org",
|
||||
organizationRole: "admin",
|
||||
}
|
||||
mockAuthService.getUserInfo.mockReturnValue(mockUserInfo)
|
||||
|
||||
const result = cloudService.getOrganizationName()
|
||||
expect(mockAuthService.getUserInfo).toHaveBeenCalled()
|
||||
expect(result).toBe("Test Org")
|
||||
})
|
||||
|
||||
it("should return null when no organization name available", () => {
|
||||
mockAuthService.getUserInfo.mockReturnValue(null)
|
||||
|
||||
const result = cloudService.getOrganizationName()
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("should return organization role from user info", () => {
|
||||
const mockUserInfo = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
organizationId: "org_123",
|
||||
organizationName: "Test Org",
|
||||
organizationRole: "admin",
|
||||
}
|
||||
mockAuthService.getUserInfo.mockReturnValue(mockUserInfo)
|
||||
|
||||
const result = cloudService.getOrganizationRole()
|
||||
expect(mockAuthService.getUserInfo).toHaveBeenCalled()
|
||||
expect(result).toBe("admin")
|
||||
})
|
||||
|
||||
it("should return null when no organization role available", () => {
|
||||
mockAuthService.getUserInfo.mockReturnValue(null)
|
||||
|
||||
const result = cloudService.getOrganizationRole()
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("should delegate getAuthState to AuthService", () => {
|
||||
const result = cloudService.getAuthState()
|
||||
expect(mockAuthService.getState).toHaveBeenCalled()
|
||||
expect(result).toBe("logged-out")
|
||||
})
|
||||
|
||||
it("should delegate handleAuthCallback to AuthService", async () => {
|
||||
await cloudService.handleAuthCallback("code", "state")
|
||||
expect(mockAuthService.handleCallback).toHaveBeenCalledWith("code", "state", undefined)
|
||||
})
|
||||
|
||||
it("should delegate handleAuthCallback with organizationId to AuthService", async () => {
|
||||
await cloudService.handleAuthCallback("code", "state", "org_123")
|
||||
expect(mockAuthService.handleCallback).toHaveBeenCalledWith("code", "state", "org_123")
|
||||
})
|
||||
|
||||
it("should return stored organization ID from AuthService", () => {
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue("org_456")
|
||||
|
||||
const result = cloudService.getStoredOrganizationId()
|
||||
expect(mockAuthService.getStoredOrganizationId).toHaveBeenCalled()
|
||||
expect(result).toBe("org_456")
|
||||
})
|
||||
|
||||
it("should return null when no stored organization ID available", () => {
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue(null)
|
||||
|
||||
const result = cloudService.getStoredOrganizationId()
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("should return true when stored organization ID exists", () => {
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue("org_789")
|
||||
|
||||
const result = cloudService.hasStoredOrganizationId()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when no stored organization ID exists", () => {
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue(null)
|
||||
|
||||
const result = cloudService.hasStoredOrganizationId()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("organization settings methods", () => {
|
||||
let cloudService: CloudService
|
||||
|
||||
beforeEach(async () => {
|
||||
cloudService = await CloudService.createInstance(mockContext)
|
||||
})
|
||||
|
||||
it("should delegate getAllowList to SettingsService", () => {
|
||||
cloudService.getAllowList()
|
||||
expect(mockSettingsService.getAllowList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should throw error when accessing methods before initialization", () => {
|
||||
expect(() => CloudService.instance.login()).toThrow("CloudService not initialized")
|
||||
})
|
||||
|
||||
it("should throw error when accessing instance before creation", () => {
|
||||
expect(() => CloudService.instance).toThrow("CloudService not initialized")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasInstance", () => {
|
||||
it("should return false when no instance exists", () => {
|
||||
expect(CloudService.hasInstance()).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when instance exists and is initialized", async () => {
|
||||
await CloudService.createInstance(mockContext)
|
||||
expect(CloudService.hasInstance()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should dispose of all services and clean up", async () => {
|
||||
const cloudService = await CloudService.createInstance(mockContext)
|
||||
cloudService.dispose()
|
||||
|
||||
expect(mockSettingsService.dispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should remove event listeners from CloudSettingsService", async () => {
|
||||
// Create a mock that will pass the instanceof check
|
||||
const mockCloudSettingsService = Object.create(CloudSettingsService.prototype)
|
||||
Object.assign(mockCloudSettingsService, {
|
||||
initialize: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getAllowList: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
})
|
||||
|
||||
// Override the mock to return our properly typed instance
|
||||
vi.mocked(CloudSettingsService).mockImplementation(() => mockCloudSettingsService)
|
||||
|
||||
const cloudService = await CloudService.createInstance(mockContext)
|
||||
|
||||
// Verify the listener was added
|
||||
expect(mockCloudSettingsService.on).toHaveBeenCalledWith("settings-updated", expect.any(Function))
|
||||
|
||||
// Get the listener function that was registered
|
||||
const registeredListener = mockCloudSettingsService.on.mock.calls.find(
|
||||
(call: unknown[]) => call[0] === "settings-updated",
|
||||
)?.[1]
|
||||
|
||||
cloudService.dispose()
|
||||
|
||||
// Verify the listener was removed with the same function
|
||||
expect(mockCloudSettingsService.off).toHaveBeenCalledWith("settings-updated", registeredListener)
|
||||
})
|
||||
|
||||
it("should handle disposal when using StaticSettingsService", async () => {
|
||||
// Reset the instance first
|
||||
CloudService.resetInstance()
|
||||
|
||||
// Mock a StaticSettingsService (which doesn't extend CloudSettingsService)
|
||||
const mockStaticSettingsService = {
|
||||
initialize: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getAllowList: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
on: vi.fn(), // Add on method to avoid initialization error
|
||||
off: vi.fn(), // Add off method for disposal
|
||||
}
|
||||
|
||||
// Override the mock to return a service that won't pass instanceof check
|
||||
vi.mocked(CloudSettingsService).mockImplementation(
|
||||
() => mockStaticSettingsService as unknown as CloudSettingsService,
|
||||
)
|
||||
|
||||
// This should not throw even though the service doesn't pass instanceof check
|
||||
const _cloudService = await CloudService.createInstance(mockContext)
|
||||
|
||||
// Should not throw when disposing
|
||||
expect(() => _cloudService.dispose()).not.toThrow()
|
||||
|
||||
// Should still call dispose on the settings service
|
||||
expect(mockStaticSettingsService.dispose).toHaveBeenCalled()
|
||||
// Should NOT call off method since it's not a CloudSettingsService instance
|
||||
expect(mockStaticSettingsService.off).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("settings event handling", () => {
|
||||
let _cloudService: CloudService
|
||||
|
||||
beforeEach(async () => {
|
||||
_cloudService = await CloudService.createInstance(mockContext)
|
||||
})
|
||||
|
||||
it("should emit settings-updated event when settings are updated", async () => {
|
||||
const settingsListener = vi.fn()
|
||||
_cloudService.on("settings-updated", settingsListener)
|
||||
|
||||
// Get the settings listener that was registered with the settings service
|
||||
const serviceSettingsListener = mockSettingsService.on.mock.calls.find(
|
||||
(call: string[]) => call[0] === "settings-updated",
|
||||
)?.[1]
|
||||
|
||||
expect(serviceSettingsListener).toBeDefined()
|
||||
|
||||
// Simulate settings update event
|
||||
const settingsData = {
|
||||
settings: {
|
||||
version: 2,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
},
|
||||
previousSettings: {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
},
|
||||
}
|
||||
serviceSettingsListener(settingsData)
|
||||
|
||||
expect(settingsListener).toHaveBeenCalledWith(settingsData)
|
||||
})
|
||||
})
|
||||
|
||||
describe("shareTask with ClineMessage retry logic", () => {
|
||||
let cloudService: CloudService
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset mocks for shareTask tests
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset authentication state for shareTask tests
|
||||
mockAuthService.isAuthenticated.mockReturnValue(true)
|
||||
mockAuthService.hasActiveSession.mockReturnValue(true)
|
||||
mockAuthService.hasOrIsAcquiringActiveSession.mockReturnValue(true)
|
||||
mockAuthService.getState.mockReturnValue("active")
|
||||
|
||||
cloudService = await CloudService.createInstance(mockContext)
|
||||
})
|
||||
|
||||
it("should call shareTask without retry when successful", async () => {
|
||||
const taskId = "test-task-id"
|
||||
const visibility = "organization"
|
||||
const clineMessages: ClineMessage[] = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "Hello world",
|
||||
},
|
||||
]
|
||||
|
||||
const expectedResult = {
|
||||
success: true,
|
||||
shareUrl: "https://example.com/share/123",
|
||||
}
|
||||
mockShareService.shareTask.mockResolvedValue(expectedResult)
|
||||
|
||||
const result = await cloudService.shareTask(taskId, visibility, clineMessages)
|
||||
|
||||
expect(mockShareService.shareTask).toHaveBeenCalledTimes(1)
|
||||
expect(mockShareService.shareTask).toHaveBeenCalledWith(taskId, visibility)
|
||||
expect(mockTelemetryClient.backfillMessages).not.toHaveBeenCalled()
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it("should retry with backfill when TaskNotFoundError occurs", async () => {
|
||||
const taskId = "test-task-id"
|
||||
const visibility = "organization"
|
||||
const clineMessages: ClineMessage[] = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "Hello world",
|
||||
},
|
||||
]
|
||||
|
||||
const expectedResult = {
|
||||
success: true,
|
||||
shareUrl: "https://example.com/share/123",
|
||||
}
|
||||
|
||||
// First call throws TaskNotFoundError, second call succeeds
|
||||
mockShareService.shareTask
|
||||
.mockRejectedValueOnce(new TaskNotFoundError(taskId))
|
||||
.mockResolvedValueOnce(expectedResult)
|
||||
|
||||
const result = await cloudService.shareTask(taskId, visibility, clineMessages)
|
||||
|
||||
expect(mockShareService.shareTask).toHaveBeenCalledTimes(2)
|
||||
expect(mockShareService.shareTask).toHaveBeenNthCalledWith(1, taskId, visibility)
|
||||
expect(mockShareService.shareTask).toHaveBeenNthCalledWith(2, taskId, visibility)
|
||||
expect(mockTelemetryClient.backfillMessages).toHaveBeenCalledTimes(1)
|
||||
expect(mockTelemetryClient.backfillMessages).toHaveBeenCalledWith(clineMessages, taskId)
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it("should not retry when TaskNotFoundError occurs but no clineMessages provided", async () => {
|
||||
const taskId = "test-task-id"
|
||||
const visibility = "organization"
|
||||
|
||||
const taskNotFoundError = new TaskNotFoundError(taskId)
|
||||
mockShareService.shareTask.mockRejectedValue(taskNotFoundError)
|
||||
|
||||
await expect(cloudService.shareTask(taskId, visibility)).rejects.toThrow(TaskNotFoundError)
|
||||
|
||||
expect(mockShareService.shareTask).toHaveBeenCalledTimes(1)
|
||||
expect(mockTelemetryClient.backfillMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not retry when non-TaskNotFoundError occurs", async () => {
|
||||
const taskId = "test-task-id"
|
||||
const visibility = "organization"
|
||||
const clineMessages: ClineMessage[] = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "Hello world",
|
||||
},
|
||||
]
|
||||
|
||||
const genericError = new Error("Some other error")
|
||||
mockShareService.shareTask.mockRejectedValue(genericError)
|
||||
|
||||
await expect(cloudService.shareTask(taskId, visibility, clineMessages)).rejects.toThrow(genericError)
|
||||
|
||||
expect(mockShareService.shareTask).toHaveBeenCalledTimes(1)
|
||||
expect(mockTelemetryClient.backfillMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should work with default parameters", async () => {
|
||||
const taskId = "test-task-id"
|
||||
const expectedResult = {
|
||||
success: true,
|
||||
shareUrl: "https://example.com/share/123",
|
||||
}
|
||||
mockShareService.shareTask.mockResolvedValue(expectedResult)
|
||||
|
||||
const result = await cloudService.shareTask(taskId)
|
||||
|
||||
expect(mockShareService.shareTask).toHaveBeenCalledTimes(1)
|
||||
expect(mockShareService.shareTask).toHaveBeenCalledWith(taskId, "organization")
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
// pnpm test src/__tests__/CloudSettingsService.parsing.test.ts
|
||||
|
||||
import type { ExtensionContext } from "vscode"
|
||||
|
||||
import type { AuthService } from "@roo-code/types"
|
||||
|
||||
import { CloudSettingsService } from "../CloudSettingsService.js"
|
||||
|
||||
describe("CloudSettingsService - Response Parsing", () => {
|
||||
let mockContext: ExtensionContext
|
||||
let mockAuthService: AuthService
|
||||
let service: CloudSettingsService
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock ExtensionContext
|
||||
mockContext = {
|
||||
globalState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
// Mock AuthService with active session
|
||||
mockAuthService = {
|
||||
getState: vi.fn().mockReturnValue("active-session"),
|
||||
hasActiveSession: vi.fn().mockReturnValue(true),
|
||||
getSessionToken: vi.fn().mockReturnValue("test-token"),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
} as unknown as AuthService
|
||||
|
||||
service = new CloudSettingsService(mockContext, mockAuthService, vi.fn())
|
||||
})
|
||||
|
||||
it("should successfully parse valid extension settings response", async () => {
|
||||
// Mock fetch response with a valid settings structure
|
||||
const mockResponse = {
|
||||
organization: {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: {
|
||||
allowAll: true,
|
||||
providers: {},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
features: {},
|
||||
settings: {},
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponse),
|
||||
})
|
||||
|
||||
// Initialize the service
|
||||
await service.initialize()
|
||||
|
||||
// Wait for the fetch to be called (timer executes immediately but asynchronously)
|
||||
await vi.waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Wait a bit for the async processing to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// Verify settings were parsed correctly
|
||||
const orgSettings = service.getSettings()
|
||||
const userSettings = service.getUserSettings()
|
||||
|
||||
expect(orgSettings).toEqual(mockResponse.organization)
|
||||
expect(userSettings).toEqual(mockResponse.user)
|
||||
})
|
||||
|
||||
it("should handle complex nested provider settings without type errors", async () => {
|
||||
// Mock response with complex nested provider settings
|
||||
const mockResponse = {
|
||||
organization: {
|
||||
version: 2,
|
||||
defaultSettings: {
|
||||
maxOpenTabsContext: 10,
|
||||
maxReadFileLine: 1000,
|
||||
},
|
||||
allowList: {
|
||||
allowAll: false,
|
||||
providers: {
|
||||
anthropic: {
|
||||
allowAll: true,
|
||||
},
|
||||
openai: {
|
||||
allowAll: false,
|
||||
models: ["gpt-4", "gpt-3.5-turbo"],
|
||||
},
|
||||
},
|
||||
},
|
||||
providerProfiles: {
|
||||
default: {
|
||||
id: "default",
|
||||
apiProvider: "anthropic",
|
||||
apiModelId: "claude-3-opus-20240229",
|
||||
apiKey: "test-key",
|
||||
modelTemperature: 0.7,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
features: {
|
||||
roomoteControlEnabled: true,
|
||||
},
|
||||
settings: {
|
||||
extensionBridgeEnabled: true,
|
||||
},
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponse),
|
||||
})
|
||||
|
||||
// Initialize the service
|
||||
await service.initialize()
|
||||
|
||||
// Wait for the fetch to be called (timer executes immediately but asynchronously)
|
||||
await vi.waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Wait a bit for the async processing to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// Verify complex settings were parsed correctly
|
||||
const orgSettings = service.getSettings()
|
||||
const userSettings = service.getUserSettings()
|
||||
|
||||
expect(orgSettings).toEqual(mockResponse.organization)
|
||||
expect(userSettings).toEqual(mockResponse.user)
|
||||
expect(orgSettings?.providerProfiles?.default).toBeDefined()
|
||||
})
|
||||
|
||||
it("should handle invalid response gracefully", async () => {
|
||||
// Mock invalid response
|
||||
const mockResponse = {
|
||||
organization: {
|
||||
// Missing required fields
|
||||
version: 1,
|
||||
},
|
||||
user: {
|
||||
// Missing required fields
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponse),
|
||||
})
|
||||
|
||||
// Initialize the service
|
||||
await service.initialize()
|
||||
|
||||
// Settings should remain undefined due to validation failure
|
||||
const orgSettings = service.getSettings()
|
||||
const userSettings = service.getUserSettings()
|
||||
|
||||
expect(orgSettings).toBeUndefined()
|
||||
expect(userSettings).toBeUndefined()
|
||||
})
|
||||
})
|
||||
535
packages/cloud/src/__tests__/CloudSettingsService.test.ts
Normal file
535
packages/cloud/src/__tests__/CloudSettingsService.test.ts
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
import type { ExtensionContext } from "vscode"
|
||||
|
||||
import type { OrganizationSettings, AuthService } from "@roo-code/types"
|
||||
|
||||
import { CloudSettingsService } from "../CloudSettingsService.js"
|
||||
import { RefreshTimer } from "../RefreshTimer.js"
|
||||
|
||||
vi.mock("../RefreshTimer")
|
||||
|
||||
vi.mock("../config", () => ({
|
||||
getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
|
||||
}))
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe("CloudSettingsService", () => {
|
||||
let mockContext: ExtensionContext
|
||||
let mockAuthService: {
|
||||
getState: ReturnType<typeof vi.fn>
|
||||
getSessionToken: ReturnType<typeof vi.fn>
|
||||
hasActiveSession: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let mockRefreshTimer: {
|
||||
start: ReturnType<typeof vi.fn>
|
||||
stop: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let cloudSettingsService: CloudSettingsService
|
||||
let mockLog: ReturnType<typeof vi.fn>
|
||||
|
||||
const mockSettings: OrganizationSettings = {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: {
|
||||
allowAll: true,
|
||||
providers: {},
|
||||
},
|
||||
}
|
||||
|
||||
const mockUserSettings = {
|
||||
features: {},
|
||||
settings: {},
|
||||
version: 1,
|
||||
}
|
||||
|
||||
const mockExtensionSettingsResponse = {
|
||||
organization: mockSettings,
|
||||
user: mockUserSettings,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockContext = {
|
||||
globalState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
mockAuthService = {
|
||||
getState: vi.fn().mockReturnValue("logged-out"),
|
||||
getSessionToken: vi.fn(),
|
||||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
mockRefreshTimer = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
|
||||
mockLog = vi.fn()
|
||||
|
||||
// Mock RefreshTimer constructor
|
||||
vi.mocked(RefreshTimer).mockImplementation(() => mockRefreshTimer as unknown as RefreshTimer)
|
||||
|
||||
cloudSettingsService = new CloudSettingsService(mockContext, mockAuthService as unknown as AuthService, mockLog)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cloudSettingsService.dispose()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create CloudSettingsService with proper dependencies", () => {
|
||||
expect(cloudSettingsService).toBeInstanceOf(CloudSettingsService)
|
||||
expect(RefreshTimer).toHaveBeenCalledWith({
|
||||
callback: expect.any(Function),
|
||||
successInterval: 30000,
|
||||
initialBackoffMs: 1000,
|
||||
maxBackoffMs: 30000,
|
||||
})
|
||||
})
|
||||
|
||||
it("should use console.log as default logger when none provided", () => {
|
||||
const service = new CloudSettingsService(mockContext, mockAuthService as unknown as AuthService)
|
||||
expect(service).toBeInstanceOf(CloudSettingsService)
|
||||
})
|
||||
})
|
||||
|
||||
describe("initialize", () => {
|
||||
it("should load cached settings on initialization", async () => {
|
||||
const cachedSettings = {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
// Create a fresh mock context for this test
|
||||
const testContext = {
|
||||
globalState: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return cachedSettings
|
||||
if (key === "user-settings") return mockUserSettings
|
||||
return undefined
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
// Mock auth service to not be logged out
|
||||
const testAuthService = {
|
||||
getState: vi.fn().mockReturnValue("active"),
|
||||
getSessionToken: vi.fn(),
|
||||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
// Create a new instance to test initialization
|
||||
const testService = new CloudSettingsService(
|
||||
testContext,
|
||||
testAuthService as unknown as AuthService,
|
||||
mockLog,
|
||||
)
|
||||
await testService.initialize()
|
||||
|
||||
expect(testContext.globalState.get).toHaveBeenCalledWith("organization-settings")
|
||||
expect(testContext.globalState.get).toHaveBeenCalledWith("user-settings")
|
||||
expect(testService.getSettings()).toEqual(cachedSettings)
|
||||
|
||||
testService.dispose()
|
||||
})
|
||||
|
||||
it("should clear cached settings if user is logged out", async () => {
|
||||
const cachedSettings = {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
mockContext.globalState.get = vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return cachedSettings
|
||||
if (key === "user-settings") return mockUserSettings
|
||||
return undefined
|
||||
})
|
||||
mockAuthService.getState.mockReturnValue("logged-out")
|
||||
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
// Check that both cache keys are cleared
|
||||
const updateCalls = vi.mocked(mockContext.globalState.update).mock.calls
|
||||
const orgSettingsCall = updateCalls.find((call) => call[0] === "organization-settings")
|
||||
const userSettingsCall = updateCalls.find((call) => call[0] === "user-settings")
|
||||
|
||||
expect(orgSettingsCall).toBeDefined()
|
||||
expect(orgSettingsCall?.[1]).toBeUndefined()
|
||||
expect(userSettingsCall).toBeDefined()
|
||||
expect(userSettingsCall?.[1]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should set up auth service event listeners", async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
expect(mockAuthService.on).toHaveBeenCalledWith("auth-state-changed", expect.any(Function))
|
||||
})
|
||||
|
||||
it("should start timer if user has active session", async () => {
|
||||
mockAuthService.hasActiveSession.mockReturnValue(true)
|
||||
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
expect(mockRefreshTimer.start).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not start timer if user has no active session", async () => {
|
||||
mockAuthService.hasActiveSession.mockReturnValue(false)
|
||||
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
expect(mockRefreshTimer.start).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("event emission", () => {
|
||||
beforeEach(async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
})
|
||||
|
||||
it("should emit 'settings-updated' event when settings change", async () => {
|
||||
const eventSpy = vi.fn()
|
||||
cloudSettingsService.on("settings-updated", eventSpy)
|
||||
|
||||
mockAuthService.getSessionToken.mockReturnValue("valid-token")
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockExtensionSettingsResponse),
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
await timerCallback?.()
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("should emit event when either org or user settings change", async () => {
|
||||
const eventSpy = vi.fn()
|
||||
|
||||
const previousSettings = {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
const newSettings = {
|
||||
version: 2,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
// Create a fresh mock context for this test
|
||||
const testContext = {
|
||||
globalState: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return previousSettings
|
||||
if (key === "user-settings") return mockUserSettings
|
||||
return undefined
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
// Mock auth service to not be logged out
|
||||
const testAuthService = {
|
||||
getState: vi.fn().mockReturnValue("active"),
|
||||
getSessionToken: vi.fn().mockReturnValue("valid-token"),
|
||||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
// Create a new service instance with cached settings
|
||||
const testService = new CloudSettingsService(
|
||||
testContext,
|
||||
testAuthService as unknown as AuthService,
|
||||
mockLog,
|
||||
)
|
||||
testService.on("settings-updated", eventSpy)
|
||||
await testService.initialize()
|
||||
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
organization: newSettings,
|
||||
user: mockUserSettings,
|
||||
}),
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer for this instance
|
||||
const timerCallback =
|
||||
vi.mocked(RefreshTimer).mock.calls[vi.mocked(RefreshTimer).mock.calls.length - 1]?.[0]?.callback
|
||||
|
||||
await timerCallback?.()
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith({})
|
||||
|
||||
testService.dispose()
|
||||
})
|
||||
|
||||
it("should not emit event when settings version is unchanged", async () => {
|
||||
const eventSpy = vi.fn()
|
||||
|
||||
// Create a fresh mock context for this test
|
||||
const testContext = {
|
||||
globalState: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return mockSettings
|
||||
if (key === "user-settings") return mockUserSettings
|
||||
return undefined
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
// Mock auth service to not be logged out
|
||||
const testAuthService = {
|
||||
getState: vi.fn().mockReturnValue("active"),
|
||||
getSessionToken: vi.fn().mockReturnValue("valid-token"),
|
||||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
// Create a new service instance with cached settings
|
||||
const testService = new CloudSettingsService(
|
||||
testContext,
|
||||
testAuthService as unknown as AuthService,
|
||||
mockLog,
|
||||
)
|
||||
testService.on("settings-updated", eventSpy)
|
||||
await testService.initialize()
|
||||
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockExtensionSettingsResponse), // Same version
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer for this instance
|
||||
const timerCallback =
|
||||
vi.mocked(RefreshTimer).mock.calls[vi.mocked(RefreshTimer).mock.calls.length - 1]?.[0]?.callback
|
||||
|
||||
await timerCallback?.()
|
||||
|
||||
expect(eventSpy).not.toHaveBeenCalled()
|
||||
|
||||
testService.dispose()
|
||||
})
|
||||
|
||||
it("should not emit event when fetch fails", async () => {
|
||||
const eventSpy = vi.fn()
|
||||
cloudSettingsService.on("settings-updated", eventSpy)
|
||||
|
||||
mockAuthService.getSessionToken.mockReturnValue("valid-token")
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
await timerCallback?.()
|
||||
|
||||
expect(eventSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not emit event when no auth token available", async () => {
|
||||
const eventSpy = vi.fn()
|
||||
cloudSettingsService.on("settings-updated", eventSpy)
|
||||
|
||||
mockAuthService.getSessionToken.mockReturnValue(null)
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
await timerCallback?.()
|
||||
|
||||
expect(eventSpy).not.toHaveBeenCalled()
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchSettings", () => {
|
||||
beforeEach(async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
})
|
||||
|
||||
it("should fetch and cache settings successfully", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue("valid-token")
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockExtensionSettingsResponse),
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
const result = await timerCallback?.()
|
||||
|
||||
expect(result).toBe(true)
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension-settings", {
|
||||
headers: {
|
||||
Authorization: "Bearer valid-token",
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("organization-settings", mockSettings)
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("user-settings", mockUserSettings)
|
||||
})
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue("valid-token")
|
||||
vi.mocked(fetch).mockRejectedValue(new Error("Network error"))
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
const result = await timerCallback?.()
|
||||
|
||||
expect(result).toBe(false)
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[cloud-settings] Error fetching extension settings:",
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle invalid response format", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue("valid-token")
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ invalid: "data" }),
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
const result = await timerCallback?.()
|
||||
|
||||
expect(result).toBe(false)
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[cloud-settings] Invalid extension settings format:",
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllowList", () => {
|
||||
it("should return settings allowList when available", async () => {
|
||||
mockContext.globalState.get = vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return mockSettings
|
||||
return undefined
|
||||
})
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
const allowList = cloudSettingsService.getAllowList()
|
||||
expect(allowList).toEqual(mockSettings.allowList)
|
||||
})
|
||||
|
||||
it("should return default allow all when no settings available", () => {
|
||||
const allowList = cloudSettingsService.getAllowList()
|
||||
expect(allowList).toEqual({ allowAll: true, providers: {} })
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSettings", () => {
|
||||
it("should return current settings", async () => {
|
||||
// Create a fresh mock context for this test
|
||||
const testContext = {
|
||||
globalState: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return mockSettings
|
||||
return undefined
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
// Mock auth service to not be logged out
|
||||
const testAuthService = {
|
||||
getState: vi.fn().mockReturnValue("active"),
|
||||
getSessionToken: vi.fn(),
|
||||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
const testService = new CloudSettingsService(
|
||||
testContext,
|
||||
testAuthService as unknown as AuthService,
|
||||
mockLog,
|
||||
)
|
||||
await testService.initialize()
|
||||
|
||||
const settings = testService.getSettings()
|
||||
expect(settings).toEqual(mockSettings)
|
||||
|
||||
testService.dispose()
|
||||
})
|
||||
|
||||
it("should return undefined when no settings available", () => {
|
||||
const settings = cloudSettingsService.getSettings()
|
||||
expect(settings).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should remove all listeners and stop timer", () => {
|
||||
const removeAllListenersSpy = vi.spyOn(cloudSettingsService, "removeAllListeners")
|
||||
|
||||
cloudSettingsService.dispose()
|
||||
|
||||
expect(removeAllListenersSpy).toHaveBeenCalled()
|
||||
expect(mockRefreshTimer.stop).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth service event handlers", () => {
|
||||
it("should start timer when auth-state-changed event is triggered with active-session", async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
// Get the auth-state-changed handler
|
||||
const authStateChangedHandler = mockAuthService.on.mock.calls.find(
|
||||
(call: string[]) => call[0] === "auth-state-changed",
|
||||
)?.[1]
|
||||
expect(authStateChangedHandler).toBeDefined()
|
||||
|
||||
// Simulate active-session state change
|
||||
authStateChangedHandler({
|
||||
state: "active-session",
|
||||
previousState: "attempting-session",
|
||||
})
|
||||
expect(mockRefreshTimer.start).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should stop timer and remove settings when auth-state-changed event is triggered with logged-out", async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
// Get the auth-state-changed handler
|
||||
const authStateChangedHandler = mockAuthService.on.mock.calls.find(
|
||||
(call: string[]) => call[0] === "auth-state-changed",
|
||||
)?.[1]
|
||||
expect(authStateChangedHandler).toBeDefined()
|
||||
|
||||
// Simulate logged-out state change from active-session
|
||||
await authStateChangedHandler({
|
||||
state: "logged-out",
|
||||
previousState: "active-session",
|
||||
})
|
||||
expect(mockRefreshTimer.stop).toHaveBeenCalled()
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("organization-settings", undefined)
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("user-settings", undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
318
packages/cloud/src/__tests__/CloudShareService.test.ts
Normal file
318
packages/cloud/src/__tests__/CloudShareService.test.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { MockedFunction } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import type { SettingsService, AuthService } from "@roo-code/types"
|
||||
|
||||
import { CloudAPI } from "../CloudAPI.js"
|
||||
import { CloudShareService } from "../CloudShareService.js"
|
||||
import { CloudAPIError, TaskNotFoundError } from "../errors.js"
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch as any
|
||||
|
||||
vi.mock("vscode", () => ({
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
showQuickPick: vi.fn(),
|
||||
},
|
||||
env: {
|
||||
clipboard: {
|
||||
writeText: vi.fn(),
|
||||
},
|
||||
openExternal: vi.fn(),
|
||||
},
|
||||
Uri: {
|
||||
parse: vi.fn(),
|
||||
},
|
||||
extensions: {
|
||||
getExtension: vi.fn(() => ({
|
||||
packageJSON: { version: "1.0.0" },
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../Config", () => ({
|
||||
getRooCodeApiUrl: () => "https://app.roocode.com",
|
||||
}))
|
||||
|
||||
vi.mock("../utils", () => ({
|
||||
getUserAgent: () => "Roo-Code 1.0.0",
|
||||
}))
|
||||
|
||||
describe("CloudShareService", () => {
|
||||
let shareService: CloudShareService
|
||||
let mockAuthService: AuthService
|
||||
let mockSettingsService: SettingsService
|
||||
let mockCloudAPI: CloudAPI
|
||||
let mockLog: MockedFunction<(...args: unknown[]) => void>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetch.mockClear()
|
||||
|
||||
mockLog = vi.fn()
|
||||
mockAuthService = {
|
||||
hasActiveSession: vi.fn(),
|
||||
getSessionToken: vi.fn(),
|
||||
isAuthenticated: vi.fn(),
|
||||
} as any
|
||||
|
||||
mockSettingsService = {
|
||||
getSettings: vi.fn(),
|
||||
} as any
|
||||
|
||||
mockCloudAPI = new CloudAPI(mockAuthService, mockLog)
|
||||
|
||||
shareService = new CloudShareService(mockCloudAPI, mockSettingsService, mockLog)
|
||||
})
|
||||
|
||||
describe("shareTask", () => {
|
||||
it("should share task with organization visibility and copy to clipboard", async () => {
|
||||
const mockResponseData = {
|
||||
success: true,
|
||||
shareUrl: "https://app.roocode.com/share/abc123",
|
||||
}
|
||||
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponseData),
|
||||
})
|
||||
|
||||
const result = await shareService.shareTask("task-123", "organization")
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.shareUrl).toBe("https://app.roocode.com/share/abc123")
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/share", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer session-token",
|
||||
"User-Agent": "Roo-Code 1.0.0",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
taskId: "task-123",
|
||||
visibility: "organization",
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
|
||||
expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith("https://app.roocode.com/share/abc123")
|
||||
})
|
||||
|
||||
it("should share task with public visibility", async () => {
|
||||
const mockResponseData = {
|
||||
success: true,
|
||||
shareUrl: "https://app.roocode.com/share/abc123",
|
||||
}
|
||||
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponseData),
|
||||
})
|
||||
|
||||
const result = await shareService.shareTask("task-123", "public")
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/share", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer session-token",
|
||||
"User-Agent": "Roo-Code 1.0.0",
|
||||
},
|
||||
body: JSON.stringify({ taskId: "task-123", visibility: "public" }),
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
})
|
||||
|
||||
it("should default to organization visibility when not specified", async () => {
|
||||
const mockResponseData = {
|
||||
success: true,
|
||||
shareUrl: "https://app.roocode.com/share/abc123",
|
||||
}
|
||||
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponseData),
|
||||
})
|
||||
|
||||
const result = await shareService.shareTask("task-123")
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/share", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer session-token",
|
||||
"User-Agent": "Roo-Code 1.0.0",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
taskId: "task-123",
|
||||
visibility: "organization",
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle API error response", async () => {
|
||||
const mockResponseData = {
|
||||
success: false,
|
||||
error: "Task not found",
|
||||
}
|
||||
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponseData),
|
||||
})
|
||||
|
||||
const result = await shareService.shareTask("task-123", "organization")
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Task not found")
|
||||
})
|
||||
|
||||
it("should handle authentication errors", async () => {
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue(null)
|
||||
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow("Authentication required")
|
||||
})
|
||||
|
||||
it("should handle unexpected errors", async () => {
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow("Network error")
|
||||
})
|
||||
|
||||
it("should throw TaskNotFoundError for 404 responses", async () => {
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
json: vi.fn().mockRejectedValue(new Error("Invalid JSON")),
|
||||
text: vi.fn().mockResolvedValue("Not Found"),
|
||||
})
|
||||
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow(TaskNotFoundError)
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow("Task not found")
|
||||
})
|
||||
|
||||
it("should throw generic Error for non-404 HTTP errors", async () => {
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
json: vi.fn().mockRejectedValue(new Error("Invalid JSON")),
|
||||
text: vi.fn().mockResolvedValue("Internal Server Error"),
|
||||
})
|
||||
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow(CloudAPIError)
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow(
|
||||
"HTTP 500: Internal Server Error",
|
||||
)
|
||||
})
|
||||
|
||||
it("should create TaskNotFoundError with correct properties", async () => {
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
json: vi.fn().mockRejectedValue(new Error("Invalid JSON")),
|
||||
text: vi.fn().mockResolvedValue("Not Found"),
|
||||
})
|
||||
|
||||
try {
|
||||
await shareService.shareTask("task-123", "organization")
|
||||
expect.fail("Expected TaskNotFoundError to be thrown")
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TaskNotFoundError)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as TaskNotFoundError).message).toBe("Task not found")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("canShareTask", () => {
|
||||
it("should return true when authenticated and sharing is enabled", async () => {
|
||||
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
|
||||
;(mockSettingsService.getSettings as any).mockReturnValue({
|
||||
cloudSettings: {
|
||||
enableTaskSharing: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when authenticated but sharing is disabled", async () => {
|
||||
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
|
||||
;(mockSettingsService.getSettings as any).mockReturnValue({
|
||||
cloudSettings: {
|
||||
enableTaskSharing: false,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when authenticated and sharing setting is undefined (default)", async () => {
|
||||
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
|
||||
;(mockSettingsService.getSettings as any).mockReturnValue({
|
||||
cloudSettings: {},
|
||||
})
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when authenticated and no settings available (default)", async () => {
|
||||
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
|
||||
;(mockSettingsService.getSettings as any).mockReturnValue(undefined)
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when settings service returns undefined", async () => {
|
||||
;(mockSettingsService.getSettings as any).mockReturnValue(undefined)
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
;(mockSettingsService.getSettings as any).mockImplementation(() => {
|
||||
throw new Error("Settings error")
|
||||
})
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[ShareService] Error checking if task can be shared:",
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
210
packages/cloud/src/__tests__/RefreshTimer.test.ts
Normal file
210
packages/cloud/src/__tests__/RefreshTimer.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
// npx vitest run src/__tests__/RefreshTimer.test.ts
|
||||
|
||||
import type { Mock } from "vitest"
|
||||
|
||||
import { RefreshTimer } from "../RefreshTimer.js"
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
describe("RefreshTimer", () => {
|
||||
let mockCallback: Mock
|
||||
let refreshTimer: RefreshTimer
|
||||
|
||||
beforeEach(() => {
|
||||
mockCallback = vi.fn()
|
||||
mockCallback.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (refreshTimer) {
|
||||
refreshTimer.stop()
|
||||
}
|
||||
|
||||
vi.clearAllTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should execute callback immediately when started", () => {
|
||||
refreshTimer = new RefreshTimer({
|
||||
callback: mockCallback,
|
||||
})
|
||||
|
||||
refreshTimer.start()
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should schedule next attempt after success interval when callback succeeds", async () => {
|
||||
mockCallback.mockResolvedValue(true)
|
||||
|
||||
refreshTimer = new RefreshTimer({
|
||||
callback: mockCallback,
|
||||
successInterval: 50000, // 50 seconds
|
||||
})
|
||||
|
||||
refreshTimer.start()
|
||||
|
||||
// Fast-forward to execute the first callback
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Fast-forward 50 seconds
|
||||
vi.advanceTimersByTime(50000)
|
||||
|
||||
// Callback should be called again
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("should use exponential backoff when callback fails", async () => {
|
||||
mockCallback.mockResolvedValue(false)
|
||||
|
||||
refreshTimer = new RefreshTimer({
|
||||
callback: mockCallback,
|
||||
initialBackoffMs: 1000, // 1 second
|
||||
})
|
||||
|
||||
refreshTimer.start()
|
||||
|
||||
// Fast-forward to execute the first callback
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Fast-forward 1 second
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
// Callback should be called again
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Fast-forward to execute the second callback
|
||||
await Promise.resolve()
|
||||
|
||||
// Fast-forward 2 seconds
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
// Callback should be called again
|
||||
expect(mockCallback).toHaveBeenCalledTimes(3)
|
||||
|
||||
// Fast-forward to execute the third callback
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it("should not exceed maximum backoff interval", async () => {
|
||||
mockCallback.mockResolvedValue(false)
|
||||
|
||||
refreshTimer = new RefreshTimer({
|
||||
callback: mockCallback,
|
||||
initialBackoffMs: 1000, // 1 second
|
||||
maxBackoffMs: 5000, // 5 seconds
|
||||
})
|
||||
|
||||
refreshTimer.start()
|
||||
|
||||
// Fast-forward through multiple failures to reach max backoff
|
||||
await Promise.resolve() // First attempt
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
await Promise.resolve() // Second attempt (backoff = 2000ms)
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
await Promise.resolve() // Third attempt (backoff = 4000ms)
|
||||
vi.advanceTimersByTime(4000)
|
||||
|
||||
await Promise.resolve() // Fourth attempt (backoff would be 8000ms but max is 5000ms)
|
||||
|
||||
// Should be capped at maxBackoffMs (no way to verify without logger)
|
||||
})
|
||||
|
||||
it("should reset backoff after a successful attempt", async () => {
|
||||
// First call fails, second succeeds, third fails
|
||||
mockCallback.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
||||
|
||||
refreshTimer = new RefreshTimer({
|
||||
callback: mockCallback,
|
||||
initialBackoffMs: 1000,
|
||||
successInterval: 5000,
|
||||
})
|
||||
|
||||
refreshTimer.start()
|
||||
|
||||
// First attempt (fails)
|
||||
await Promise.resolve()
|
||||
|
||||
// Fast-forward 1 second
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
// Second attempt (succeeds)
|
||||
await Promise.resolve()
|
||||
|
||||
// Fast-forward 5 seconds
|
||||
vi.advanceTimersByTime(5000)
|
||||
|
||||
// Third attempt (fails)
|
||||
await Promise.resolve()
|
||||
|
||||
// Backoff should be reset to initial value (no way to verify without logger)
|
||||
})
|
||||
|
||||
it("should handle errors in callback as failures", async () => {
|
||||
mockCallback.mockRejectedValue(new Error("Test error"))
|
||||
|
||||
refreshTimer = new RefreshTimer({
|
||||
callback: mockCallback,
|
||||
initialBackoffMs: 1000,
|
||||
})
|
||||
|
||||
refreshTimer.start()
|
||||
|
||||
// Fast-forward to execute the callback
|
||||
await Promise.resolve()
|
||||
|
||||
// Error should be treated as a failure (no way to verify without logger)
|
||||
})
|
||||
|
||||
it("should stop the timer and cancel pending executions", () => {
|
||||
refreshTimer = new RefreshTimer({
|
||||
callback: mockCallback,
|
||||
})
|
||||
|
||||
refreshTimer.start()
|
||||
|
||||
// Stop the timer
|
||||
refreshTimer.stop()
|
||||
|
||||
// Fast-forward a long time
|
||||
vi.advanceTimersByTime(1000000)
|
||||
|
||||
// Callback should only have been called once (the initial call)
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should reset the backoff state", async () => {
|
||||
mockCallback.mockResolvedValue(false)
|
||||
|
||||
refreshTimer = new RefreshTimer({
|
||||
callback: mockCallback,
|
||||
initialBackoffMs: 1000,
|
||||
})
|
||||
|
||||
refreshTimer.start()
|
||||
|
||||
// Fast-forward through a few failures
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
// Reset the timer
|
||||
refreshTimer.reset()
|
||||
|
||||
// Stop and restart to trigger a new execution
|
||||
refreshTimer.stop()
|
||||
refreshTimer.start()
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
// Backoff should be back to initial value (no way to verify without logger)
|
||||
})
|
||||
})
|
||||
102
packages/cloud/src/__tests__/StaticSettingsService.test.ts
Normal file
102
packages/cloud/src/__tests__/StaticSettingsService.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// npx vitest run src/__tests__/StaticSettingsService.test.ts
|
||||
|
||||
import { StaticSettingsService } from "../StaticSettingsService.js"
|
||||
|
||||
describe("StaticSettingsService", () => {
|
||||
const validSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
enableTaskSharing: true,
|
||||
taskShareExpirationDays: 30,
|
||||
},
|
||||
defaultSettings: {
|
||||
enableCheckpoints: true,
|
||||
maxOpenTabsContext: 10,
|
||||
},
|
||||
allowList: {
|
||||
allowAll: false,
|
||||
providers: {
|
||||
anthropic: {
|
||||
allowAll: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const validBase64 = Buffer.from(JSON.stringify(validSettings)).toString("base64")
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should parse valid base64 encoded JSON settings", () => {
|
||||
const service = new StaticSettingsService(validBase64)
|
||||
expect(service.getSettings()).toEqual(validSettings)
|
||||
})
|
||||
|
||||
it("should throw error for invalid base64", () => {
|
||||
expect(() => new StaticSettingsService("invalid-base64!@#")).toThrow("Failed to parse static settings")
|
||||
})
|
||||
|
||||
it("should throw error for invalid JSON", () => {
|
||||
const invalidJson = Buffer.from("{ invalid json }").toString("base64")
|
||||
expect(() => new StaticSettingsService(invalidJson)).toThrow("Failed to parse static settings")
|
||||
})
|
||||
|
||||
it("should throw error for invalid schema", () => {
|
||||
const invalidSettings = { invalid: "schema" }
|
||||
const invalidBase64 = Buffer.from(JSON.stringify(invalidSettings)).toString("base64")
|
||||
expect(() => new StaticSettingsService(invalidBase64)).toThrow("Failed to parse static settings")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllowList", () => {
|
||||
it("should return the allow list from settings", () => {
|
||||
const service = new StaticSettingsService(validBase64)
|
||||
expect(service.getAllowList()).toEqual(validSettings.allowList)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSettings", () => {
|
||||
it("should return the parsed settings", () => {
|
||||
const service = new StaticSettingsService(validBase64)
|
||||
expect(service.getSettings()).toEqual(validSettings)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should be a no-op for static settings", () => {
|
||||
const service = new StaticSettingsService(validBase64)
|
||||
expect(() => service.dispose()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("logging", () => {
|
||||
it("should use provided logger for errors", () => {
|
||||
const mockLog = vi.fn()
|
||||
expect(() => new StaticSettingsService("invalid-base64!@#", mockLog)).toThrow()
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[StaticSettingsService] failed to parse static settings:"),
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use console.log as default logger for errors", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
expect(() => new StaticSettingsService("invalid-base64!@#")).toThrow()
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[StaticSettingsService] failed to parse static settings:"),
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("should not log anything for successful parsing", () => {
|
||||
const mockLog = vi.fn()
|
||||
new StaticSettingsService(validBase64, mockLog)
|
||||
|
||||
expect(mockLog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
314
packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts
Normal file
314
packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import type { ExtensionContext } from "vscode"
|
||||
|
||||
import { StaticTokenAuthService } from "../StaticTokenAuthService.js"
|
||||
|
||||
vi.mock("vscode", () => ({
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
},
|
||||
env: {
|
||||
openExternal: vi.fn(),
|
||||
uriScheme: "vscode",
|
||||
},
|
||||
Uri: {
|
||||
parse: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("StaticTokenAuthService", () => {
|
||||
let authService: StaticTokenAuthService
|
||||
let mockContext: ExtensionContext
|
||||
let mockLog: (...args: unknown[]) => void
|
||||
const testToken = "test-static-token"
|
||||
|
||||
// Job token (t:'cj') with userId and orgId - sub is CloudJob ID
|
||||
const jobTokenWithOrg =
|
||||
"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJyY2MiLCJzdWIiOiIzIiwiZXhwIjoxNzU2Mjc5NzU0LCJpYXQiOjE3NTYyNzU4NTQsIm5iZiI6MTc1NjI3NTgyNCwidiI6MSwiciI6eyJ1IjoidXNlcl8yeG1CaGVqTmVEVHdhbk04Q2dJT25NZ1Z4ekMiLCJvIjoib3JnXzEyM2FiYyIsInQiOiJjaiJ9fQ.k6VgV0cZUbx75kdedaeAsVYSRT7PzxDOCseLowq6moX92B4QuqtNkPRLKtQX7pJCxjuqRwEjJxmfTeXtQ82Pyg"
|
||||
|
||||
// Job token without orgId (orgId was null during creation)
|
||||
const jobTokenNoOrg =
|
||||
"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJyY2MiLCJzdWIiOiI1IiwiZXhwIjoxNzU2Mjc5NzU0LCJpYXQiOjE3NTYyNzU4NTQsIm5iZiI6MTc1NjI3NTgyNCwidiI6MSwiciI6eyJ1IjoidXNlcl8yeG1CaGVqTmVEVHdhbk04Q2dJT25NZ1Z4ekMiLCJ0IjoiY2oifX0.signature"
|
||||
|
||||
// Auth token (t:'auth') - sub is User ID
|
||||
const authToken =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJyY2MiLCJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTc1NjI3OTc1NCwiaWF0IjoxNzU2Mjc1ODU0LCJuYmYiOjE3NTYyNzU4MjQsInYiOjEsInIiOnsidSI6InVzZXJfMTIzIiwidCI6ImF1dGgifX0.signature"
|
||||
|
||||
// JWT without the 'r' field (legacy format)
|
||||
const legacyJWT =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
|
||||
beforeEach(() => {
|
||||
mockLog = vi.fn()
|
||||
|
||||
// Create a minimal mock that satisfies the constructor requirements
|
||||
const mockContextPartial = {
|
||||
extension: {
|
||||
packageJSON: {
|
||||
publisher: "TestPublisher",
|
||||
name: "test-extension",
|
||||
},
|
||||
},
|
||||
globalState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
secrets: {
|
||||
get: vi.fn(),
|
||||
store: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
onDidChange: vi.fn(),
|
||||
},
|
||||
subscriptions: [],
|
||||
}
|
||||
|
||||
// Use type assertion for test mocking
|
||||
mockContext = mockContextPartial as unknown as ExtensionContext
|
||||
|
||||
authService = new StaticTokenAuthService(mockContext, testToken, mockLog)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create instance and log static token mode", () => {
|
||||
expect(authService).toBeInstanceOf(StaticTokenAuthService)
|
||||
expect(mockLog).toHaveBeenCalledWith("[auth] Using StaticTokenAuthService")
|
||||
})
|
||||
|
||||
it("should use console.log as default logger", () => {
|
||||
const serviceWithoutLog = new StaticTokenAuthService(mockContext as unknown as ExtensionContext, testToken)
|
||||
// Can't directly test console.log usage, but constructor should not throw
|
||||
expect(serviceWithoutLog).toBeInstanceOf(StaticTokenAuthService)
|
||||
})
|
||||
|
||||
it("should parse job token with orgId and extract userId from r.u", () => {
|
||||
const serviceWithJWT = new StaticTokenAuthService(mockContext, jobTokenWithOrg, mockLog)
|
||||
|
||||
const userInfo = serviceWithJWT.getUserInfo()
|
||||
expect(userInfo?.id).toBe("user_2xmBhejNeDTwanM8CgIOnMgVxzC")
|
||||
expect(userInfo?.organizationId).toBe("org_123abc")
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse job token without orgId (null orgId case)", () => {
|
||||
const serviceWithJWT = new StaticTokenAuthService(mockContext, jobTokenNoOrg, mockLog)
|
||||
|
||||
const userInfo = serviceWithJWT.getUserInfo()
|
||||
expect(userInfo?.id).toBe("user_2xmBhejNeDTwanM8CgIOnMgVxzC")
|
||||
expect(userInfo?.organizationId).toBeUndefined()
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse auth token and extract userId from r.u", () => {
|
||||
const serviceWithAuthToken = new StaticTokenAuthService(mockContext, authToken, mockLog)
|
||||
|
||||
const userInfo = serviceWithAuthToken.getUserInfo()
|
||||
expect(userInfo?.id).toBe("user_123")
|
||||
expect(userInfo?.organizationId).toBeUndefined()
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle legacy JWT format with sub field", () => {
|
||||
const serviceWithLegacyJWT = new StaticTokenAuthService(mockContext, legacyJWT, mockLog)
|
||||
|
||||
const userInfo = serviceWithLegacyJWT.getUserInfo()
|
||||
expect(userInfo?.id).toBe("user_123")
|
||||
expect(userInfo?.organizationId).toBeUndefined()
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle invalid JWT gracefully", () => {
|
||||
const serviceWithInvalidJWT = new StaticTokenAuthService(mockContext, "invalid-jwt-token", mockLog)
|
||||
|
||||
const userInfo = serviceWithInvalidJWT.getUserInfo()
|
||||
expect(userInfo?.id).toBeUndefined()
|
||||
expect(userInfo?.organizationId).toBeUndefined()
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse JWT:", expect.any(Error))
|
||||
})
|
||||
|
||||
it("should handle malformed JWT payload", () => {
|
||||
// JWT with invalid base64 in payload
|
||||
const malformedJWT = "header.!!!invalid-base64!!!.signature"
|
||||
const serviceWithMalformedJWT = new StaticTokenAuthService(mockContext, malformedJWT, mockLog)
|
||||
|
||||
const userInfo = serviceWithMalformedJWT.getUserInfo()
|
||||
expect(userInfo?.id).toBeUndefined()
|
||||
expect(userInfo?.organizationId).toBeUndefined()
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse JWT:", expect.any(Error))
|
||||
})
|
||||
})
|
||||
|
||||
describe("initialize", () => {
|
||||
it("should start in active-session state", async () => {
|
||||
await authService.initialize()
|
||||
expect(authService.getState()).toBe("active-session")
|
||||
})
|
||||
|
||||
it("should not emit events on initialize", async () => {
|
||||
const authStateChangedSpy = vi.fn()
|
||||
const userInfoSpy = vi.fn()
|
||||
|
||||
authService.on("auth-state-changed", authStateChangedSpy)
|
||||
authService.on("user-info", userInfoSpy)
|
||||
|
||||
await authService.initialize()
|
||||
|
||||
expect(authStateChangedSpy).not.toHaveBeenCalled()
|
||||
expect(userInfoSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("broadcast", () => {
|
||||
it("should emit auth-state-changed event", () => {
|
||||
const spy = vi.fn()
|
||||
authService.on("auth-state-changed", spy)
|
||||
|
||||
authService.broadcast()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
state: "active-session",
|
||||
previousState: "initializing",
|
||||
})
|
||||
})
|
||||
|
||||
it("should emit user-info event", () => {
|
||||
const spy = vi.fn()
|
||||
authService.on("user-info", spy)
|
||||
|
||||
authService.broadcast()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
userInfo: expect.objectContaining({
|
||||
extensionBridgeEnabled: true,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it("should emit user-info with parsed JWT data", () => {
|
||||
const serviceWithJWT = new StaticTokenAuthService(mockContext, jobTokenWithOrg, mockLog)
|
||||
|
||||
const spy = vi.fn()
|
||||
serviceWithJWT.on("user-info", spy)
|
||||
|
||||
serviceWithJWT.broadcast()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
userInfo: {
|
||||
extensionBridgeEnabled: true,
|
||||
id: "user_2xmBhejNeDTwanM8CgIOnMgVxzC",
|
||||
organizationId: "org_123abc",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSessionToken", () => {
|
||||
it("should return the provided token", () => {
|
||||
expect(authService.getSessionToken()).toBe(testToken)
|
||||
})
|
||||
|
||||
it("should return different token when constructed with different token", () => {
|
||||
const differentToken = "different-token"
|
||||
const differentService = new StaticTokenAuthService(mockContext, differentToken, mockLog)
|
||||
expect(differentService.getSessionToken()).toBe(differentToken)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getUserInfo", () => {
|
||||
it("should return object with extensionBridgeEnabled flag", () => {
|
||||
const userInfo = authService.getUserInfo()
|
||||
expect(userInfo).toHaveProperty("extensionBridgeEnabled")
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getStoredOrganizationId", () => {
|
||||
it("should return null for non-JWT token", () => {
|
||||
expect(authService.getStoredOrganizationId()).toBeNull()
|
||||
})
|
||||
|
||||
it("should return organizationId from parsed JWT", () => {
|
||||
const serviceWithJWT = new StaticTokenAuthService(mockContext, jobTokenWithOrg, mockLog)
|
||||
|
||||
expect(serviceWithJWT.getStoredOrganizationId()).toBe("org_123abc")
|
||||
})
|
||||
|
||||
it("should return null when JWT has no organizationId", () => {
|
||||
const serviceWithNoOrg = new StaticTokenAuthService(mockContext, jobTokenNoOrg, mockLog)
|
||||
|
||||
expect(serviceWithNoOrg.getStoredOrganizationId()).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for legacy JWT format", () => {
|
||||
const serviceWithLegacyJWT = new StaticTokenAuthService(mockContext, legacyJWT, mockLog)
|
||||
|
||||
expect(serviceWithLegacyJWT.getStoredOrganizationId()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("authentication state methods", () => {
|
||||
it("should always return true for isAuthenticated", () => {
|
||||
expect(authService.isAuthenticated()).toBe(true)
|
||||
})
|
||||
|
||||
it("should always return true for hasActiveSession", () => {
|
||||
expect(authService.hasActiveSession()).toBe(true)
|
||||
})
|
||||
|
||||
it("should always return true for hasOrIsAcquiringActiveSession", () => {
|
||||
expect(authService.hasOrIsAcquiringActiveSession()).toBe(true)
|
||||
})
|
||||
|
||||
it("should return active-session for getState", () => {
|
||||
expect(authService.getState()).toBe("active-session")
|
||||
})
|
||||
})
|
||||
|
||||
describe("disabled authentication methods", () => {
|
||||
const expectedErrorMessage = "Authentication methods are disabled in StaticTokenAuthService"
|
||||
|
||||
it("should throw error for login", async () => {
|
||||
await expect(authService.login()).rejects.toThrow(expectedErrorMessage)
|
||||
})
|
||||
|
||||
it("should throw error for logout", async () => {
|
||||
await expect(authService.logout()).rejects.toThrow(expectedErrorMessage)
|
||||
})
|
||||
|
||||
it("should throw error for handleCallback", async () => {
|
||||
await expect(authService.handleCallback("code", "state")).rejects.toThrow(expectedErrorMessage)
|
||||
})
|
||||
|
||||
it("should throw error for handleCallback with organization", async () => {
|
||||
await expect(authService.handleCallback("code", "state", "org_123")).rejects.toThrow(expectedErrorMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("event emission", () => {
|
||||
it("should be able to register and emit events via broadcast", () => {
|
||||
const authStateChangedSpy = vi.fn()
|
||||
const userInfoSpy = vi.fn()
|
||||
|
||||
authService.on("auth-state-changed", authStateChangedSpy)
|
||||
authService.on("user-info", userInfoSpy)
|
||||
|
||||
authService.broadcast()
|
||||
|
||||
expect(authStateChangedSpy).toHaveBeenCalledWith({
|
||||
state: "active-session",
|
||||
previousState: "initializing",
|
||||
})
|
||||
|
||||
expect(userInfoSpy).toHaveBeenCalledWith({
|
||||
userInfo: expect.objectContaining({
|
||||
extensionBridgeEnabled: true,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
740
packages/cloud/src/__tests__/TelemetryClient.test.ts
Normal file
740
packages/cloud/src/__tests__/TelemetryClient.test.ts
Normal file
|
|
@ -0,0 +1,740 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// npx vitest run src/__tests__/TelemetryClient.test.ts
|
||||
|
||||
import { type TelemetryPropertiesProvider, TelemetryEventName } from "@roo-code/types"
|
||||
|
||||
import { CloudTelemetryClient as TelemetryClient } from "../TelemetryClient.js"
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch as any
|
||||
|
||||
describe("TelemetryClient", () => {
|
||||
const getPrivateProperty = <T>(instance: any, propertyName: string): T => {
|
||||
return instance[propertyName]
|
||||
}
|
||||
|
||||
let mockAuthService: any
|
||||
let mockSettingsService: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a mock AuthService instead of using the singleton
|
||||
mockAuthService = {
|
||||
getSessionToken: vi.fn().mockReturnValue("mock-token"),
|
||||
getState: vi.fn().mockReturnValue("active-session"),
|
||||
isAuthenticated: vi.fn().mockReturnValue(true),
|
||||
hasActiveSession: vi.fn().mockReturnValue(true),
|
||||
}
|
||||
|
||||
// Create a mock SettingsService
|
||||
mockSettingsService = {
|
||||
getSettings: vi.fn().mockReturnValue({
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
|
||||
vi.spyOn(console, "info").mockImplementation(() => {})
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("isEventCapturable", () => {
|
||||
it("should return true for events not in exclude list", () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
"isEventCapturable",
|
||||
).bind(client)
|
||||
|
||||
expect(isEventCapturable(TelemetryEventName.TASK_CREATED)).toBe(true)
|
||||
expect(isEventCapturable(TelemetryEventName.LLM_COMPLETION)).toBe(true)
|
||||
expect(isEventCapturable(TelemetryEventName.MODE_SWITCH)).toBe(true)
|
||||
expect(isEventCapturable(TelemetryEventName.TOOL_USED)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for events in exclude list", () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
"isEventCapturable",
|
||||
).bind(client)
|
||||
|
||||
expect(isEventCapturable(TelemetryEventName.TASK_CONVERSATION_MESSAGE)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true for TASK_MESSAGE events when recordTaskMessages is true", () => {
|
||||
mockSettingsService.getSettings.mockReturnValue({
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
"isEventCapturable",
|
||||
).bind(client)
|
||||
|
||||
expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for TASK_MESSAGE events when recordTaskMessages is false", () => {
|
||||
mockSettingsService.getSettings.mockReturnValue({
|
||||
cloudSettings: {
|
||||
recordTaskMessages: false,
|
||||
},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
"isEventCapturable",
|
||||
).bind(client)
|
||||
|
||||
expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for TASK_MESSAGE events when recordTaskMessages is undefined", () => {
|
||||
mockSettingsService.getSettings.mockReturnValue({
|
||||
cloudSettings: {},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
"isEventCapturable",
|
||||
).bind(client)
|
||||
|
||||
expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for TASK_MESSAGE events when cloudSettings is undefined", () => {
|
||||
mockSettingsService.getSettings.mockReturnValue({})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
"isEventCapturable",
|
||||
).bind(client)
|
||||
|
||||
expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for TASK_MESSAGE events when getSettings returns undefined", () => {
|
||||
mockSettingsService.getSettings.mockReturnValue(undefined)
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
"isEventCapturable",
|
||||
).bind(client)
|
||||
|
||||
expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEventProperties", () => {
|
||||
it("should merge provider properties with event properties", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockResolvedValue({
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
}),
|
||||
}
|
||||
|
||||
client.setProvider(mockProvider)
|
||||
|
||||
const getEventProperties = getPrivateProperty<
|
||||
(event: { event: TelemetryEventName; properties?: Record<string, any> }) => Promise<Record<string, any>>
|
||||
>(client, "getEventProperties").bind(client)
|
||||
|
||||
const result = await getEventProperties({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: {
|
||||
customProp: "value",
|
||||
mode: "override", // This should override the provider's mode.
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "override", // Event property takes precedence.
|
||||
customProp: "value",
|
||||
})
|
||||
|
||||
expect(mockProvider.getTelemetryProperties).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should handle errors from provider gracefully", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockRejectedValue(new Error("Provider error")),
|
||||
}
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, "error")
|
||||
|
||||
client.setProvider(mockProvider)
|
||||
|
||||
const getEventProperties = getPrivateProperty<
|
||||
(event: { event: TelemetryEventName; properties?: Record<string, any> }) => Promise<Record<string, any>>
|
||||
>(client, "getEventProperties").bind(client)
|
||||
|
||||
const result = await getEventProperties({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: { customProp: "value" },
|
||||
})
|
||||
|
||||
expect(result).toEqual({ customProp: "value" })
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error getting telemetry properties: Provider error"),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return event properties when no provider is set", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const getEventProperties = getPrivateProperty<
|
||||
(event: { event: TelemetryEventName; properties?: Record<string, any> }) => Promise<Record<string, any>>
|
||||
>(client, "getEventProperties").bind(client)
|
||||
|
||||
const result = await getEventProperties({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: { customProp: "value" },
|
||||
})
|
||||
|
||||
expect(result).toEqual({ customProp: "value" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("capture", () => {
|
||||
it("should not capture events that are not capturable", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CONVERSATION_MESSAGE, // In exclude list.
|
||||
properties: { test: "value" },
|
||||
})
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not capture TASK_MESSAGE events when recordTaskMessages is false", async () => {
|
||||
mockSettingsService.getSettings.mockReturnValue({
|
||||
cloudSettings: {
|
||||
recordTaskMessages: false,
|
||||
},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_MESSAGE,
|
||||
properties: {
|
||||
taskId: "test-task-id",
|
||||
message: {
|
||||
ts: 1,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "test message",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not capture TASK_MESSAGE events when recordTaskMessages is undefined", async () => {
|
||||
mockSettingsService.getSettings.mockReturnValue({
|
||||
cloudSettings: {},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_MESSAGE,
|
||||
properties: {
|
||||
taskId: "test-task-id",
|
||||
message: {
|
||||
ts: 1,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "test message",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not send request when schema validation fails", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: { test: "value" },
|
||||
})
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Invalid telemetry event"))
|
||||
})
|
||||
|
||||
it("should send request when event is capturable and validation passes", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const providerProperties = {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
}
|
||||
|
||||
const eventProperties = {
|
||||
taskId: "test-task-id",
|
||||
}
|
||||
|
||||
const mockValidatedData = {
|
||||
type: TelemetryEventName.TASK_CREATED,
|
||||
properties: {
|
||||
...providerProperties,
|
||||
taskId: "test-task-id",
|
||||
},
|
||||
}
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockResolvedValue(providerProperties),
|
||||
}
|
||||
|
||||
client.setProvider(mockProvider)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: eventProperties,
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify(mockValidatedData),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should attempt to capture TASK_MESSAGE events when recordTaskMessages is true", async () => {
|
||||
mockSettingsService.getSettings.mockReturnValue({
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
},
|
||||
})
|
||||
|
||||
const eventProperties = {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
taskId: "test-task-id",
|
||||
message: {
|
||||
ts: 1,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "test message",
|
||||
},
|
||||
}
|
||||
|
||||
const mockValidatedData = {
|
||||
type: TelemetryEventName.TASK_MESSAGE,
|
||||
properties: eventProperties,
|
||||
}
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_MESSAGE,
|
||||
properties: eventProperties,
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify(mockValidatedData),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(
|
||||
client.capture({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: { test: "value" },
|
||||
}),
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("telemetry state methods", () => {
|
||||
it("should always return true for isTelemetryEnabled", () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
expect(client.isTelemetryEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it("should have empty implementations for updateTelemetryState and shutdown", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
client.updateTelemetryState(true)
|
||||
await client.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("backfillMessages", () => {
|
||||
it("should not send request when not authenticated", async () => {
|
||||
mockAuthService.isAuthenticated.mockReturnValue(false)
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message",
|
||||
},
|
||||
]
|
||||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not send request when no session token available", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue(null)
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message",
|
||||
},
|
||||
]
|
||||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"[TelemetryClient#backfillMessages] Unauthorized: No session token available.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should send FormData request with correct structure when authenticated", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const providerProperties = {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
}
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockResolvedValue(providerProperties),
|
||||
}
|
||||
|
||||
client.setProvider(mockProvider)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message 1",
|
||||
},
|
||||
{
|
||||
ts: 2,
|
||||
type: "ask" as const,
|
||||
ask: "followup" as const,
|
||||
text: "test question",
|
||||
},
|
||||
]
|
||||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events/backfill",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer mock-token",
|
||||
},
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify FormData contents
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const formData = call?.[1]?.body as FormData
|
||||
|
||||
expect(formData.get("taskId")).toBe("test-task-id")
|
||||
|
||||
// Parse and compare properties as objects since JSON.stringify order can vary
|
||||
const propertiesJson = formData.get("properties") as string
|
||||
const parsedProperties = JSON.parse(propertiesJson)
|
||||
expect(parsedProperties).toEqual({
|
||||
taskId: "test-task-id",
|
||||
...providerProperties,
|
||||
})
|
||||
// The messages are stored as a File object under the "file" key
|
||||
const fileField = formData.get("file") as File
|
||||
expect(fileField).toBeInstanceOf(File)
|
||||
expect(fileField.name).toBe("task.json")
|
||||
expect(fileField.type).toBe("application/json")
|
||||
|
||||
// Read the file content to verify the messages
|
||||
const fileContent = await fileField.text()
|
||||
expect(fileContent).toBe(JSON.stringify(messages))
|
||||
})
|
||||
|
||||
it("should handle provider errors gracefully", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockRejectedValue(new Error("Provider error")),
|
||||
}
|
||||
|
||||
client.setProvider(mockProvider)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message",
|
||||
},
|
||||
]
|
||||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events/backfill",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer mock-token",
|
||||
},
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify FormData contents - should still work with just taskId
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const formData = call?.[1]?.body as FormData
|
||||
|
||||
expect(formData.get("taskId")).toBe("test-task-id")
|
||||
expect(formData.get("properties")).toBe(
|
||||
JSON.stringify({
|
||||
taskId: "test-task-id",
|
||||
}),
|
||||
)
|
||||
|
||||
// The messages are stored as a File object under the "file" key
|
||||
const fileField = formData.get("file") as File
|
||||
expect(fileField).toBeInstanceOf(File)
|
||||
expect(fileField.name).toBe("task.json")
|
||||
expect(fileField.type).toBe("application/json")
|
||||
|
||||
// Read the file content to verify the messages
|
||||
const fileContent = await fileField.text()
|
||||
expect(fileContent).toBe(JSON.stringify(messages))
|
||||
})
|
||||
|
||||
it("should work without provider set", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message",
|
||||
},
|
||||
]
|
||||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events/backfill",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer mock-token",
|
||||
},
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify FormData contents - should work with just taskId
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const formData = call?.[1]?.body as FormData
|
||||
|
||||
expect(formData.get("taskId")).toBe("test-task-id")
|
||||
expect(formData.get("properties")).toBe(
|
||||
JSON.stringify({
|
||||
taskId: "test-task-id",
|
||||
}),
|
||||
)
|
||||
|
||||
// The messages are stored as a File object under the "file" key
|
||||
const fileField = formData.get("file") as File
|
||||
expect(fileField).toBeInstanceOf(File)
|
||||
expect(fileField.name).toBe("task.json")
|
||||
expect(fileField.type).toBe("application/json")
|
||||
|
||||
// Read the file content to verify the messages
|
||||
const fileContent = await fileField.text()
|
||||
expect(fileContent).toBe(JSON.stringify(messages))
|
||||
})
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message",
|
||||
},
|
||||
]
|
||||
|
||||
await expect(client.backfillMessages(messages, "test-task-id")).resolves.not.toThrow()
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"[TelemetryClient#backfillMessages] Error uploading messages: Error: Network error",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle HTTP error responses", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
})
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message",
|
||||
},
|
||||
]
|
||||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"[TelemetryClient#backfillMessages] POST events/backfill -> 404 Not Found",
|
||||
)
|
||||
})
|
||||
|
||||
it("should log debug information when debug is enabled", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService, true)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message",
|
||||
},
|
||||
]
|
||||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(console.info).toHaveBeenCalledWith(
|
||||
"[TelemetryClient#backfillMessages] Uploading 1 messages for task test-task-id",
|
||||
)
|
||||
expect(console.info).toHaveBeenCalledWith(
|
||||
"[TelemetryClient#backfillMessages] Successfully uploaded messages for task test-task-id",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle empty messages array", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.backfillMessages([], "test-task-id")
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events/backfill",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer mock-token",
|
||||
},
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify FormData contents
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const formData = call?.[1]?.body as FormData
|
||||
|
||||
// The messages are stored as a File object under the "file" key
|
||||
const fileField = formData.get("file") as File
|
||||
expect(fileField).toBeInstanceOf(File)
|
||||
expect(fileField.name).toBe("task.json")
|
||||
expect(fileField.type).toBe("application/json")
|
||||
|
||||
// Read the file content to verify the empty messages array
|
||||
const fileContent = await fileField.text()
|
||||
expect(fileContent).toBe("[]")
|
||||
})
|
||||
})
|
||||
})
|
||||
1196
packages/cloud/src/__tests__/WebAuthService.spec.ts
Normal file
1196
packages/cloud/src/__tests__/WebAuthService.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
290
packages/cloud/src/bridge/ExtensionBridgeService.ts
Normal file
290
packages/cloud/src/bridge/ExtensionBridgeService.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import crypto from "crypto"
|
||||
|
||||
import {
|
||||
type TaskProviderLike,
|
||||
type TaskLike,
|
||||
type CloudUserInfo,
|
||||
type ExtensionBridgeCommand,
|
||||
type TaskBridgeCommand,
|
||||
ConnectionState,
|
||||
ExtensionSocketEvents,
|
||||
TaskSocketEvents,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { SocketConnectionManager } from "./SocketConnectionManager.js"
|
||||
import { ExtensionManager } from "./ExtensionManager.js"
|
||||
import { TaskManager } from "./TaskManager.js"
|
||||
|
||||
export interface ExtensionBridgeServiceOptions {
|
||||
userId: string
|
||||
socketBridgeUrl: string
|
||||
token: string
|
||||
provider: TaskProviderLike
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export class ExtensionBridgeService {
|
||||
private static instance: ExtensionBridgeService | null = null
|
||||
|
||||
// Core
|
||||
private readonly userId: string
|
||||
private readonly socketBridgeUrl: string
|
||||
private readonly token: string
|
||||
private readonly provider: TaskProviderLike
|
||||
private readonly instanceId: string
|
||||
|
||||
// Managers
|
||||
private connectionManager: SocketConnectionManager
|
||||
private extensionManager: ExtensionManager
|
||||
private taskManager: TaskManager
|
||||
|
||||
// Reconnection
|
||||
private readonly MAX_RECONNECT_ATTEMPTS = Infinity
|
||||
private readonly RECONNECT_DELAY = 1_000
|
||||
private readonly RECONNECT_DELAY_MAX = 30_000
|
||||
|
||||
public static getInstance(): ExtensionBridgeService | null {
|
||||
return ExtensionBridgeService.instance
|
||||
}
|
||||
|
||||
public static async createInstance(options: ExtensionBridgeServiceOptions) {
|
||||
console.log("[ExtensionBridgeService] createInstance")
|
||||
ExtensionBridgeService.instance = new ExtensionBridgeService(options)
|
||||
await ExtensionBridgeService.instance.initialize()
|
||||
return ExtensionBridgeService.instance
|
||||
}
|
||||
|
||||
public static resetInstance() {
|
||||
if (ExtensionBridgeService.instance) {
|
||||
console.log("[ExtensionBridgeService] resetInstance")
|
||||
ExtensionBridgeService.instance.disconnect().catch(() => {})
|
||||
ExtensionBridgeService.instance = null
|
||||
}
|
||||
}
|
||||
|
||||
public static async handleRemoteControlState(
|
||||
userInfo: CloudUserInfo | null,
|
||||
remoteControlEnabled: boolean | undefined,
|
||||
options: ExtensionBridgeServiceOptions,
|
||||
logger?: (message: string) => void,
|
||||
) {
|
||||
if (userInfo?.extensionBridgeEnabled && remoteControlEnabled) {
|
||||
const existingService = ExtensionBridgeService.getInstance()
|
||||
|
||||
if (!existingService) {
|
||||
try {
|
||||
const service = await ExtensionBridgeService.createInstance(options)
|
||||
const state = service.getConnectionState()
|
||||
|
||||
logger?.(`[ExtensionBridgeService#handleRemoteControlState] Instance created (state: ${state})`)
|
||||
|
||||
if (state !== ConnectionState.CONNECTED) {
|
||||
logger?.(
|
||||
`[ExtensionBridgeService#handleRemoteControlState] Service is not connected yet, will retry in background`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = `[ExtensionBridgeService#handleRemoteControlState] Failed to create instance: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
|
||||
logger?.(message)
|
||||
console.error(message)
|
||||
}
|
||||
} else {
|
||||
const state = existingService.getConnectionState()
|
||||
|
||||
if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) {
|
||||
logger?.(
|
||||
`[ExtensionBridgeService#handleRemoteControlState] Existing service is ${state}, attempting reconnection`,
|
||||
)
|
||||
|
||||
existingService.reconnect().catch((error) => {
|
||||
const message = `[ExtensionBridgeService#handleRemoteControlState] Reconnection failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
|
||||
logger?.(message)
|
||||
console.error(message)
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const existingService = ExtensionBridgeService.getInstance()
|
||||
|
||||
if (existingService) {
|
||||
try {
|
||||
await existingService.disconnect()
|
||||
ExtensionBridgeService.resetInstance()
|
||||
|
||||
logger?.(`[ExtensionBridgeService#handleRemoteControlState] Service disconnected and reset`)
|
||||
} catch (error) {
|
||||
const message = `[ExtensionBridgeService#handleRemoteControlState] Failed to disconnect and reset instance: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
|
||||
logger?.(message)
|
||||
console.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private constructor(options: ExtensionBridgeServiceOptions) {
|
||||
this.userId = options.userId
|
||||
this.socketBridgeUrl = options.socketBridgeUrl
|
||||
this.token = options.token
|
||||
this.provider = options.provider
|
||||
this.instanceId = options.sessionId || crypto.randomUUID()
|
||||
|
||||
this.connectionManager = new SocketConnectionManager({
|
||||
url: this.socketBridgeUrl,
|
||||
socketOptions: {
|
||||
query: {
|
||||
token: this.token,
|
||||
clientType: "extension",
|
||||
instanceId: this.instanceId,
|
||||
},
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.MAX_RECONNECT_ATTEMPTS,
|
||||
reconnectionDelay: this.RECONNECT_DELAY,
|
||||
reconnectionDelayMax: this.RECONNECT_DELAY_MAX,
|
||||
},
|
||||
onConnect: () => this.handleConnect(),
|
||||
onDisconnect: () => this.handleDisconnect(),
|
||||
onReconnect: () => this.handleReconnect(),
|
||||
})
|
||||
|
||||
this.extensionManager = new ExtensionManager(this.instanceId, this.userId, this.provider)
|
||||
|
||||
this.taskManager = new TaskManager()
|
||||
}
|
||||
|
||||
private async initialize() {
|
||||
// Populate the app and git properties before registering the instance.
|
||||
await this.provider.getTelemetryProperties()
|
||||
|
||||
await this.connectionManager.connect()
|
||||
this.setupSocketListeners()
|
||||
}
|
||||
|
||||
private setupSocketListeners() {
|
||||
const socket = this.connectionManager.getSocket()
|
||||
|
||||
if (!socket) {
|
||||
console.error("[ExtensionBridgeService] Socket not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove any existing listeners first to prevent duplicates.
|
||||
socket.off(ExtensionSocketEvents.RELAYED_COMMAND)
|
||||
socket.off(TaskSocketEvents.RELAYED_COMMAND)
|
||||
socket.off("connected")
|
||||
|
||||
socket.on(ExtensionSocketEvents.RELAYED_COMMAND, (message: ExtensionBridgeCommand) => {
|
||||
console.log(
|
||||
`[ExtensionBridgeService] on(${ExtensionSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.instanceId}`,
|
||||
)
|
||||
|
||||
this.extensionManager?.handleExtensionCommand(message)
|
||||
})
|
||||
|
||||
socket.on(TaskSocketEvents.RELAYED_COMMAND, (message: TaskBridgeCommand) => {
|
||||
console.log(
|
||||
`[ExtensionBridgeService] on(${TaskSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.taskId}`,
|
||||
)
|
||||
|
||||
this.taskManager.handleTaskCommand(message)
|
||||
})
|
||||
}
|
||||
|
||||
private async handleConnect() {
|
||||
const socket = this.connectionManager.getSocket()
|
||||
|
||||
if (!socket) {
|
||||
console.error("[ExtensionBridgeService] Socket not available after connect")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await this.extensionManager.onConnect(socket)
|
||||
await this.taskManager.onConnect(socket)
|
||||
}
|
||||
|
||||
private handleDisconnect() {
|
||||
this.extensionManager.onDisconnect()
|
||||
this.taskManager.onDisconnect()
|
||||
}
|
||||
|
||||
private async handleReconnect() {
|
||||
const socket = this.connectionManager.getSocket()
|
||||
|
||||
if (!socket) {
|
||||
console.error("[ExtensionBridgeService] Socket not available after reconnect")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Re-setup socket listeners to ensure they're properly configured
|
||||
// after automatic reconnection (Socket.IO's built-in reconnection)
|
||||
// The socket.off() calls in setupSocketListeners prevent duplicates
|
||||
this.setupSocketListeners()
|
||||
|
||||
await this.extensionManager.onReconnect(socket)
|
||||
await this.taskManager.onReconnect(socket)
|
||||
}
|
||||
|
||||
// Task API
|
||||
|
||||
public async subscribeToTask(task: TaskLike): Promise<void> {
|
||||
const socket = this.connectionManager.getSocket()
|
||||
|
||||
if (!socket || !this.connectionManager.isConnected()) {
|
||||
console.warn("[ExtensionBridgeService] Cannot subscribe to task: not connected. Will retry when connected.")
|
||||
|
||||
this.taskManager.addPendingTask(task)
|
||||
|
||||
const state = this.connectionManager.getConnectionState()
|
||||
|
||||
if (state === ConnectionState.DISCONNECTED || state === ConnectionState.FAILED) {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await this.taskManager.subscribeToTask(task, socket)
|
||||
}
|
||||
|
||||
public async unsubscribeFromTask(taskId: string): Promise<void> {
|
||||
const socket = this.connectionManager.getSocket()
|
||||
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.taskManager.unsubscribeFromTask(taskId, socket)
|
||||
}
|
||||
|
||||
// Shared API
|
||||
|
||||
public getConnectionState(): ConnectionState {
|
||||
return this.connectionManager.getConnectionState()
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
await this.extensionManager.cleanup(this.connectionManager.getSocket())
|
||||
await this.taskManager.cleanup(this.connectionManager.getSocket())
|
||||
await this.connectionManager.disconnect()
|
||||
ExtensionBridgeService.instance = null
|
||||
}
|
||||
|
||||
public async reconnect(): Promise<void> {
|
||||
await this.connectionManager.reconnect()
|
||||
|
||||
// After a manual reconnect, we have a new socket instance
|
||||
// so we need to set up listeners again.
|
||||
this.setupSocketListeners()
|
||||
}
|
||||
}
|
||||
297
packages/cloud/src/bridge/ExtensionManager.ts
Normal file
297
packages/cloud/src/bridge/ExtensionManager.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import type { Socket } from "socket.io-client"
|
||||
|
||||
import {
|
||||
type TaskProviderLike,
|
||||
type ExtensionInstance,
|
||||
type ExtensionBridgeCommand,
|
||||
type ExtensionBridgeEvent,
|
||||
RooCodeEventName,
|
||||
TaskStatus,
|
||||
ExtensionBridgeCommandName,
|
||||
ExtensionBridgeEventName,
|
||||
ExtensionSocketEvents,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
} from "@roo-code/types"
|
||||
|
||||
export class ExtensionManager {
|
||||
private instanceId: string
|
||||
private userId: string
|
||||
private provider: TaskProviderLike
|
||||
private extensionInstance: ExtensionInstance
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null
|
||||
private socket: Socket | null = null
|
||||
|
||||
constructor(instanceId: string, userId: string, provider: TaskProviderLike) {
|
||||
this.instanceId = instanceId
|
||||
this.userId = userId
|
||||
this.provider = provider
|
||||
|
||||
this.extensionInstance = {
|
||||
instanceId: this.instanceId,
|
||||
userId: this.userId,
|
||||
workspacePath: this.provider.cwd,
|
||||
appProperties: this.provider.appProperties,
|
||||
gitProperties: this.provider.gitProperties,
|
||||
lastHeartbeat: Date.now(),
|
||||
task: {
|
||||
taskId: "",
|
||||
taskStatus: TaskStatus.None,
|
||||
},
|
||||
taskHistory: [],
|
||||
}
|
||||
|
||||
this.setupListeners()
|
||||
}
|
||||
|
||||
public async onConnect(socket: Socket): Promise<void> {
|
||||
this.socket = socket
|
||||
await this.registerInstance(socket)
|
||||
this.startHeartbeat(socket)
|
||||
}
|
||||
|
||||
public onDisconnect(): void {
|
||||
this.stopHeartbeat()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
public async onReconnect(socket: Socket): Promise<void> {
|
||||
this.socket = socket
|
||||
await this.registerInstance(socket)
|
||||
this.startHeartbeat(socket)
|
||||
}
|
||||
|
||||
public async cleanup(socket: Socket | null): Promise<void> {
|
||||
this.stopHeartbeat()
|
||||
|
||||
if (socket) {
|
||||
await this.unregisterInstance(socket)
|
||||
}
|
||||
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
public handleExtensionCommand(message: ExtensionBridgeCommand): void {
|
||||
if (message.instanceId !== this.instanceId) {
|
||||
console.log(`[ExtensionManager] command -> instance id mismatch | ${this.instanceId}`, {
|
||||
messageInstanceId: message.instanceId,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case ExtensionBridgeCommandName.StartTask: {
|
||||
console.log(`[ExtensionManager] command -> createTask() | ${message.instanceId}`, {
|
||||
text: message.payload.text?.substring(0, 100) + "...",
|
||||
hasImages: !!message.payload.images,
|
||||
})
|
||||
|
||||
this.provider.createTask(message.payload.text, message.payload.images)
|
||||
|
||||
break
|
||||
}
|
||||
case ExtensionBridgeCommandName.StopTask: {
|
||||
const instance = this.updateInstance()
|
||||
|
||||
if (instance.task.taskStatus === TaskStatus.Running) {
|
||||
console.log(`[ExtensionManager] command -> cancelTask() | ${message.instanceId}`)
|
||||
|
||||
this.provider.cancelTask()
|
||||
this.provider.postStateToWebview()
|
||||
} else if (instance.task.taskId) {
|
||||
console.log(`[ExtensionManager] command -> clearTask() | ${message.instanceId}`)
|
||||
|
||||
this.provider.clearTask()
|
||||
this.provider.postStateToWebview()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case ExtensionBridgeCommandName.ResumeTask: {
|
||||
console.log(`[ExtensionManager] command -> resumeTask() | ${message.instanceId}`, {
|
||||
taskId: message.payload.taskId,
|
||||
})
|
||||
|
||||
// Resume the task from history by taskId
|
||||
this.provider.resumeTask(message.payload.taskId)
|
||||
|
||||
this.provider.postStateToWebview()
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async registerInstance(socket: Socket): Promise<void> {
|
||||
const instance = this.updateInstance()
|
||||
|
||||
try {
|
||||
socket.emit(ExtensionSocketEvents.REGISTER, instance)
|
||||
|
||||
console.log(
|
||||
`[ExtensionManager] emit() -> ${ExtensionSocketEvents.REGISTER}`,
|
||||
// instance,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.REGISTER}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async unregisterInstance(socket: Socket): Promise<void> {
|
||||
const instance = this.updateInstance()
|
||||
|
||||
try {
|
||||
socket.emit(ExtensionSocketEvents.UNREGISTER, instance)
|
||||
|
||||
console.log(
|
||||
`[ExtensionManager] emit() -> ${ExtensionSocketEvents.UNREGISTER}`,
|
||||
// instance,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.UNREGISTER}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(socket: Socket): void {
|
||||
this.stopHeartbeat()
|
||||
|
||||
this.heartbeatInterval = setInterval(async () => {
|
||||
const instance = this.updateInstance()
|
||||
|
||||
try {
|
||||
socket.emit(ExtensionSocketEvents.HEARTBEAT, instance)
|
||||
|
||||
// console.log(
|
||||
// `[ExtensionManager] emit() -> ${ExtensionSocketEvents.HEARTBEAT}`,
|
||||
// instance,
|
||||
// );
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.HEARTBEAT}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
this.heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
private setupListeners(): void {
|
||||
const eventMapping = [
|
||||
{
|
||||
from: RooCodeEventName.TaskCreated,
|
||||
to: ExtensionBridgeEventName.TaskCreated,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskStarted,
|
||||
to: ExtensionBridgeEventName.TaskStarted,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskCompleted,
|
||||
to: ExtensionBridgeEventName.TaskCompleted,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskAborted,
|
||||
to: ExtensionBridgeEventName.TaskAborted,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskFocused,
|
||||
to: ExtensionBridgeEventName.TaskFocused,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskUnfocused,
|
||||
to: ExtensionBridgeEventName.TaskUnfocused,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskActive,
|
||||
to: ExtensionBridgeEventName.TaskActive,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskInteractive,
|
||||
to: ExtensionBridgeEventName.TaskInteractive,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskResumable,
|
||||
to: ExtensionBridgeEventName.TaskResumable,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskIdle,
|
||||
to: ExtensionBridgeEventName.TaskIdle,
|
||||
},
|
||||
] as const
|
||||
|
||||
const addListener =
|
||||
(type: ExtensionBridgeEventName) =>
|
||||
async (..._args: unknown[]) => {
|
||||
this.publishEvent({
|
||||
type,
|
||||
instance: this.updateInstance(),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
eventMapping.forEach(({ from, to }) => this.provider.on(from, addListener(to)))
|
||||
}
|
||||
|
||||
private async publishEvent(message: ExtensionBridgeEvent): Promise<boolean> {
|
||||
if (!this.socket) {
|
||||
console.error("[ExtensionManager] publishEvent -> socket not available")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit(ExtensionSocketEvents.EVENT, message)
|
||||
|
||||
console.log(`[ExtensionManager] emit() -> ${ExtensionSocketEvents.EVENT} ${message.type}`, message)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.EVENT}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private updateInstance(): ExtensionInstance {
|
||||
const task = this.provider?.getCurrentTask()
|
||||
const taskHistory = this.provider?.getRecentTasks() ?? []
|
||||
|
||||
this.extensionInstance = {
|
||||
...this.extensionInstance,
|
||||
appProperties: this.extensionInstance.appProperties ?? this.provider.appProperties,
|
||||
gitProperties: this.extensionInstance.gitProperties ?? this.provider.gitProperties,
|
||||
lastHeartbeat: Date.now(),
|
||||
task: task
|
||||
? {
|
||||
taskId: task.taskId,
|
||||
taskStatus: task.taskStatus,
|
||||
...task.metadata,
|
||||
}
|
||||
: { taskId: "", taskStatus: TaskStatus.None },
|
||||
taskAsk: task?.taskAsk,
|
||||
taskHistory,
|
||||
}
|
||||
|
||||
return this.extensionInstance
|
||||
}
|
||||
}
|
||||
289
packages/cloud/src/bridge/SocketConnectionManager.ts
Normal file
289
packages/cloud/src/bridge/SocketConnectionManager.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { io, type Socket } from "socket.io-client"
|
||||
|
||||
import { ConnectionState, type RetryConfig } from "@roo-code/types"
|
||||
|
||||
export interface SocketConnectionOptions {
|
||||
url: string
|
||||
socketOptions: Record<string, unknown>
|
||||
onConnect?: () => void | Promise<void>
|
||||
onDisconnect?: (reason: string) => void
|
||||
onReconnect?: (attemptNumber: number) => void | Promise<void>
|
||||
onError?: (error: Error) => void
|
||||
logger?: {
|
||||
log: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
}
|
||||
}
|
||||
|
||||
export class SocketConnectionManager {
|
||||
private socket: Socket | null = null
|
||||
private connectionState: ConnectionState = ConnectionState.DISCONNECTED
|
||||
private retryAttempt: number = 0
|
||||
private retryTimeout: NodeJS.Timeout | null = null
|
||||
private hasConnectedOnce: boolean = false
|
||||
|
||||
private readonly retryConfig: RetryConfig = {
|
||||
maxInitialAttempts: 10,
|
||||
initialDelay: 1_000,
|
||||
maxDelay: 15_000,
|
||||
backoffMultiplier: 2,
|
||||
}
|
||||
|
||||
private readonly CONNECTION_TIMEOUT = 2_000
|
||||
private readonly options: SocketConnectionOptions
|
||||
|
||||
constructor(options: SocketConnectionOptions, retryConfig?: Partial<RetryConfig>) {
|
||||
this.options = options
|
||||
|
||||
if (retryConfig) {
|
||||
this.retryConfig = { ...this.retryConfig, ...retryConfig }
|
||||
}
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connectionState === ConnectionState.CONNECTED) {
|
||||
console.log(`[SocketConnectionManager] Already connected`)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.connectionState === ConnectionState.CONNECTING || this.connectionState === ConnectionState.RETRYING) {
|
||||
console.log(`[SocketConnectionManager] Connection attempt already in progress`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Start connection attempt without blocking.
|
||||
this.startConnectionAttempt()
|
||||
}
|
||||
|
||||
private async startConnectionAttempt() {
|
||||
this.retryAttempt = 0
|
||||
|
||||
try {
|
||||
await this.connectWithRetry()
|
||||
} catch (error) {
|
||||
console.error(`[SocketConnectionManager] Initial connection attempts failed:`, error)
|
||||
|
||||
// If we've never connected successfully, we've exhausted our retry attempts
|
||||
// The user will need to manually retry or fix the issue
|
||||
this.connectionState = ConnectionState.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
private async connectWithRetry(): Promise<void> {
|
||||
let delay = this.retryConfig.initialDelay
|
||||
|
||||
while (this.retryAttempt < this.retryConfig.maxInitialAttempts) {
|
||||
try {
|
||||
this.connectionState = this.retryAttempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING
|
||||
|
||||
console.log(
|
||||
`[SocketConnectionManager] Connection attempt ${this.retryAttempt + 1} / ${this.retryConfig.maxInitialAttempts}`,
|
||||
)
|
||||
|
||||
await this.connectSocket()
|
||||
|
||||
console.log(`[SocketConnectionManager] Connected to ${this.options.url}`)
|
||||
|
||||
this.connectionState = ConnectionState.CONNECTED
|
||||
this.retryAttempt = 0
|
||||
|
||||
this.clearRetryTimeouts()
|
||||
|
||||
if (this.options.onConnect) {
|
||||
await this.options.onConnect()
|
||||
}
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
this.retryAttempt++
|
||||
|
||||
console.error(`[SocketConnectionManager] Connection attempt ${this.retryAttempt} failed:`, error)
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.disconnect()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
if (this.retryAttempt >= this.retryConfig.maxInitialAttempts) {
|
||||
this.connectionState = ConnectionState.FAILED
|
||||
|
||||
throw new Error(`Failed to connect after ${this.retryConfig.maxInitialAttempts} attempts`)
|
||||
}
|
||||
|
||||
console.log(`[SocketConnectionManager] Waiting ${delay}ms before retry...`)
|
||||
|
||||
await this.delay(delay)
|
||||
|
||||
delay = Math.min(delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async connectSocket(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = io(this.options.url, this.options.socketOptions)
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
console.error(`[SocketConnectionManager] Connection timeout`)
|
||||
|
||||
if (this.connectionState !== ConnectionState.CONNECTED) {
|
||||
this.socket?.disconnect()
|
||||
reject(new Error("Connection timeout"))
|
||||
}
|
||||
}, this.CONNECTION_TIMEOUT)
|
||||
|
||||
this.socket.on("connect", async () => {
|
||||
clearTimeout(connectionTimeout)
|
||||
|
||||
const isReconnection = this.hasConnectedOnce
|
||||
|
||||
// If this is a reconnection (not the first connect), treat it as a
|
||||
// reconnect.
|
||||
// This handles server restarts where 'reconnect' event might not fire.
|
||||
if (isReconnection) {
|
||||
console.log(
|
||||
`[SocketConnectionManager] Treating connect as reconnection (server may have restarted)`,
|
||||
)
|
||||
|
||||
this.connectionState = ConnectionState.CONNECTED
|
||||
|
||||
if (this.options.onReconnect) {
|
||||
// Call onReconnect to re-register instance.
|
||||
await this.options.onReconnect(0)
|
||||
}
|
||||
}
|
||||
|
||||
this.hasConnectedOnce = true
|
||||
resolve()
|
||||
})
|
||||
|
||||
this.socket.on("disconnect", (reason: string) => {
|
||||
console.log(`[SocketConnectionManager] Disconnected (reason: ${reason})`)
|
||||
|
||||
this.connectionState = ConnectionState.DISCONNECTED
|
||||
|
||||
if (this.options.onDisconnect) {
|
||||
this.options.onDisconnect(reason)
|
||||
}
|
||||
|
||||
// Don't attempt to reconnect if we're manually disconnecting.
|
||||
const isManualDisconnect = reason === "io client disconnect"
|
||||
|
||||
if (!isManualDisconnect && this.hasConnectedOnce) {
|
||||
// After successful initial connection, rely entirely on Socket.IO's
|
||||
// reconnection.
|
||||
console.log(`[SocketConnectionManager] Socket.IO will handle reconnection (reason: ${reason})`)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for reconnection attempts.
|
||||
this.socket.on("reconnect_attempt", (attemptNumber: number) => {
|
||||
console.log(`[SocketConnectionManager] Socket.IO reconnect attempt:`, {
|
||||
attemptNumber,
|
||||
})
|
||||
})
|
||||
|
||||
this.socket.on("reconnect", (attemptNumber: number) => {
|
||||
console.log(`[SocketConnectionManager] Socket reconnected (attempt: ${attemptNumber})`)
|
||||
|
||||
this.connectionState = ConnectionState.CONNECTED
|
||||
|
||||
if (this.options.onReconnect) {
|
||||
this.options.onReconnect(attemptNumber)
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on("reconnect_error", (error: Error) => {
|
||||
console.error(`[SocketConnectionManager] Socket.IO reconnect error:`, error)
|
||||
})
|
||||
|
||||
this.socket.on("reconnect_failed", () => {
|
||||
console.error(`[SocketConnectionManager] Socket.IO reconnection failed after all attempts`)
|
||||
|
||||
this.connectionState = ConnectionState.FAILED
|
||||
|
||||
// Socket.IO has exhausted its reconnection attempts
|
||||
// The connection is now permanently failed until manual intervention
|
||||
})
|
||||
|
||||
this.socket.on("error", (error) => {
|
||||
console.error(`[SocketConnectionManager] Socket error:`, error)
|
||||
|
||||
if (this.connectionState !== ConnectionState.CONNECTED) {
|
||||
clearTimeout(connectionTimeout)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
if (this.options.onError) {
|
||||
this.options.onError(error)
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on("auth_error", (error) => {
|
||||
console.error(`[SocketConnectionManager] Authentication error:`, error)
|
||||
clearTimeout(connectionTimeout)
|
||||
reject(new Error(error.message || "Authentication failed"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.retryTimeout = setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Custom retry for initial connection attempts.
|
||||
// 2. Socket.IO's built-in reconnection after successful initial connection.
|
||||
|
||||
private clearRetryTimeouts() {
|
||||
if (this.retryTimeout) {
|
||||
clearTimeout(this.retryTimeout)
|
||||
this.retryTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
console.log(`[SocketConnectionManager] Disconnecting...`)
|
||||
|
||||
this.clearRetryTimeouts()
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners()
|
||||
this.socket.disconnect()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
this.connectionState = ConnectionState.DISCONNECTED
|
||||
|
||||
console.log(`[SocketConnectionManager] Disconnected`)
|
||||
}
|
||||
|
||||
public getSocket(): Socket | null {
|
||||
return this.socket
|
||||
}
|
||||
|
||||
public getConnectionState(): ConnectionState {
|
||||
return this.connectionState
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connectionState === ConnectionState.CONNECTED && this.socket?.connected === true
|
||||
}
|
||||
|
||||
public async reconnect(): Promise<void> {
|
||||
if (this.connectionState === ConnectionState.CONNECTED) {
|
||||
console.log(`[SocketConnectionManager] Already connected`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[SocketConnectionManager] Manual reconnection requested`)
|
||||
|
||||
this.hasConnectedOnce = false
|
||||
|
||||
await this.disconnect()
|
||||
await this.connect()
|
||||
}
|
||||
}
|
||||
279
packages/cloud/src/bridge/TaskManager.ts
Normal file
279
packages/cloud/src/bridge/TaskManager.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import type { Socket } from "socket.io-client"
|
||||
|
||||
import {
|
||||
type ClineMessage,
|
||||
type TaskEvents,
|
||||
type TaskLike,
|
||||
type TaskBridgeCommand,
|
||||
type TaskBridgeEvent,
|
||||
RooCodeEventName,
|
||||
TaskBridgeEventName,
|
||||
TaskBridgeCommandName,
|
||||
TaskSocketEvents,
|
||||
} from "@roo-code/types"
|
||||
|
||||
type TaskEventListener = {
|
||||
[K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise<void>
|
||||
}[keyof TaskEvents]
|
||||
|
||||
const TASK_EVENT_MAPPING: Record<TaskBridgeEventName, keyof TaskEvents> = {
|
||||
[TaskBridgeEventName.Message]: RooCodeEventName.Message,
|
||||
[TaskBridgeEventName.TaskModeSwitched]: RooCodeEventName.TaskModeSwitched,
|
||||
[TaskBridgeEventName.TaskInteractive]: RooCodeEventName.TaskInteractive,
|
||||
}
|
||||
|
||||
export class TaskManager {
|
||||
private subscribedTasks: Map<string, TaskLike> = new Map()
|
||||
private pendingTasks: Map<string, TaskLike> = new Map()
|
||||
private socket: Socket | null = null
|
||||
|
||||
private taskListeners: Map<string, Map<TaskBridgeEventName, TaskEventListener>> = new Map()
|
||||
|
||||
constructor() {}
|
||||
|
||||
public async onConnect(socket: Socket): Promise<void> {
|
||||
this.socket = socket
|
||||
|
||||
// Rejoin all subscribed tasks.
|
||||
for (const taskId of this.subscribedTasks.keys()) {
|
||||
try {
|
||||
socket.emit(TaskSocketEvents.JOIN, { taskId })
|
||||
|
||||
console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to any pending tasks.
|
||||
for (const task of this.pendingTasks.values()) {
|
||||
await this.subscribeToTask(task, socket)
|
||||
}
|
||||
|
||||
this.pendingTasks.clear()
|
||||
}
|
||||
|
||||
public onDisconnect(): void {
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
public async onReconnect(socket: Socket): Promise<void> {
|
||||
this.socket = socket
|
||||
|
||||
// Rejoin all subscribed tasks.
|
||||
for (const taskId of this.subscribedTasks.keys()) {
|
||||
try {
|
||||
socket.emit(TaskSocketEvents.JOIN, { taskId })
|
||||
|
||||
console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanup(socket: Socket | null): Promise<void> {
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
|
||||
const unsubscribePromises = []
|
||||
|
||||
for (const taskId of this.subscribedTasks.keys()) {
|
||||
unsubscribePromises.push(this.unsubscribeFromTask(taskId, socket))
|
||||
}
|
||||
|
||||
await Promise.allSettled(unsubscribePromises)
|
||||
this.subscribedTasks.clear()
|
||||
this.taskListeners.clear()
|
||||
this.pendingTasks.clear()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
public addPendingTask(task: TaskLike): void {
|
||||
this.pendingTasks.set(task.taskId, task)
|
||||
}
|
||||
|
||||
public async subscribeToTask(task: TaskLike, socket: Socket): Promise<void> {
|
||||
const taskId = task.taskId
|
||||
this.subscribedTasks.set(taskId, task)
|
||||
this.setupListeners(task)
|
||||
|
||||
try {
|
||||
socket.emit(TaskSocketEvents.JOIN, { taskId })
|
||||
console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public async unsubscribeFromTask(taskId: string, socket: Socket): Promise<void> {
|
||||
const task = this.subscribedTasks.get(taskId)
|
||||
|
||||
if (task) {
|
||||
this.removeListeners(task)
|
||||
this.subscribedTasks.delete(taskId)
|
||||
}
|
||||
|
||||
try {
|
||||
socket.emit(TaskSocketEvents.LEAVE, { taskId })
|
||||
|
||||
console.log(`[TaskManager] emit() -> ${TaskSocketEvents.LEAVE} ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] emit() failed -> ${TaskSocketEvents.LEAVE}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public handleTaskCommand(message: TaskBridgeCommand): void {
|
||||
const task = this.subscribedTasks.get(message.taskId)
|
||||
|
||||
if (!task) {
|
||||
console.error(`[TaskManager#handleTaskCommand] Unable to find task ${message.taskId}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case TaskBridgeCommandName.Message:
|
||||
console.log(
|
||||
`[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.Message} ${message.taskId} -> submitUserMessage()`,
|
||||
message,
|
||||
)
|
||||
|
||||
task.submitUserMessage(message.payload.text, message.payload.images)
|
||||
break
|
||||
case TaskBridgeCommandName.ApproveAsk:
|
||||
console.log(
|
||||
`[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.ApproveAsk} ${message.taskId} -> approveAsk()`,
|
||||
message,
|
||||
)
|
||||
|
||||
task.approveAsk(message.payload)
|
||||
break
|
||||
case TaskBridgeCommandName.DenyAsk:
|
||||
console.log(
|
||||
`[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.DenyAsk} ${message.taskId} -> denyAsk()`,
|
||||
message,
|
||||
)
|
||||
|
||||
task.denyAsk(message.payload)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private setupListeners(task: TaskLike): void {
|
||||
if (this.taskListeners.has(task.taskId)) {
|
||||
console.warn("[TaskManager] Listeners already exist for task, removing old listeners:", task.taskId)
|
||||
|
||||
this.removeListeners(task)
|
||||
}
|
||||
|
||||
const listeners = new Map<TaskBridgeEventName, TaskEventListener>()
|
||||
|
||||
const onMessage = ({ action, message }: { action: string; message: ClineMessage }) => {
|
||||
this.publishEvent({
|
||||
type: TaskBridgeEventName.Message,
|
||||
taskId: task.taskId,
|
||||
action,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
task.on(RooCodeEventName.Message, onMessage)
|
||||
listeners.set(TaskBridgeEventName.Message, onMessage)
|
||||
|
||||
const onTaskModeSwitched = (mode: string) => {
|
||||
this.publishEvent({
|
||||
type: TaskBridgeEventName.TaskModeSwitched,
|
||||
taskId: task.taskId,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
|
||||
task.on(RooCodeEventName.TaskModeSwitched, onTaskModeSwitched)
|
||||
listeners.set(TaskBridgeEventName.TaskModeSwitched, onTaskModeSwitched)
|
||||
|
||||
const onTaskInteractive = (_taskId: string) => {
|
||||
this.publishEvent({
|
||||
type: TaskBridgeEventName.TaskInteractive,
|
||||
taskId: task.taskId,
|
||||
})
|
||||
}
|
||||
|
||||
task.on(RooCodeEventName.TaskInteractive, onTaskInteractive)
|
||||
|
||||
listeners.set(TaskBridgeEventName.TaskInteractive, onTaskInteractive)
|
||||
|
||||
this.taskListeners.set(task.taskId, listeners)
|
||||
|
||||
console.log("[TaskManager] Task listeners setup complete for:", task.taskId)
|
||||
}
|
||||
|
||||
private removeListeners(task: TaskLike): void {
|
||||
const listeners = this.taskListeners.get(task.taskId)
|
||||
|
||||
if (!listeners) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[TaskManager] Removing task listeners for:", task.taskId)
|
||||
|
||||
listeners.forEach((listener, eventName) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
task.off(TASK_EVENT_MAPPING[eventName], listener as any)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] Error removing listener for ${String(eventName)} on task ${task.taskId}:`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
this.taskListeners.delete(task.taskId)
|
||||
}
|
||||
|
||||
private async publishEvent(message: TaskBridgeEvent): Promise<boolean> {
|
||||
if (!this.socket) {
|
||||
console.error("[TaskManager] publishEvent -> socket not available")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit(TaskSocketEvents.EVENT, message)
|
||||
|
||||
if (message.type !== TaskBridgeEventName.Message) {
|
||||
console.log(
|
||||
`[TaskManager] emit() -> ${TaskSocketEvents.EVENT} ${message.taskId} ${message.type}`,
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] emit() failed -> ${TaskSocketEvents.EVENT}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/cloud/src/config.ts
Normal file
6
packages/cloud/src/config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const PRODUCTION_CLERK_BASE_URL = "https://clerk.roocode.com"
|
||||
export const PRODUCTION_ROO_CODE_API_URL = "https://app.roocode.com"
|
||||
|
||||
export const getClerkBaseUrl = () => process.env.CLERK_BASE_URL || PRODUCTION_CLERK_BASE_URL
|
||||
|
||||
export const getRooCodeApiUrl = () => process.env.ROO_CODE_API_URL || PRODUCTION_ROO_CODE_API_URL
|
||||
42
packages/cloud/src/errors.ts
Normal file
42
packages/cloud/src/errors.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export class CloudAPIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public responseBody?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "CloudAPIError"
|
||||
Object.setPrototypeOf(this, CloudAPIError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskNotFoundError extends CloudAPIError {
|
||||
constructor(taskId?: string) {
|
||||
super(taskId ? `Task '${taskId}' not found` : "Task not found", 404)
|
||||
this.name = "TaskNotFoundError"
|
||||
Object.setPrototypeOf(this, TaskNotFoundError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationError extends CloudAPIError {
|
||||
constructor(message = "Authentication required") {
|
||||
super(message, 401)
|
||||
this.name = "AuthenticationError"
|
||||
Object.setPrototypeOf(this, AuthenticationError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends CloudAPIError {
|
||||
constructor(message = "Network error occurred") {
|
||||
super(message)
|
||||
this.name = "NetworkError"
|
||||
Object.setPrototypeOf(this, NetworkError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidClientTokenError extends Error {
|
||||
constructor() {
|
||||
super("Invalid/Expired client token")
|
||||
Object.setPrototypeOf(this, InvalidClientTokenError.prototype)
|
||||
}
|
||||
}
|
||||
49
packages/cloud/src/importVscode.ts
Normal file
49
packages/cloud/src/importVscode.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Utility for lazy-loading the VS Code module in environments where it's available.
|
||||
* This allows the SDK to be used in both VS Code extension and Node.js environments.
|
||||
* Compatible with both VSCode and Cursor extension hosts.
|
||||
*/
|
||||
|
||||
let vscodeModule: typeof import("vscode") | undefined
|
||||
|
||||
/**
|
||||
* Attempts to dynamically import the VS Code module.
|
||||
* Returns undefined if not running in a VS Code/Cursor extension context.
|
||||
*/
|
||||
export async function importVscode(): Promise<typeof import("vscode") | undefined> {
|
||||
// Check if already loaded
|
||||
if (vscodeModule) {
|
||||
return vscodeModule
|
||||
}
|
||||
|
||||
try {
|
||||
// Method 1: Check if vscode is available in global scope (common in extension hosts).
|
||||
if (typeof globalThis !== "undefined" && "acquireVsCodeApi" in globalThis) {
|
||||
// We're in a webview context, vscode module won't be available.
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Method 2: Try to require the module (works in most extension contexts).
|
||||
if (typeof require !== "undefined") {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
vscodeModule = require("vscode")
|
||||
|
||||
if (vscodeModule) {
|
||||
return vscodeModule
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading VS Code module:", error)
|
||||
// Fall through to dynamic import.
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Dynamic import (original approach, works in VSCode).
|
||||
vscodeModule = await import("vscode")
|
||||
return vscodeModule
|
||||
} catch (error) {
|
||||
// Log the original error for debugging.
|
||||
console.warn("VS Code module not available in this environment:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
5
packages/cloud/src/index.ts
Normal file
5
packages/cloud/src/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./config.js"
|
||||
|
||||
export * from "./CloudAPI.js"
|
||||
export * from "./CloudService.js"
|
||||
export * from "./bridge/ExtensionBridgeService.js"
|
||||
5
packages/cloud/src/utils.ts
Normal file
5
packages/cloud/src/utils.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { ExtensionContext } from "vscode"
|
||||
|
||||
export function getUserAgent(context?: ExtensionContext): string {
|
||||
return `Roo-Code ${context?.extension?.packageJSON?.version || "unknown"}`
|
||||
}
|
||||
9
packages/cloud/tsconfig.json
Normal file
9
packages/cloud/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "@roo-code/config-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals", "node"],
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src", "scripts", "*.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
14
packages/cloud/vitest.config.ts
Normal file
14
packages/cloud/vitest.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
watch: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
vscode: new URL("./src/__mocks__/vscode.ts", import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022"
|
||||
"target": "ES2022",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"lint": "eslint src --ext=ts --max-warnings=0",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rimraf dist .turbo"
|
||||
"clean": "rimraf .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@roo-code/types": "workspace:^",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@roo-code/types",
|
||||
"version": "1.63.0",
|
||||
"version": "1.64.0",
|
||||
"description": "TypeScript type definitions for Roo Code.",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { TaskCommandName, taskCommandSchema } from "../ipc.js"
|
||||
|
||||
describe("IPC Types", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { getApiProtocol } from "../provider-settings.js"
|
||||
|
||||
describe("getApiProtocol", () => {
|
||||
|
|
|
|||
618
packages/types/src/cloud.ts
Normal file
618
packages/types/src/cloud.ts
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
import EventEmitter from "events"
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
import { RooCodeEventName } from "./events.js"
|
||||
import { TaskStatus, taskMetadataSchema } from "./task.js"
|
||||
import { globalSettingsSchema } from "./global-settings.js"
|
||||
import { providerSettingsWithIdSchema } from "./provider-settings.js"
|
||||
import { mcpMarketplaceItemSchema } from "./marketplace.js"
|
||||
import { clineMessageSchema } from "./message.js"
|
||||
import { staticAppPropertiesSchema, gitPropertiesSchema } from "./telemetry.js"
|
||||
|
||||
/**
|
||||
* JWTPayload
|
||||
*/
|
||||
|
||||
export interface JWTPayload {
|
||||
iss?: string // Issuer (should be 'rcc')
|
||||
sub?: string // Subject - CloudJob ID for job tokens (t:'cj'), User ID for auth tokens (t:'auth')
|
||||
exp?: number // Expiration time
|
||||
iat?: number // Issued at time
|
||||
nbf?: number // Not before time
|
||||
v?: number // Version (should be 1)
|
||||
r?: {
|
||||
u?: string // User ID (always present in valid tokens)
|
||||
o?: string // Organization ID (optional - undefined when orgId is null)
|
||||
t?: string // Token type: 'cj' for job tokens, 'auth' for auth tokens
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CloudUserInfo
|
||||
*/
|
||||
|
||||
export interface CloudUserInfo {
|
||||
id?: string
|
||||
name?: string
|
||||
email?: string
|
||||
picture?: string
|
||||
organizationId?: string
|
||||
organizationName?: string
|
||||
organizationRole?: string
|
||||
organizationImageUrl?: string
|
||||
extensionBridgeEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* CloudOrganization
|
||||
*/
|
||||
|
||||
export interface CloudOrganization {
|
||||
id: string
|
||||
name: string
|
||||
slug?: string
|
||||
image_url?: string
|
||||
has_image?: boolean
|
||||
created_at?: number
|
||||
updated_at?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* CloudOrganizationMembership
|
||||
*/
|
||||
|
||||
export interface CloudOrganizationMembership {
|
||||
id: string
|
||||
organization: CloudOrganization
|
||||
role: string
|
||||
permissions?: string[]
|
||||
created_at?: number
|
||||
updated_at?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* OrganizationAllowList
|
||||
*/
|
||||
|
||||
export const organizationAllowListSchema = z.object({
|
||||
allowAll: z.boolean(),
|
||||
providers: z.record(
|
||||
z.object({
|
||||
allowAll: z.boolean(),
|
||||
models: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export type OrganizationAllowList = z.infer<typeof organizationAllowListSchema>
|
||||
|
||||
/**
|
||||
* OrganizationDefaultSettings
|
||||
*/
|
||||
|
||||
export const organizationDefaultSettingsSchema = globalSettingsSchema
|
||||
.pick({
|
||||
enableCheckpoints: true,
|
||||
fuzzyMatchThreshold: true,
|
||||
maxOpenTabsContext: true,
|
||||
maxReadFileLine: true,
|
||||
maxWorkspaceFiles: true,
|
||||
showRooIgnoredFiles: true,
|
||||
terminalCommandDelay: true,
|
||||
terminalCompressProgressBar: true,
|
||||
terminalOutputLineLimit: true,
|
||||
terminalShellIntegrationDisabled: true,
|
||||
terminalShellIntegrationTimeout: true,
|
||||
terminalZshClearEolMark: true,
|
||||
})
|
||||
// Add stronger validations for some fields.
|
||||
.merge(
|
||||
z.object({
|
||||
maxOpenTabsContext: z.number().int().nonnegative().optional(),
|
||||
maxReadFileLine: z.number().int().gte(-1).optional(),
|
||||
maxWorkspaceFiles: z.number().int().nonnegative().optional(),
|
||||
terminalCommandDelay: z.number().int().nonnegative().optional(),
|
||||
terminalOutputLineLimit: z.number().int().nonnegative().optional(),
|
||||
terminalShellIntegrationTimeout: z.number().int().nonnegative().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type OrganizationDefaultSettings = z.infer<typeof organizationDefaultSettingsSchema>
|
||||
|
||||
/**
|
||||
* OrganizationCloudSettings
|
||||
*/
|
||||
|
||||
export const organizationCloudSettingsSchema = z.object({
|
||||
recordTaskMessages: z.boolean().optional(),
|
||||
enableTaskSharing: z.boolean().optional(),
|
||||
taskShareExpirationDays: z.number().int().positive().optional(),
|
||||
allowMembersViewAllTasks: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type OrganizationCloudSettings = z.infer<typeof organizationCloudSettingsSchema>
|
||||
|
||||
/**
|
||||
* OrganizationSettings
|
||||
*/
|
||||
|
||||
export const organizationSettingsSchema = z.object({
|
||||
version: z.number(),
|
||||
cloudSettings: organizationCloudSettingsSchema.optional(),
|
||||
defaultSettings: organizationDefaultSettingsSchema,
|
||||
allowList: organizationAllowListSchema,
|
||||
hiddenMcps: z.array(z.string()).optional(),
|
||||
hideMarketplaceMcps: z.boolean().optional(),
|
||||
mcps: z.array(mcpMarketplaceItemSchema).optional(),
|
||||
providerProfiles: z.record(z.string(), providerSettingsWithIdSchema).optional(),
|
||||
})
|
||||
|
||||
export type OrganizationSettings = z.infer<typeof organizationSettingsSchema>
|
||||
|
||||
/**
|
||||
* User Settings Schemas
|
||||
*/
|
||||
|
||||
export const userFeaturesSchema = z.object({
|
||||
roomoteControlEnabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type UserFeatures = z.infer<typeof userFeaturesSchema>
|
||||
|
||||
export const userSettingsConfigSchema = z.object({
|
||||
extensionBridgeEnabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type UserSettingsConfig = z.infer<typeof userSettingsConfigSchema>
|
||||
|
||||
export const userSettingsDataSchema = z.object({
|
||||
features: userFeaturesSchema,
|
||||
settings: userSettingsConfigSchema,
|
||||
version: z.number(),
|
||||
})
|
||||
|
||||
export type UserSettingsData = z.infer<typeof userSettingsDataSchema>
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
|
||||
export const ORGANIZATION_ALLOW_ALL: OrganizationAllowList = {
|
||||
allowAll: true,
|
||||
providers: {},
|
||||
} as const
|
||||
|
||||
export const ORGANIZATION_DEFAULT: OrganizationSettings = {
|
||||
version: 0,
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
enableTaskSharing: true,
|
||||
taskShareExpirationDays: 30,
|
||||
allowMembersViewAllTasks: true,
|
||||
},
|
||||
defaultSettings: {},
|
||||
allowList: ORGANIZATION_ALLOW_ALL,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* ShareVisibility
|
||||
*/
|
||||
|
||||
export type ShareVisibility = "organization" | "public"
|
||||
|
||||
/**
|
||||
* ShareResponse
|
||||
*/
|
||||
|
||||
export const shareResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
shareUrl: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
isNewShare: z.boolean().optional(),
|
||||
manageUrl: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ShareResponse = z.infer<typeof shareResponseSchema>
|
||||
|
||||
/**
|
||||
* AuthService
|
||||
*/
|
||||
|
||||
export type AuthState = "initializing" | "logged-out" | "active-session" | "attempting-session" | "inactive-session"
|
||||
|
||||
export interface AuthService extends EventEmitter<AuthServiceEvents> {
|
||||
// Lifecycle
|
||||
initialize(): Promise<void>
|
||||
broadcast(): void
|
||||
|
||||
// Authentication methods
|
||||
login(): Promise<void>
|
||||
logout(): Promise<void>
|
||||
handleCallback(code: string | null, state: string | null, organizationId?: string | null): Promise<void>
|
||||
|
||||
// State methods
|
||||
getState(): AuthState
|
||||
isAuthenticated(): boolean
|
||||
hasActiveSession(): boolean
|
||||
hasOrIsAcquiringActiveSession(): boolean
|
||||
|
||||
// Token and user info
|
||||
getSessionToken(): string | undefined
|
||||
getUserInfo(): CloudUserInfo | null
|
||||
getStoredOrganizationId(): string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthServiceEvents
|
||||
*/
|
||||
|
||||
export interface AuthServiceEvents {
|
||||
"auth-state-changed": [
|
||||
data: {
|
||||
state: AuthState
|
||||
previousState: AuthState
|
||||
},
|
||||
]
|
||||
"user-info": [data: { userInfo: CloudUserInfo }]
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingsService
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface for settings services that provide organization settings
|
||||
*/
|
||||
export interface SettingsService {
|
||||
/**
|
||||
* Get the organization allow list
|
||||
* @returns The organization allow list or default if none available
|
||||
*/
|
||||
getAllowList(): OrganizationAllowList
|
||||
|
||||
/**
|
||||
* Get the current organization settings
|
||||
* @returns The organization settings or undefined if none available
|
||||
*/
|
||||
getSettings(): OrganizationSettings | undefined
|
||||
|
||||
/**
|
||||
* Get the current user settings
|
||||
* @returns The user settings data or undefined if none available
|
||||
*/
|
||||
getUserSettings(): UserSettingsData | undefined
|
||||
|
||||
/**
|
||||
* Get the current user features
|
||||
* @returns The user features or empty object if none available
|
||||
*/
|
||||
getUserFeatures(): UserFeatures
|
||||
|
||||
/**
|
||||
* Get the current user settings configuration
|
||||
* @returns The user settings configuration or empty object if none available
|
||||
*/
|
||||
getUserSettingsConfig(): UserSettingsConfig
|
||||
|
||||
/**
|
||||
* Update user settings with partial configuration
|
||||
* @param settings Partial user settings configuration to update
|
||||
* @returns Promise that resolves to true if successful, false otherwise
|
||||
*/
|
||||
updateUserSettings(settings: Partial<UserSettingsConfig>): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Dispose of the settings service and clean up resources
|
||||
*/
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingsServiceEvents
|
||||
*/
|
||||
|
||||
export interface SettingsServiceEvents {
|
||||
"settings-updated": [data: Record<string, never>]
|
||||
}
|
||||
|
||||
/**
|
||||
* CloudServiceEvents
|
||||
*/
|
||||
|
||||
export type CloudServiceEvents = AuthServiceEvents & SettingsServiceEvents
|
||||
|
||||
/**
|
||||
* ConnectionState
|
||||
*/
|
||||
|
||||
export enum ConnectionState {
|
||||
DISCONNECTED = "disconnected",
|
||||
CONNECTING = "connecting",
|
||||
CONNECTED = "connected",
|
||||
RETRYING = "retrying",
|
||||
FAILED = "failed",
|
||||
}
|
||||
|
||||
/**
|
||||
* RetryConfig
|
||||
*/
|
||||
|
||||
export interface RetryConfig {
|
||||
maxInitialAttempts: number
|
||||
initialDelay: number
|
||||
maxDelay: number
|
||||
backoffMultiplier: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
|
||||
export const HEARTBEAT_INTERVAL_MS = 20_000
|
||||
export const INSTANCE_TTL_SECONDS = 60
|
||||
|
||||
/**
|
||||
* ExtensionTask
|
||||
*/
|
||||
|
||||
const extensionTaskSchema = z.object({
|
||||
taskId: z.string(),
|
||||
taskStatus: z.nativeEnum(TaskStatus),
|
||||
...taskMetadataSchema.shape,
|
||||
})
|
||||
|
||||
export type ExtensionTask = z.infer<typeof extensionTaskSchema>
|
||||
|
||||
/**
|
||||
* ExtensionInstance
|
||||
*/
|
||||
|
||||
export const extensionInstanceSchema = z.object({
|
||||
instanceId: z.string(),
|
||||
userId: z.string(),
|
||||
workspacePath: z.string(),
|
||||
appProperties: staticAppPropertiesSchema,
|
||||
gitProperties: gitPropertiesSchema.optional(),
|
||||
lastHeartbeat: z.coerce.number(),
|
||||
task: extensionTaskSchema,
|
||||
taskAsk: clineMessageSchema.optional(),
|
||||
taskHistory: z.array(z.string()),
|
||||
})
|
||||
|
||||
export type ExtensionInstance = z.infer<typeof extensionInstanceSchema>
|
||||
|
||||
/**
|
||||
* ExtensionBridgeEvent
|
||||
*/
|
||||
|
||||
export enum ExtensionBridgeEventName {
|
||||
TaskCreated = RooCodeEventName.TaskCreated,
|
||||
TaskStarted = RooCodeEventName.TaskStarted,
|
||||
TaskCompleted = RooCodeEventName.TaskCompleted,
|
||||
TaskAborted = RooCodeEventName.TaskAborted,
|
||||
TaskFocused = RooCodeEventName.TaskFocused,
|
||||
TaskUnfocused = RooCodeEventName.TaskUnfocused,
|
||||
TaskActive = RooCodeEventName.TaskActive,
|
||||
TaskInteractive = RooCodeEventName.TaskInteractive,
|
||||
TaskResumable = RooCodeEventName.TaskResumable,
|
||||
TaskIdle = RooCodeEventName.TaskIdle,
|
||||
|
||||
InstanceRegistered = "instance_registered",
|
||||
InstanceUnregistered = "instance_unregistered",
|
||||
HeartbeatUpdated = "heartbeat_updated",
|
||||
}
|
||||
|
||||
export const extensionBridgeEventSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.TaskCreated),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.TaskStarted),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.TaskCompleted),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.TaskAborted),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.TaskFocused),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.TaskUnfocused),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.TaskActive),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.TaskInteractive),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.TaskResumable),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.TaskIdle),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.InstanceRegistered),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.InstanceUnregistered),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeEventName.HeartbeatUpdated),
|
||||
instance: extensionInstanceSchema,
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
])
|
||||
|
||||
export type ExtensionBridgeEvent = z.infer<typeof extensionBridgeEventSchema>
|
||||
|
||||
/**
|
||||
* ExtensionBridgeCommand
|
||||
*/
|
||||
|
||||
export enum ExtensionBridgeCommandName {
|
||||
StartTask = "start_task",
|
||||
StopTask = "stop_task",
|
||||
ResumeTask = "resume_task",
|
||||
}
|
||||
|
||||
export const extensionBridgeCommandSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeCommandName.StartTask),
|
||||
instanceId: z.string(),
|
||||
payload: z.object({
|
||||
text: z.string(),
|
||||
images: z.array(z.string()).optional(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeCommandName.StopTask),
|
||||
instanceId: z.string(),
|
||||
payload: z.object({ taskId: z.string() }),
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(ExtensionBridgeCommandName.ResumeTask),
|
||||
instanceId: z.string(),
|
||||
payload: z.object({
|
||||
taskId: z.string(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
])
|
||||
|
||||
export type ExtensionBridgeCommand = z.infer<typeof extensionBridgeCommandSchema>
|
||||
|
||||
/**
|
||||
* TaskBridgeEvent
|
||||
*/
|
||||
|
||||
export enum TaskBridgeEventName {
|
||||
Message = RooCodeEventName.Message,
|
||||
TaskModeSwitched = RooCodeEventName.TaskModeSwitched,
|
||||
TaskInteractive = RooCodeEventName.TaskInteractive,
|
||||
}
|
||||
|
||||
export const taskBridgeEventSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal(TaskBridgeEventName.Message),
|
||||
taskId: z.string(),
|
||||
action: z.string(),
|
||||
message: clineMessageSchema,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(TaskBridgeEventName.TaskModeSwitched),
|
||||
taskId: z.string(),
|
||||
mode: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(TaskBridgeEventName.TaskInteractive),
|
||||
taskId: z.string(),
|
||||
}),
|
||||
])
|
||||
|
||||
export type TaskBridgeEvent = z.infer<typeof taskBridgeEventSchema>
|
||||
|
||||
/**
|
||||
* TaskBridgeCommand
|
||||
*/
|
||||
|
||||
export enum TaskBridgeCommandName {
|
||||
Message = "message",
|
||||
ApproveAsk = "approve_ask",
|
||||
DenyAsk = "deny_ask",
|
||||
}
|
||||
|
||||
export const taskBridgeCommandSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal(TaskBridgeCommandName.Message),
|
||||
taskId: z.string(),
|
||||
payload: z.object({
|
||||
text: z.string(),
|
||||
images: z.array(z.string()).optional(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(TaskBridgeCommandName.ApproveAsk),
|
||||
taskId: z.string(),
|
||||
payload: z.object({
|
||||
text: z.string().optional(),
|
||||
images: z.array(z.string()).optional(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(TaskBridgeCommandName.DenyAsk),
|
||||
taskId: z.string(),
|
||||
payload: z.object({
|
||||
text: z.string().optional(),
|
||||
images: z.array(z.string()).optional(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
}),
|
||||
])
|
||||
|
||||
export type TaskBridgeCommand = z.infer<typeof taskBridgeCommandSchema>
|
||||
|
||||
/**
|
||||
* ExtensionSocketEvents
|
||||
*/
|
||||
|
||||
export const ExtensionSocketEvents = {
|
||||
CONNECTED: "extension:connected",
|
||||
|
||||
REGISTER: "extension:register",
|
||||
UNREGISTER: "extension:unregister",
|
||||
|
||||
HEARTBEAT: "extension:heartbeat",
|
||||
|
||||
EVENT: "extension:event", // event from extension instance
|
||||
RELAYED_EVENT: "extension:relayed_event", // relay from server
|
||||
|
||||
COMMAND: "extension:command", // command from user
|
||||
RELAYED_COMMAND: "extension:relayed_command", // relay from server
|
||||
} as const
|
||||
|
||||
/**
|
||||
* TaskSocketEvents
|
||||
*/
|
||||
|
||||
export const TaskSocketEvents = {
|
||||
JOIN: "task:join",
|
||||
LEAVE: "task:leave",
|
||||
|
||||
EVENT: "task:event", // event from extension task
|
||||
RELAYED_EVENT: "task:relayed_event", // relay from server
|
||||
|
||||
COMMAND: "task:command", // command from user
|
||||
RELAYED_COMMAND: "task:relayed_command", // relay from server
|
||||
} as const
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from "./api.js"
|
||||
export * from "./cloud.js"
|
||||
export * from "./codebase-index.js"
|
||||
export * from "./events.js"
|
||||
export * from "./experiment.js"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, test, expect } from "vitest"
|
||||
import { convertModelNameForVertex, getClaudeCodeModelId } from "../claude-code.js"
|
||||
|
||||
describe("convertModelNameForVertex", () => {
|
||||
|
|
|
|||
170
pnpm-lock.yaml
generated
170
pnpm-lock.yaml
generated
|
|
@ -71,8 +71,8 @@ importers:
|
|||
specifier: ^4.19.3
|
||||
version: 4.19.4
|
||||
turbo:
|
||||
specifier: ^2.5.3
|
||||
version: 2.5.4
|
||||
specifier: ^2.5.6
|
||||
version: 2.5.6
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.8.3
|
||||
|
|
@ -285,8 +285,8 @@ importers:
|
|||
specifier: ^8.6.0
|
||||
version: 8.6.0(react@18.3.1)
|
||||
framer-motion:
|
||||
specifier: ^12.15.0
|
||||
version: 12.16.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
specifier: 12.15.0
|
||||
version: 12.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
lucide-react:
|
||||
specifier: ^0.518.0
|
||||
version: 0.518.0(react@18.3.1)
|
||||
|
|
@ -371,6 +371,46 @@ importers:
|
|||
specifier: ^3.2.3
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
|
||||
packages/cloud:
|
||||
dependencies:
|
||||
'@roo-code/types':
|
||||
specifier: workspace:^
|
||||
version: link:../types
|
||||
ioredis:
|
||||
specifier: ^5.6.1
|
||||
version: 5.6.1
|
||||
jwt-decode:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
p-wait-for:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
socket.io-client:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.1
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@roo-code/config-eslint':
|
||||
specifier: workspace:^
|
||||
version: link:../config-eslint
|
||||
'@roo-code/config-typescript':
|
||||
specifier: workspace:^
|
||||
version: link:../config-typescript
|
||||
'@types/node':
|
||||
specifier: ^24.1.0
|
||||
version: 24.2.1
|
||||
'@types/vscode':
|
||||
specifier: ^1.102.0
|
||||
version: 1.103.0
|
||||
globals:
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.0
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
|
||||
packages/config-eslint:
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
|
|
@ -396,7 +436,7 @@ importers:
|
|||
version: 5.2.0(eslint@9.27.0(jiti@2.4.2))
|
||||
eslint-plugin-turbo:
|
||||
specifier: ^2.4.4
|
||||
version: 2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.4)
|
||||
version: 2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.6)
|
||||
globals:
|
||||
specifier: ^16.0.0
|
||||
version: 16.1.0
|
||||
|
|
@ -578,14 +618,14 @@ importers:
|
|||
specifier: ^1.9.18
|
||||
version: 1.9.18(zod@3.25.61)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.9.0
|
||||
specifier: 1.12.0
|
||||
version: 1.12.0
|
||||
'@qdrant/js-client-rest':
|
||||
specifier: ^1.14.0
|
||||
version: 1.14.0(typescript@5.8.3)
|
||||
'@roo-code/cloud':
|
||||
specifier: ^0.29.0
|
||||
version: 0.29.0
|
||||
specifier: workspace:^
|
||||
version: link:../packages/cloud
|
||||
'@roo-code/ipc':
|
||||
specifier: workspace:^
|
||||
version: link:../packages/ipc
|
||||
|
|
@ -595,9 +635,6 @@ importers:
|
|||
'@roo-code/types':
|
||||
specifier: workspace:^
|
||||
version: link:../packages/types
|
||||
'@types/lodash.debounce':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
'@vscode/codicons':
|
||||
specifier: ^0.0.36
|
||||
version: 0.0.36
|
||||
|
|
@ -803,6 +840,9 @@ importers:
|
|||
'@types/glob':
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.0
|
||||
'@types/lodash.debounce':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
'@types/mocha':
|
||||
specifier: ^10.0.10
|
||||
version: 10.0.10
|
||||
|
|
@ -3346,12 +3386,6 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@roo-code/cloud@0.29.0':
|
||||
resolution: {integrity: sha512-fXN0mdkd5GezpVrCspe6atUkwvSk5D4wF80g+lc8E3aPVqEAozoI97kHNulRChGlBw7UIdd5xxbr1Z8Jtn+S/Q==}
|
||||
|
||||
'@roo-code/types@1.63.0':
|
||||
resolution: {integrity: sha512-pX8ftkDq1CySBbkUTIW9/QEG52ttFT/kl0ID286l0L3W22wpGRUct6PCedNI9kLDM4s5sxaUeZx7b3rUChikkw==}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
|
|
@ -4225,6 +4259,9 @@ packages:
|
|||
'@types/vscode@1.100.0':
|
||||
resolution: {integrity: sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==}
|
||||
|
||||
'@types/vscode@1.103.0':
|
||||
resolution: {integrity: sha512-o4hanZAQdNfsKecexq9L3eHICd0AAvdbLk6hA60UzGXbGH/q8b/9xv2RgR7vV3ZcHuyKVq7b37IGd/+gM4Tu+Q==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
|
|
@ -6129,8 +6166,8 @@ packages:
|
|||
fraction.js@4.3.7:
|
||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||
|
||||
framer-motion@12.16.0:
|
||||
resolution: {integrity: sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==}
|
||||
framer-motion@12.15.0:
|
||||
resolution: {integrity: sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
|
|
@ -9470,38 +9507,38 @@ packages:
|
|||
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
|
||||
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
|
||||
|
||||
turbo-darwin-64@2.5.4:
|
||||
resolution: {integrity: sha512-ah6YnH2dErojhFooxEzmvsoZQTMImaruZhFPfMKPBq8sb+hALRdvBNLqfc8NWlZq576FkfRZ/MSi4SHvVFT9PQ==}
|
||||
turbo-darwin-64@2.5.6:
|
||||
resolution: {integrity: sha512-3C1xEdo4aFwMJAPvtlPqz1Sw/+cddWIOmsalHFMrsqqydcptwBfu26WW2cDm3u93bUzMbBJ8k3zNKFqxJ9ei2A==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
turbo-darwin-arm64@2.5.4:
|
||||
resolution: {integrity: sha512-2+Nx6LAyuXw2MdXb7pxqle3MYignLvS7OwtsP9SgtSBaMlnNlxl9BovzqdYAgkUW3AsYiQMJ/wBRb7d+xemM5A==}
|
||||
turbo-darwin-arm64@2.5.6:
|
||||
resolution: {integrity: sha512-LyiG+rD7JhMfYwLqB6k3LZQtYn8CQQUePbpA8mF/hMLPAekXdJo1g0bUPw8RZLwQXUIU/3BU7tXENvhSGz5DPA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
turbo-linux-64@2.5.4:
|
||||
resolution: {integrity: sha512-5May2kjWbc8w4XxswGAl74GZ5eM4Gr6IiroqdLhXeXyfvWEdm2mFYCSWOzz0/z5cAgqyGidF1jt1qzUR8hTmOA==}
|
||||
turbo-linux-64@2.5.6:
|
||||
resolution: {integrity: sha512-GOcUTT0xiT/pSnHL4YD6Yr3HreUhU8pUcGqcI2ksIF9b2/r/kRHwGFcsHgpG3+vtZF/kwsP0MV8FTlTObxsYIA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
turbo-linux-arm64@2.5.4:
|
||||
resolution: {integrity: sha512-/2yqFaS3TbfxV3P5yG2JUI79P7OUQKOUvAnx4MV9Bdz6jqHsHwc9WZPpO4QseQm+NvmgY6ICORnoVPODxGUiJg==}
|
||||
turbo-linux-arm64@2.5.6:
|
||||
resolution: {integrity: sha512-10Tm15bruJEA3m0V7iZcnQBpObGBcOgUcO+sY7/2vk1bweW34LMhkWi8svjV9iDF68+KJDThnYDlYE/bc7/zzQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
turbo-windows-64@2.5.4:
|
||||
resolution: {integrity: sha512-EQUO4SmaCDhO6zYohxIjJpOKRN3wlfU7jMAj3CgcyTPvQR/UFLEKAYHqJOnJtymbQmiiM/ihX6c6W6Uq0yC7mA==}
|
||||
turbo-windows-64@2.5.6:
|
||||
resolution: {integrity: sha512-FyRsVpgaj76It0ludwZsNN40ytHN+17E4PFJyeliBEbxrGTc5BexlXVpufB7XlAaoaZVxbS6KT8RofLfDRyEPg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
turbo-windows-arm64@2.5.4:
|
||||
resolution: {integrity: sha512-oQ8RrK1VS8lrxkLriotFq+PiF7iiGgkZtfLKF4DDKsmdbPo0O9R2mQxm7jHLuXraRCuIQDWMIw6dpcr7Iykf4A==}
|
||||
turbo-windows-arm64@2.5.6:
|
||||
resolution: {integrity: sha512-j/tWu8cMeQ7HPpKri6jvKtyXg9K1gRyhdK4tKrrchH8GNHscPX/F71zax58yYtLRWTiK04zNzPcUJuoS0+v/+Q==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
turbo@2.5.4:
|
||||
resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==}
|
||||
turbo@2.5.6:
|
||||
resolution: {integrity: sha512-gxToHmi9oTBNB05UjUsrWf0OyN5ZXtD0apOarC1KIx232Vp3WimRNy3810QzeNSgyD5rsaIDXlxlbnOzlouo+w==}
|
||||
hasBin: true
|
||||
|
||||
turndown@7.2.0:
|
||||
|
|
@ -11424,8 +11461,8 @@ snapshots:
|
|||
'@modelcontextprotocol/sdk': 1.12.0
|
||||
google-auth-library: 9.15.1
|
||||
ws: 8.18.2
|
||||
zod: 3.25.61
|
||||
zod-to-json-schema: 3.24.5(zod@3.25.61)
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.24.5(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- encoding
|
||||
|
|
@ -11684,8 +11721,8 @@ snapshots:
|
|||
'@lmstudio/lms-isomorphic': 0.4.5
|
||||
chalk: 4.1.2
|
||||
jsonschema: 1.5.0
|
||||
zod: 3.25.61
|
||||
zod-to-json-schema: 3.24.5(zod@3.25.61)
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.24.5(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
|
@ -11751,8 +11788,8 @@ snapshots:
|
|||
express-rate-limit: 7.5.0(express@5.1.0)
|
||||
pkce-challenge: 5.0.0
|
||||
raw-body: 3.0.0
|
||||
zod: 3.25.61
|
||||
zod-to-json-schema: 3.24.5(zod@3.25.61)
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.24.5(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -12732,23 +12769,6 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc@4.40.2':
|
||||
optional: true
|
||||
|
||||
'@roo-code/cloud@0.29.0':
|
||||
dependencies:
|
||||
'@roo-code/types': 1.63.0
|
||||
ioredis: 5.6.1
|
||||
jwt-decode: 4.0.0
|
||||
p-wait-for: 5.0.2
|
||||
socket.io-client: 4.8.1
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@roo-code/types@1.63.0':
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@sevinf/maybe@0.5.0': {}
|
||||
|
|
@ -13799,6 +13819,8 @@ snapshots:
|
|||
|
||||
'@types/vscode@1.100.0': {}
|
||||
|
||||
'@types/vscode@1.103.0': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.2.1
|
||||
|
|
@ -15551,11 +15573,11 @@ snapshots:
|
|||
string.prototype.matchall: 4.0.12
|
||||
string.prototype.repeat: 1.0.0
|
||||
|
||||
eslint-plugin-turbo@2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.4):
|
||||
eslint-plugin-turbo@2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.6):
|
||||
dependencies:
|
||||
dotenv: 16.0.3
|
||||
eslint: 9.27.0(jiti@2.4.2)
|
||||
turbo: 2.5.4
|
||||
turbo: 2.5.6
|
||||
|
||||
eslint-scope@8.3.0:
|
||||
dependencies:
|
||||
|
|
@ -16026,7 +16048,7 @@ snapshots:
|
|||
|
||||
fraction.js@4.3.7: {}
|
||||
|
||||
framer-motion@12.16.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
framer-motion@12.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
motion-dom: 12.16.0
|
||||
motion-utils: 12.12.1
|
||||
|
|
@ -19977,32 +19999,32 @@ snapshots:
|
|||
|
||||
tunnel@0.0.6: {}
|
||||
|
||||
turbo-darwin-64@2.5.4:
|
||||
turbo-darwin-64@2.5.6:
|
||||
optional: true
|
||||
|
||||
turbo-darwin-arm64@2.5.4:
|
||||
turbo-darwin-arm64@2.5.6:
|
||||
optional: true
|
||||
|
||||
turbo-linux-64@2.5.4:
|
||||
turbo-linux-64@2.5.6:
|
||||
optional: true
|
||||
|
||||
turbo-linux-arm64@2.5.4:
|
||||
turbo-linux-arm64@2.5.6:
|
||||
optional: true
|
||||
|
||||
turbo-windows-64@2.5.4:
|
||||
turbo-windows-64@2.5.6:
|
||||
optional: true
|
||||
|
||||
turbo-windows-arm64@2.5.4:
|
||||
turbo-windows-arm64@2.5.6:
|
||||
optional: true
|
||||
|
||||
turbo@2.5.4:
|
||||
turbo@2.5.6:
|
||||
optionalDependencies:
|
||||
turbo-darwin-64: 2.5.4
|
||||
turbo-darwin-arm64: 2.5.4
|
||||
turbo-linux-64: 2.5.4
|
||||
turbo-linux-arm64: 2.5.4
|
||||
turbo-windows-64: 2.5.4
|
||||
turbo-windows-arm64: 2.5.4
|
||||
turbo-darwin-64: 2.5.6
|
||||
turbo-darwin-arm64: 2.5.6
|
||||
turbo-linux-64: 2.5.6
|
||||
turbo-linux-arm64: 2.5.6
|
||||
turbo-windows-64: 2.5.6
|
||||
turbo-windows-arm64: 2.5.6
|
||||
|
||||
turndown@7.2.0:
|
||||
dependencies:
|
||||
|
|
@ -20836,6 +20858,10 @@ snapshots:
|
|||
dependencies:
|
||||
zod: 3.25.61
|
||||
|
||||
zod-to-json-schema@3.24.5(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.61):
|
||||
dependencies:
|
||||
typescript: 5.8.3
|
||||
|
|
|
|||
|
|
@ -1,340 +0,0 @@
|
|||
import { spawn, execSync, type ChildProcess } from "child_process"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import { fileURLToPath } from "url"
|
||||
import { glob } from "glob"
|
||||
|
||||
// @ts-expect-error - TS1470: We only run this script with tsx so it will never
|
||||
// compile to CJS and it's safe to ignore this tsc error.
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
interface PackageConfig {
|
||||
readonly name: string
|
||||
readonly sourcePath: string
|
||||
readonly targetPaths: readonly string[]
|
||||
readonly replacePath?: string
|
||||
readonly npmPath: string
|
||||
readonly watchCommand?: string
|
||||
readonly watchOutput?: {
|
||||
readonly start: string[]
|
||||
readonly stop: string[]
|
||||
}
|
||||
}
|
||||
|
||||
interface Config {
|
||||
readonly packages: readonly PackageConfig[]
|
||||
}
|
||||
|
||||
interface WatcherResult {
|
||||
child: ChildProcess
|
||||
}
|
||||
|
||||
interface NpmPackage {
|
||||
name?: string
|
||||
version?: string
|
||||
type: "module"
|
||||
dependencies: Record<string, string>
|
||||
main: string
|
||||
module: string
|
||||
types: string
|
||||
exports: {
|
||||
".": {
|
||||
types: string
|
||||
import: string
|
||||
require: {
|
||||
types: string
|
||||
default: string
|
||||
}
|
||||
}
|
||||
}
|
||||
files: string[]
|
||||
}
|
||||
|
||||
const config: Config = {
|
||||
packages: [
|
||||
{
|
||||
name: "@roo-code/cloud",
|
||||
sourcePath: "../Roo-Code-Cloud/packages/sdk",
|
||||
targetPaths: ["src/node_modules/@roo-code/cloud"] as const,
|
||||
replacePath: "node_modules/.pnpm/@roo-code+cloud*",
|
||||
npmPath: "npm",
|
||||
watchCommand: "pnpm build:development:watch",
|
||||
watchOutput: {
|
||||
start: ["CLI Building", "CLI Change detected"],
|
||||
stop: ["DTS ⚡️ Build success"],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const packageName = args.find((arg) => !arg.startsWith("--"))
|
||||
const watchMode = !args.includes("--no-watch")
|
||||
const unlink = args.includes("--unlink")
|
||||
|
||||
const packages: readonly PackageConfig[] = packageName
|
||||
? config.packages.filter((p) => p.name === packageName)
|
||||
: config.packages
|
||||
|
||||
if (!packages.length) {
|
||||
console.error(`Package '${packageName}' not found`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function pathExists(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function copyRecursiveSync(src: string, dest: string): void {
|
||||
const exists = pathExists(src)
|
||||
|
||||
if (!exists) {
|
||||
return
|
||||
}
|
||||
|
||||
const stats = fs.statSync(src)
|
||||
const isDirectory = stats.isDirectory()
|
||||
|
||||
if (isDirectory) {
|
||||
if (!pathExists(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true })
|
||||
}
|
||||
|
||||
const children = fs.readdirSync(src)
|
||||
|
||||
children.forEach((childItemName) => {
|
||||
copyRecursiveSync(path.join(src, childItemName), path.join(dest, childItemName))
|
||||
})
|
||||
} else {
|
||||
fs.copyFileSync(src, dest)
|
||||
}
|
||||
}
|
||||
|
||||
function generateNpmPackageJson(sourcePath: string, npmPath: string): string {
|
||||
const npmDir = path.join(sourcePath, npmPath)
|
||||
const npmPackagePath = path.join(npmDir, "package.json")
|
||||
const npmMetadataPath = path.join(npmDir, "package.metadata.json")
|
||||
const monorepoPackagePath = path.join(sourcePath, "package.json")
|
||||
|
||||
if (pathExists(npmPackagePath)) {
|
||||
return npmPackagePath
|
||||
}
|
||||
|
||||
if (!pathExists(npmMetadataPath)) {
|
||||
throw new Error(`No package.metadata.json found in ${npmDir}`)
|
||||
}
|
||||
|
||||
const monorepoPackageContent = fs.readFileSync(monorepoPackagePath, "utf8")
|
||||
|
||||
const monorepoPackage = JSON.parse(monorepoPackageContent) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
const npmMetadataContent = fs.readFileSync(npmMetadataPath, "utf8")
|
||||
const npmMetadata = JSON.parse(npmMetadataContent) as Partial<NpmPackage>
|
||||
|
||||
const npmPackage: NpmPackage = {
|
||||
...npmMetadata,
|
||||
type: "module",
|
||||
dependencies: monorepoPackage.dependencies || {},
|
||||
main: "./dist/index.cjs",
|
||||
module: "./dist/index.js",
|
||||
types: "./dist/index.d.ts",
|
||||
exports: {
|
||||
".": {
|
||||
types: "./dist/index.d.ts",
|
||||
import: "./dist/index.js",
|
||||
require: {
|
||||
types: "./dist/index.d.cts",
|
||||
default: "./dist/index.cjs",
|
||||
},
|
||||
},
|
||||
},
|
||||
files: ["dist"],
|
||||
}
|
||||
|
||||
fs.writeFileSync(npmPackagePath, JSON.stringify(npmPackage, null, 2) + "\n")
|
||||
|
||||
return npmPackagePath
|
||||
}
|
||||
|
||||
function linkPackage(pkg: PackageConfig): void {
|
||||
const sourcePath = path.resolve(__dirname, "..", pkg.sourcePath)
|
||||
|
||||
if (!pathExists(sourcePath)) {
|
||||
console.error(`❌ Source not found: ${sourcePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
generateNpmPackageJson(sourcePath, pkg.npmPath)
|
||||
|
||||
for (const currentTargetPath of pkg.targetPaths) {
|
||||
const targetPath = path.resolve(__dirname, "..", currentTargetPath)
|
||||
|
||||
if (pathExists(targetPath)) {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(targetPath)
|
||||
fs.mkdirSync(parentDir, { recursive: true })
|
||||
|
||||
const linkSource = pkg.npmPath ? path.join(sourcePath, pkg.npmPath) : sourcePath
|
||||
copyRecursiveSync(linkSource, targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
function unlinkPackage(pkg: PackageConfig): void {
|
||||
for (const currentTargetPath of pkg.targetPaths) {
|
||||
const targetPath = path.resolve(__dirname, "..", currentTargetPath)
|
||||
|
||||
if (pathExists(targetPath)) {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true })
|
||||
console.log(`🗑️ Removed ${pkg.name} from ${currentTargetPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startWatch(pkg: PackageConfig): WatcherResult {
|
||||
if (!pkg.watchCommand) {
|
||||
throw new Error(`Package ${pkg.name} has no watch command configured`)
|
||||
}
|
||||
|
||||
const commandParts = pkg.watchCommand.split(" ")
|
||||
const [cmd, ...args] = commandParts
|
||||
|
||||
if (!cmd) {
|
||||
throw new Error(`Invalid watch command for ${pkg.name}`)
|
||||
}
|
||||
|
||||
console.log(`👀 Watching for changes to ${pkg.sourcePath} with ${cmd} ${args.join(" ")}`)
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: path.resolve(__dirname, "..", pkg.sourcePath),
|
||||
stdio: "pipe",
|
||||
shell: true,
|
||||
})
|
||||
|
||||
let debounceTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const DEBOUNCE_DELAY = 500
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
const output = data.toString()
|
||||
|
||||
const isStarting = pkg.watchOutput?.start.some((start) => output.includes(start))
|
||||
|
||||
const isDone = pkg.watchOutput?.stop.some((stop) => output.includes(stop))
|
||||
|
||||
if (isStarting) {
|
||||
console.log(`🔨 Building ${pkg.name}...`)
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
console.log(`✅ Built ${pkg.name}`)
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
linkPackage(pkg)
|
||||
|
||||
console.log(`♻️ Copied ${pkg.name} to ${pkg.targetPaths.length} paths\n`)
|
||||
|
||||
debounceTimer = null
|
||||
}, DEBOUNCE_DELAY)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
console.log(`❌ "${data.toString()}"`)
|
||||
})
|
||||
}
|
||||
|
||||
return { child }
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
if (unlink) {
|
||||
packages.forEach(unlinkPackage)
|
||||
|
||||
console.log("\n📦 Restoring npm packages...")
|
||||
|
||||
try {
|
||||
execSync("pnpm install", { cwd: __dirname, stdio: "ignore" })
|
||||
console.log("✅ npm packages restored")
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to restore packages: ${error instanceof Error ? error.message : String(error)}`)
|
||||
|
||||
console.log(" Run 'pnpm install' manually if needed")
|
||||
}
|
||||
} else {
|
||||
packages.forEach((pkg) => {
|
||||
linkPackage(pkg)
|
||||
|
||||
if (pkg.replacePath) {
|
||||
const replacePattern = path.resolve(__dirname, "..", pkg.replacePath)
|
||||
|
||||
try {
|
||||
const matchedPaths = glob.sync(replacePattern)
|
||||
|
||||
if (matchedPaths.length > 0) {
|
||||
matchedPaths.forEach((matchedPath: string) => {
|
||||
if (pathExists(matchedPath)) {
|
||||
fs.rmSync(matchedPath, { recursive: true, force: true })
|
||||
console.log(`🗑️ Removed ${pkg.name} from ${matchedPath}`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (pathExists(replacePattern)) {
|
||||
fs.rmSync(replacePattern, { recursive: true, force: true })
|
||||
console.log(`🗑️ Removed ${pkg.name} from ${replacePattern}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Error processing replace path: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (watchMode) {
|
||||
const packagesWithWatch = packages.filter(
|
||||
(pkg): pkg is PackageConfig & { watchCommand: string } => pkg.watchCommand !== undefined,
|
||||
)
|
||||
|
||||
const watchers = packagesWithWatch.map(startWatch)
|
||||
|
||||
if (watchers.length > 0) {
|
||||
process.on("SIGINT", () => {
|
||||
console.log("\n👋 Stopping watchers...")
|
||||
|
||||
watchers.forEach((w) => {
|
||||
if (w.child) {
|
||||
w.child.kill()
|
||||
}
|
||||
})
|
||||
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { getCommands, getCommand, getCommandNames } from "../services/command/commands"
|
||||
import * as path from "path"
|
||||
|
||||
import { getCommands, getCommand, getCommandNames } from "../services/command/commands"
|
||||
|
||||
describe("Command Integration Tests", () => {
|
||||
const testWorkspaceDir = path.join(__dirname, "../../")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { parseMentions } from "../core/mentions"
|
||||
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
|
||||
import { getCommand } from "../services/command/commands"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
getCommands,
|
||||
getCommand,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { vi } from "vitest"
|
||||
|
||||
// Mock BedrockRuntimeClient and commands
|
||||
const mockSend = vi.fn()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
|
||||
// Mock i18n
|
||||
vi.mock("../../i18n", () => ({
|
||||
t: vi.fn((key: string, params?: Record<string, any>) => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { ClaudeCodeHandler } from "../claude-code"
|
||||
import { runClaudeCode } from "../../../integrations/claude-code/run"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
import type { ClaudeCodeMessage } from "../../../integrations/claude-code/types"
|
||||
import type { ApiStreamUsageChunk } from "../../transform/stream"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
// Mock the runClaudeCode function
|
||||
vi.mock("../../../integrations/claude-code/run", () => ({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, test, expect, vi, beforeEach } from "vitest"
|
||||
import { ClaudeCodeHandler } from "../claude-code"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
import { ClaudeCodeMessage } from "../../../integrations/claude-code/types"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// npx vitest run src/api/providers/__tests__/constants.spec.ts
|
||||
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { DEFAULT_HEADERS } from "../constants"
|
||||
import { Package } from "../../../shared/package"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
import { t } from "i18next"
|
||||
|
||||
import { GeminiHandler } from "../gemini"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { IOIntelligenceHandler } from "../io-intelligence"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
const mockCreate = vi.fn()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import OpenAI from "openai"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,6 @@ vitest.mock("../../../i18n", () => ({
|
|||
// Import after mocks are set up
|
||||
import { RooHandler } from "../roo"
|
||||
import { CloudService } from "@roo-code/cloud"
|
||||
import { t } from "../../../i18n"
|
||||
|
||||
describe("RooHandler", () => {
|
||||
let handler: RooHandler
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import axios from "axios"
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest"
|
||||
import { LMStudioClient, LLM, LLMInstanceInfo, LLMInfo } from "@lmstudio/sdk"
|
||||
import { LMStudioClient, LLMInstanceInfo, LLMInfo } from "@lmstudio/sdk"
|
||||
|
||||
import { ModelInfo, lMStudioDefaultModelInfo } from "@roo-code/types"
|
||||
|
||||
import { getLMStudioModels, parseLMStudioModel } from "../lmstudio"
|
||||
import { ModelInfo, lMStudioDefaultModelInfo } from "@roo-code/types" // ModelInfo is a type
|
||||
|
||||
// Mock axios
|
||||
vi.mock("axios")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import axios from "axios"
|
||||
import path from "path"
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest"
|
||||
|
||||
import { getOllamaModels, parseOllamaModel } from "../ollama"
|
||||
import ollamaModelsData from "./fixtures/ollama-model-details.json"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { rooDefaultModelId, rooModels, type RooModelId } from "@roo-code/types"
|
||||
import { CloudService } from "@roo-code/cloud"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
// npx vitest src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import { AssistantMessageParser } from "../AssistantMessageParser"
|
||||
import { AssistantMessageContent } from "../parseAssistantMessage"
|
||||
import { TextContent, ToolUse } from "../../../shared/tools"
|
||||
import { toolNames } from "@roo-code/types"
|
||||
|
||||
/**
|
||||
* Helper to filter out empty text content blocks.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
import { APIError } from "openai"
|
||||
|
||||
import { checkContextWindowExceededError } from "../context-error-handling"
|
||||
|
||||
describe("checkContextWindowExceededError", () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
// npx vitest core/mentions/__tests__/index.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import { parseMentions } from "../index"
|
||||
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
|
||||
import { t } from "../../../i18n"
|
||||
|
||||
// Mock vscode
|
||||
vi.mock("vscode", () => ({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// npx vitest core/mentions/__tests__/processUserContentMentions.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { processUserContentMentions } from "../processUserContentMentions"
|
||||
import { parseMentions } from "../index"
|
||||
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { getPromptComponent } from "../system"
|
||||
import type { CustomModePrompts } from "@roo-code/types"
|
||||
|
||||
import { getPromptComponent } from "../system"
|
||||
|
||||
describe("getPromptComponent", () => {
|
||||
it("should return undefined for empty objects", () => {
|
||||
const customModePrompts: CustomModePrompts = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import * as path from "path"
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
||||
|
||||
// Use vi.hoisted to ensure mocks are available during hoisting
|
||||
const { mockHomedir, mockStat, mockReadFile, mockReaddir, mockGetRooDirectoriesForCwd, mockGetGlobalRooDirectory } =
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
|
||||
describe("custom-instructions path detection", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { getFetchInstructionsDescription } from "../fetch-instructions"
|
||||
|
||||
describe("getFetchInstructionsDescription", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { getNewTaskDescription } from "../new-task"
|
||||
import { ToolArgs } from "../types"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { AutoApprovalHandler } from "../AutoApprovalHandler"
|
||||
import { GlobalState, ClineMessage } from "@roo-code/types"
|
||||
|
||||
import { AutoApprovalHandler } from "../AutoApprovalHandler"
|
||||
|
||||
// Mock getApiMetrics
|
||||
vi.mock("../../../shared/getApiMetrics", () => ({
|
||||
getApiMetrics: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ProviderSettings } from "@roo-code/types"
|
||||
|
||||
import { Task } from "../Task"
|
||||
import { ClineProvider } from "../../webview/ClineProvider"
|
||||
import { ProviderSettings } from "@roo-code/types"
|
||||
import { vi, describe, test, expect, beforeEach, afterEach } from "vitest"
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../webview/ClineProvider")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
import { askFollowupQuestionTool } from "../askFollowupQuestionTool"
|
||||
import { ToolUse } from "../../../shared/tools"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { TodoItem } from "@roo-code/types"
|
||||
|
||||
import { AttemptCompletionToolUse } from "../../../shared/tools"
|
||||
|
||||
// Mock the formatResponse module before importing the tool
|
||||
|
|
|
|||
|
|
@ -29,16 +29,17 @@ import {
|
|||
type TerminalActionId,
|
||||
type TerminalActionPromptType,
|
||||
type HistoryItem,
|
||||
type ClineAsk,
|
||||
type CloudUserInfo,
|
||||
RooCodeEventName,
|
||||
requestyDefaultModelId,
|
||||
openRouterDefaultModelId,
|
||||
glamaDefaultModelId,
|
||||
DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
||||
DEFAULT_WRITE_DELAY_MS,
|
||||
ORGANIZATION_ALLOW_ALL,
|
||||
} from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { type CloudUserInfo, CloudService, ORGANIZATION_ALLOW_ALL, getRooCodeApiUrl } from "@roo-code/cloud"
|
||||
import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud"
|
||||
|
||||
import { Package } from "../../shared/package"
|
||||
import { findLast } from "../../shared/array"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import Anthropic from "@anthropic-ai/sdk"
|
|||
import * as vscode from "vscode"
|
||||
import axios from "axios"
|
||||
|
||||
import { type ProviderSettingsEntry, type ClineMessage } from "@roo-code/types"
|
||||
import { type ProviderSettingsEntry, type ClineMessage, ORGANIZATION_ALLOW_ALL } from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { ORGANIZATION_ALLOW_ALL } from "@roo-code/cloud"
|
||||
|
||||
import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
|
||||
import { defaultModeSlug } from "../../../shared/modes"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { MessageEnhancer } from "../messageEnhancer"
|
||||
import { ProviderSettings, ClineMessage } from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { MessageEnhancer } from "../messageEnhancer"
|
||||
import * as singleCompletionHandlerModule from "../../../utils/single-completion-handler"
|
||||
import { ProviderSettingsManager } from "../../config/ProviderSettingsManager"
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "@roo-code/types"
|
||||
import { CloudService } from "@roo-code/cloud"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { type ApiMessage } from "../task-persistence/apiMessages"
|
||||
|
||||
import { ClineProvider } from "./ClineProvider"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ try {
|
|||
console.warn("Failed to load environment variables:", e)
|
||||
}
|
||||
|
||||
import { CloudService, ExtensionBridgeService, type CloudUserInfo } from "@roo-code/cloud"
|
||||
import type { CloudUserInfo } from "@roo-code/types"
|
||||
import { CloudService, ExtensionBridgeService } from "@roo-code/cloud"
|
||||
import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry"
|
||||
|
||||
import "./utils/path" // Necessary to have access to String.prototype.toPosix.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, test, expect } from "vitest"
|
||||
import { filterMessagesForClaudeCode } from "../message-filter"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { filterMessagesForClaudeCode } from "../message-filter"
|
||||
|
||||
describe("filterMessagesForClaudeCode", () => {
|
||||
test("should pass through string messages unchanged", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
|
||||
// Mock i18n system
|
||||
vi.mock("../../i18n", () => ({
|
||||
t: vi.fn((key: string, options?: Record<string, any>) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// npx vitest run integrations/misc/__tests__/extract-text-large-files.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from "vitest"
|
||||
import * as fs from "fs/promises"
|
||||
|
||||
import { extractTextFromFile } from "../extract-text"
|
||||
import { countFileLines } from "../line-counter"
|
||||
import { readLines } from "../read-lines"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
|
||||
import { openFile } from "../open-file"
|
||||
|
||||
// Mock vscode module
|
||||
|
|
|
|||
|
|
@ -427,13 +427,12 @@
|
|||
"@google/genai": "^1.0.0",
|
||||
"@lmstudio/sdk": "^1.1.1",
|
||||
"@mistralai/mistralai": "^1.9.18",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@modelcontextprotocol/sdk": "1.12.0",
|
||||
"@qdrant/js-client-rest": "^1.14.0",
|
||||
"@roo-code/cloud": "^0.29.0",
|
||||
"@roo-code/cloud": "workspace:^",
|
||||
"@roo-code/ipc": "workspace:^",
|
||||
"@roo-code/telemetry": "workspace:^",
|
||||
"@roo-code/types": "workspace:^",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"async-mutex": "^0.5.0",
|
||||
"axios": "^1.7.4",
|
||||
|
|
@ -504,6 +503,7 @@
|
|||
"@types/diff": "^5.2.1",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/glob": "^8.1.0",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "20.x",
|
||||
"@types/node-cache": "^4.1.3",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
// npx vitest services/browser/__tests__/BrowserSession.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { BrowserSession } from "../BrowserSession"
|
||||
import { discoverChromeHostUrl, tryChromeHostUrl } from "../browserDiscovery"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("vscode", () => ({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
// npx vitest services/browser/__tests__/UrlContentFetcher.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { UrlContentFetcher } from "../UrlContentFetcher"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
import * as path from "path"
|
||||
|
||||
import { UrlContentFetcher } from "../UrlContentFetcher"
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("vscode", () => ({
|
||||
ExtensionContext: vi.fn(),
|
||||
|
|
@ -128,8 +127,8 @@ describe("UrlContentFetcher", () => {
|
|||
it("should launch browser with correct arguments on non-Linux platforms", async () => {
|
||||
// Ensure we're not on Linux for this test
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin' // macOS
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "darwin", // macOS
|
||||
})
|
||||
|
||||
try {
|
||||
|
|
@ -153,8 +152,8 @@ describe("UrlContentFetcher", () => {
|
|||
})
|
||||
} finally {
|
||||
// Restore original platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -162,8 +161,8 @@ describe("UrlContentFetcher", () => {
|
|||
it("should launch browser with Linux-specific arguments", async () => {
|
||||
// Mock process.platform to be linux
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux'
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "linux",
|
||||
})
|
||||
|
||||
try {
|
||||
|
|
@ -190,8 +189,8 @@ describe("UrlContentFetcher", () => {
|
|||
})
|
||||
} finally {
|
||||
// Restore original platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
// npx vitest services/code-index/__tests__/config-manager.spec.ts
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { CodeIndexConfigManager } from "../config-manager"
|
||||
import { ContextProxy } from "../../../core/config/ContextProxy"
|
||||
import { PreviousConfigSnapshot } from "../interfaces/config"
|
||||
|
||||
// Mock ContextProxy
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { vitest, describe, it, expect, beforeEach } from "vitest"
|
||||
import type { MockedClass } from "vitest"
|
||||
|
||||
import { MistralEmbedder } from "../mistral"
|
||||
import { OpenAICompatibleEmbedder } from "../openai-compatible"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { vitest, describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
import type { MockedFunction } from "vitest"
|
||||
|
||||
import { CodeIndexOllamaEmbedder } from "../ollama"
|
||||
|
||||
// Mock fetch
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach, MockedClass, MockedFunction } from "vitest"
|
||||
import type { MockedClass, MockedFunction } from "vitest"
|
||||
import { OpenAI } from "openai"
|
||||
|
||||
import { OpenAICompatibleEmbedder } from "../openai-compatible"
|
||||
|
||||
// Mock the OpenAI SDK
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { vitest, describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
import type { MockedClass, MockedFunction } from "vitest"
|
||||
import { OpenAI } from "openai"
|
||||
|
||||
import { OpenAiEmbedder } from "../openai"
|
||||
import { MAX_BATCH_TOKENS, MAX_ITEM_TOKENS, MAX_BATCH_RETRIES, INITIAL_RETRY_DELAY_MS } from "../../constants"
|
||||
import { MAX_ITEM_TOKENS, INITIAL_RETRY_DELAY_MS } from "../../constants"
|
||||
|
||||
// Mock the OpenAI SDK
|
||||
vitest.mock("openai")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// npx vitest run src/services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { VercelAiGatewayEmbedder } from "../vercel-ai-gateway"
|
||||
import { OpenAICompatibleEmbedder } from "../openai-compatible"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { CodeParser } from "../parser"
|
||||
import * as path from "path"
|
||||
|
||||
// Mock TelemetryService
|
||||
vi.mock("../../../../../packages/telemetry/src/TelemetryService", () => ({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import path from "path"
|
||||
|
||||
import { generateNormalizedAbsolutePath, generateRelativeFilePath } from "../get-relative-path"
|
||||
|
||||
describe("get-relative-path", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { sanitizeErrorMessage } from "../validation-helpers"
|
||||
|
||||
describe("sanitizeErrorMessage", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { getBuiltInCommands, getBuiltInCommand, getBuiltInCommandNames } from "../built-in-commands"
|
||||
|
||||
describe("Built-in Commands", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
import { getCommand, getCommands } from "../commands"
|
||||
|
||||
// Mock fs and path modules
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { vi, describe, it, expect, beforeEach } from "vitest"
|
||||
import * as path from "path"
|
||||
import { listFiles } from "../list-files"
|
||||
import * as childProcess from "child_process"
|
||||
import { listFiles } from "../list-files"
|
||||
|
||||
vi.mock("child_process")
|
||||
vi.mock("fs")
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import * as path from "path"
|
|||
import * as vscode from "vscode"
|
||||
import * as yaml from "yaml"
|
||||
|
||||
import type { MarketplaceItem, MarketplaceItemType, McpMarketplaceItem } from "@roo-code/types"
|
||||
import type { OrganizationSettings, MarketplaceItem, MarketplaceItemType, McpMarketplaceItem } from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { type OrganizationSettings, CloudService } from "@roo-code/cloud"
|
||||
import { CloudService } from "@roo-code/cloud"
|
||||
|
||||
import { GlobalFileNames } from "../../shared/globalFileNames"
|
||||
import { ensureSettingsDirectoryExists } from "../../utils/globalContext"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue