Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
76 changes: 76 additions & 0 deletions packages/cloud/src/ShareService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import axios from "axios"
import * as vscode from "vscode"

import { shareResponseSchema } 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 {
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 = shareResponseSchema.parse(response.data)
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
Loading