Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/cloud/src/AuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { CloudUserInfo } from "@roo-code/types"

import { getClerkBaseUrl, getRooCodeApiUrl } from "./Config"
import { RefreshTimer } from "./RefreshTimer"
import { getUserAgent } from "./utils"

export interface AuthServiceEvents {
"inactive-session": [data: { previousState: AuthState }]
Expand Down Expand Up @@ -435,7 +436,7 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
}

private userAgent(): string {
return `Roo-Code ${this.context.extension?.packageJSON?.version}`
return getUserAgent(this.context)
}

private static _instance: AuthService | null = null
Expand Down
24 changes: 23 additions & 1 deletion packages/cloud/src/CloudService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CloudServiceCallbacks } from "./types"
import { AuthService } from "./AuthService"
import { SettingsService } from "./SettingsService"
import { TelemetryClient } from "./TelemetryClient"
import { ShareService } from "./ShareService"

export class CloudService {
private static _instance: CloudService | null = null
Expand All @@ -17,6 +18,7 @@ export class CloudService {
private authService: AuthService | null = null
private settingsService: SettingsService | null = null
private telemetryClient: TelemetryClient | null = null
private shareService: ShareService | null = null
private isInitialized = false
private log: (...args: unknown[]) => void

Expand Down Expand Up @@ -48,6 +50,8 @@ export class CloudService {

this.telemetryClient = new TelemetryClient(this.authService, this.settingsService)

this.shareService = new ShareService(this.authService, this.settingsService, this.log)

try {
TelemetryService.instance.register(this.telemetryClient)
} catch (error) {
Expand Down Expand Up @@ -112,6 +116,18 @@ export class CloudService {
this.telemetryClient!.capture(event)
}

// ShareService

public async shareTask(taskId: string): Promise<boolean> {
this.ensureInitialized()
return this.shareService!.shareTask(taskId)
}

public async canShareTask(): Promise<boolean> {
this.ensureInitialized()
return this.shareService!.canShareTask()
}

// Lifecycle

public dispose(): void {
Expand All @@ -128,7 +144,13 @@ export class CloudService {
}

private ensureInitialized(): void {
if (!this.isInitialized || !this.authService || !this.settingsService || !this.telemetryClient) {
if (
!this.isInitialized ||
!this.authService ||
!this.settingsService ||
!this.telemetryClient ||
!this.shareService
) {
throw new Error("CloudService not initialized.")
}
}
Expand Down
80 changes: 80 additions & 0 deletions packages/cloud/src/ShareService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import axios from "axios"
import * as vscode from "vscode"

import type { ShareResponse } from "@roo-code/types"
import { getRooCodeApiUrl } from "./Config"
import type { AuthService } from "./AuthService"
import type { SettingsService } from "./SettingsService"
import { getUserAgent } from "./utils"

export class ShareService {
private authService: AuthService
private settingsService: SettingsService
private log: (...args: unknown[]) => void

constructor(authService: AuthService, settingsService: SettingsService, log?: (...args: unknown[]) => void) {
this.authService = authService
this.settingsService = settingsService
this.log = log || console.log
}

/**
* Share a task: Create link and copy to clipboard
* Returns true if successful, false if failed
*/
async shareTask(taskId: string): Promise<boolean> {
try {
if (!this.authService.hasActiveSession()) {
return false
}

const sessionToken = this.authService.getSessionToken()
if (!sessionToken) {
return false
}

const response = await axios.post(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might consider using fetch instead of axios. I have an incomplete cleanup task to remove axios from AuthService that I keep not getting to a clean state.

Main motivation (other than not using a library unless we have to): as far as I can tell you can do a better job of setting timeouts with fetch than axios, which is something we should start doing for most of these requests (another cleanup task I haven't gotten to yet). Hopefully those will be better examples for Roo too, although maybe that's too optimistic.

That would mean you'd need to explicitly check the status & fail on non-2xx, since that behavior's different between fetch and axios.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of changing all of the axioses to fetches as a follow up and adding a rules file with instructions to use fetch instead going forward.

`${getRooCodeApiUrl()}/api/extension/share`,
{ taskId },
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${sessionToken}`,
"User-Agent": getUserAgent(),
},
},
)

const data = response.data as ShareResponse
this.log("[share] Share link created successfully:", data)

if (data.success && data.shareUrl) {
// Copy to clipboard
await vscode.env.clipboard.writeText(data.shareUrl)
return true
} else {
this.log("[share] Share failed:", data.error)
return false
}
} catch (error) {
this.log("[share] Error sharing task:", error)
return false
}
}

/**
* Check if sharing is available
*/
async canShareTask(): Promise<boolean> {
try {
if (!this.authService.isAuthenticated()) {
return false
}

return !!this.settingsService.getSettings()?.cloudSettings?.enableTaskSharing
} catch (error) {
this.log("[share] Error checking if task can be shared:", error)
return false
}
}
}
224 changes: 224 additions & 0 deletions packages/cloud/src/__tests__/ShareService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi, type MockedFunction } from "vitest"
import axios from "axios"
import * as vscode from "vscode"

import { ShareService } from "../ShareService"
import type { AuthService } from "../AuthService"
import type { SettingsService } from "../SettingsService"

// Mock axios
vi.mock("axios")
const mockedAxios = axios as any

// Mock vscode
vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
},
env: {
clipboard: {
writeText: vi.fn(),
},
openExternal: vi.fn(),
},
Uri: {
parse: vi.fn(),
},
extensions: {
getExtension: vi.fn(() => ({
packageJSON: { version: "1.0.0" },
})),
},
}))

// Mock config
vi.mock("../Config", () => ({
getRooCodeApiUrl: () => "https://app.roocode.com",
}))

// Mock utils
vi.mock("../utils", () => ({
getUserAgent: () => "Roo-Code 1.0.0",
}))

describe("ShareService", () => {
let shareService: ShareService
let mockAuthService: AuthService
let mockSettingsService: SettingsService
let mockLog: MockedFunction<(...args: unknown[]) => void>

beforeEach(() => {
vi.clearAllMocks()

mockLog = vi.fn()
mockAuthService = {
hasActiveSession: vi.fn(),
getSessionToken: vi.fn(),
isAuthenticated: vi.fn(),
} as any

mockSettingsService = {
getSettings: vi.fn(),
} as any

shareService = new ShareService(mockAuthService, mockSettingsService, mockLog)
})

describe("shareTask", () => {
it("should share task and copy to clipboard", async () => {
const mockResponse = {
data: {
success: true,
shareUrl: "https://app.roocode.com/share/abc123",
},
}

;(mockAuthService.hasActiveSession as any).mockReturnValue(true)
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
mockedAxios.post.mockResolvedValue(mockResponse)

const result = await shareService.shareTask("task-123")

expect(result).toBe(true)
expect(mockedAxios.post).toHaveBeenCalledWith(
"https://app.roocode.com/api/extension/share",
{ taskId: "task-123" },
{
headers: {
"Content-Type": "application/json",
Authorization: "Bearer session-token",
"User-Agent": "Roo-Code 1.0.0",
},
},
)
expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith("https://app.roocode.com/share/abc123")
})

it("should handle API error response", async () => {
const mockResponse = {
data: {
success: false,
error: "Task not found",
},
}

;(mockAuthService.hasActiveSession as any).mockReturnValue(true)
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
mockedAxios.post.mockResolvedValue(mockResponse)

const result = await shareService.shareTask("task-123")

expect(result).toBe(false)
})

it("should handle authentication errors", async () => {
;(mockAuthService.hasActiveSession as any).mockReturnValue(false)

const result = await shareService.shareTask("task-123")

expect(result).toBe(false)
expect(mockedAxios.post).not.toHaveBeenCalled()
})

it("should handle 403 error for disabled sharing", async () => {
;(mockAuthService.hasActiveSession as any).mockReturnValue(true)
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")

const error = {
isAxiosError: true,
response: {
status: 403,
data: {
error: "Task sharing is not enabled for this organization",
},
},
}

mockedAxios.isAxiosError.mockReturnValue(true)
mockedAxios.post.mockRejectedValue(error)

const result = await shareService.shareTask("task-123")

expect(result).toBe(false)
})

it("should handle unexpected errors", async () => {
;(mockAuthService.hasActiveSession as any).mockReturnValue(true)
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")

mockedAxios.post.mockRejectedValue(new Error("Network error"))

const result = await shareService.shareTask("task-123")

expect(result).toBe(false)
})
})

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 not authenticated", async () => {
;(mockAuthService.isAuthenticated as any).mockReturnValue(false)

const result = await shareService.canShareTask()

expect(result).toBe(false)
})

it("should handle errors gracefully", async () => {
;(mockAuthService.isAuthenticated as any).mockImplementation(() => {
throw new Error("Auth error")
})

const result = await shareService.canShareTask()

expect(result).toBe(false)
})
})
})
10 changes: 10 additions & 0 deletions packages/cloud/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as vscode from "vscode"

/**
* Get the User-Agent string for API requests
* @param context Optional extension context for more accurate version detection
* @returns User-Agent string in format "Roo-Code {version}"
*/
export function getUserAgent(context?: vscode.ExtensionContext): string {
return `Roo-Code ${context?.extension?.packageJSON?.version || "unknown"}`
}
Loading