mirror of
https://github.com/cline/cline.git
synced 2026-09-14 02:29:17 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09c2579a69 | ||
|
|
45a1e9ea6a | ||
|
|
693f725ed5 | ||
|
|
5147b5c218 | ||
|
|
ac22d898b2 | ||
|
|
0a8a47c3d9 | ||
|
|
698598996f | ||
|
|
015ecd6314 | ||
|
|
28f09bbea5 | ||
|
|
9ff63b2ccc |
@@ -16,6 +16,12 @@ service FileService {
|
||||
// Opens a file in the editor
|
||||
rpc openFile(StringRequest) returns (Empty);
|
||||
|
||||
// Opens the current log file in the editor
|
||||
rpc openLogFile(EmptyRequest) returns (Empty);
|
||||
|
||||
// Opens the logs folder in the OS file manager
|
||||
rpc openLogsFolder(EmptyRequest) returns (Empty);
|
||||
|
||||
// Opens an image in the system viewer
|
||||
rpc openImage(StringRequest) returns (Empty);
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ service WindowService {
|
||||
// Opens a file in the IDE.
|
||||
rpc openFile(OpenFileRequest) returns (OpenFileResponse);
|
||||
|
||||
// Reveals a file or folder in the OS file manager (Finder, Explorer, etc.)
|
||||
rpc revealInFileManager(RevealInFileManagerRequest) returns (RevealInFileManagerResponse);
|
||||
|
||||
// Opens the host settings UI, optionally focusing a specific query/section.
|
||||
rpc openSettings(OpenSettingsRequest) returns (OpenSettingsResponse);
|
||||
|
||||
@@ -130,6 +133,12 @@ message OpenFileRequest {
|
||||
|
||||
message OpenFileResponse {}
|
||||
|
||||
message RevealInFileManagerRequest {
|
||||
string file_path = 1;
|
||||
}
|
||||
|
||||
message RevealInFileManagerResponse {}
|
||||
|
||||
message OpenSettingsRequest {
|
||||
// Optional query to focus a particular settings section/key.
|
||||
// This value is host-specific. In VS Code, it is passed directly as the
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
|
||||
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Opens the current log file in the editor.
|
||||
* VS Code mode: Opens the log file from global storage.
|
||||
* Standalone mode: Does nothing (logs are managed by parent process).
|
||||
*/
|
||||
export async function openLogFile(_controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
const logPath = Logger.ensureLogFileAndGetPath()
|
||||
if (logPath) {
|
||||
await openFileIntegration(logPath)
|
||||
}
|
||||
return Empty.create()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { getVSCodeLogsDir } from "@/services/logging/constants"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Opens the centralized logs folder in the OS file manager (Finder, Explorer, etc.)
|
||||
* Opens ~/.cline/logs/ so users can see all Cline logs (VS Code, CLI, etc.)
|
||||
* @param controller The controller instance
|
||||
* @param request Empty request
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function openLogsFolder(_controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
// Open the centralized logs directory where all Cline logs are stored
|
||||
const logsDir = getVSCodeLogsDir()
|
||||
|
||||
await HostProvider.window.revealInFileManager({
|
||||
filePath: logsDir,
|
||||
})
|
||||
|
||||
return Empty.create()
|
||||
}
|
||||
@@ -2597,7 +2597,7 @@ export class Task {
|
||||
|
||||
// present content to user - we don't want the stream to break if present fails, so we catch errors here
|
||||
await this.presentAssistantMessage().catch((error) =>
|
||||
Logger.debug("[Task] Failed to present message: " + error),
|
||||
Logger.error("[Task] Failed to present message: " + error),
|
||||
)
|
||||
|
||||
if (this.taskState.abort) {
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("WorkspaceResolver", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resolver = new WorkspaceResolver()
|
||||
loggerStub = sinon.stub(Logger, "debug")
|
||||
loggerStub = sinon.stub(Logger, "log")
|
||||
originalEnv = process.env.MULTI_ROOT_TRACE
|
||||
})
|
||||
|
||||
|
||||
@@ -486,6 +486,9 @@ export async function deactivate() {
|
||||
|
||||
tearDown()
|
||||
|
||||
// Clean up file logging resources
|
||||
Logger.cleanup()
|
||||
|
||||
// Clean up test mode
|
||||
cleanupTestMode()
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RevealInFileManagerRequest, RevealInFileManagerResponse } from "@shared/proto/host/window"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
/**
|
||||
* Reveals a file or folder in the OS file manager (Finder, Explorer, etc.)
|
||||
*/
|
||||
export async function revealInFileManager(request: RevealInFileManagerRequest): Promise<RevealInFileManagerResponse> {
|
||||
await vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(request.filePath))
|
||||
return RevealInFileManagerResponse.create()
|
||||
}
|
||||
@@ -43,7 +43,6 @@ export class AudioRecordingService {
|
||||
if (this.outputFile && fs.existsSync(this.outputFile)) {
|
||||
try {
|
||||
fs.unlinkSync(this.outputFile)
|
||||
Logger.info("Temporary audio file cleaned up")
|
||||
} catch (error) {
|
||||
Logger.warn("Failed to cleanup temporary audio file: " + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
@@ -60,7 +59,6 @@ export class AudioRecordingService {
|
||||
return
|
||||
}
|
||||
|
||||
Logger.info("Terminating recording process...")
|
||||
this.recordingProcess.kill("SIGINT")
|
||||
|
||||
// Wait for the process to finish with timeout
|
||||
@@ -72,7 +70,6 @@ export class AudioRecordingService {
|
||||
|
||||
this.recordingProcess?.on("exit", (code) => {
|
||||
clearTimeout(timeoutId)
|
||||
Logger.info(`Recording process exited with code: ${code}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
@@ -96,7 +93,6 @@ export class AudioRecordingService {
|
||||
try {
|
||||
// Defensive cleanup before starting - ensures clean state
|
||||
if (this.recordingProcess || this.outputFile) {
|
||||
Logger.info("Performing pre-recording cleanup of stale resources...")
|
||||
await this.performCleanup()
|
||||
}
|
||||
|
||||
@@ -114,14 +110,11 @@ export class AudioRecordingService {
|
||||
const tempDir = os.tmpdir()
|
||||
this.outputFile = path.join(tempDir, `cline_recording_${Date.now()}.webm`)
|
||||
|
||||
Logger.info("Starting audio recording...")
|
||||
|
||||
// Get the recording program path
|
||||
const recordProgram = this.getRecordProgram()
|
||||
if (!recordProgram) {
|
||||
return { success: false, error: "Recording program not found" }
|
||||
}
|
||||
Logger.info(`Using recording program: ${recordProgram.path}`)
|
||||
|
||||
// Set up recording arguments
|
||||
const args = recordProgram.getArgs(this.outputFile)
|
||||
@@ -150,7 +143,6 @@ export class AudioRecordingService {
|
||||
}
|
||||
})
|
||||
|
||||
Logger.info("Audio recording started successfully")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
await this.performCleanup()
|
||||
@@ -166,8 +158,6 @@ export class AudioRecordingService {
|
||||
return { success: false, error: "Not currently recording" }
|
||||
}
|
||||
|
||||
Logger.info("Stopping audio recording...")
|
||||
|
||||
// Terminate the process but keep the file for reading
|
||||
await this.terminateProcess()
|
||||
this.resetRecordingState()
|
||||
@@ -186,7 +176,6 @@ export class AudioRecordingService {
|
||||
// Clean up temporary file after reading
|
||||
await this.cleanupTempFile()
|
||||
|
||||
Logger.info("Audio recording stopped and converted to base64")
|
||||
return { success: true, audioBase64 }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
@@ -205,12 +194,9 @@ export class AudioRecordingService {
|
||||
return { success: false, error: "Not currently recording" }
|
||||
}
|
||||
|
||||
Logger.info("Canceling audio recording...")
|
||||
|
||||
// Perform full cleanup including file deletion
|
||||
await this.performCleanup()
|
||||
|
||||
Logger.info("Audio recording canceled successfully")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -102,7 +102,7 @@ export class VoiceTranscriptionService {
|
||||
|
||||
async transcribeAudio(audioBase64: string, language?: string): Promise<{ text?: string; error?: string }> {
|
||||
try {
|
||||
Logger.info("Transcribing audio with Cline transcription service...")
|
||||
Logger.log("Transcribing audio with Cline transcription service...")
|
||||
|
||||
// Check if using organization account for telemetry
|
||||
const userInfo = await this.clineAccountService.fetchMe()
|
||||
@@ -111,7 +111,7 @@ export class VoiceTranscriptionService {
|
||||
|
||||
const result = await this.clineAccountService.transcribeAudio(audioBase64, language)
|
||||
|
||||
Logger.info("Transcription successful")
|
||||
Logger.log("Transcription successful")
|
||||
|
||||
// Capture telemetry with account type - use dynamic import to avoid circular dependency
|
||||
const { telemetryService } = await import("@/services/telemetry")
|
||||
|
||||
@@ -60,12 +60,12 @@ export class FeatureFlagsProviderFactory {
|
||||
*/
|
||||
class NoOpFeatureFlagsProvider implements IFeatureFlagsProvider {
|
||||
public async getFeatureFlag(flagName: string): Promise<boolean | string | undefined> {
|
||||
Logger.info(`[NoOpFeatureFlagsProvider] getFeatureFlag called with flagName=${flagName}`)
|
||||
Logger.log(`[NoOpFeatureFlagsProvider] getFeatureFlag called with flagName=${flagName}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
public async getFeatureFlagPayload(flagName: string) {
|
||||
Logger.info(`[NoOpFeatureFlagsProvider] getFeatureFlagPayload called with flagName=${flagName}`)
|
||||
Logger.log(`[NoOpFeatureFlagsProvider] getFeatureFlagPayload called with flagName=${flagName}`)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -81,6 +81,6 @@ class NoOpFeatureFlagsProvider implements IFeatureFlagsProvider {
|
||||
}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
Logger.info("[NoOpFeatureFlagsProvider] Disposing")
|
||||
Logger.log("[NoOpFeatureFlagsProvider] Disposing")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,165 @@
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ErrorService } from "../error"
|
||||
import { getVSCodeLogsDir } from "./constants"
|
||||
import { cleanupLogsOlderThan, LOG_RETENTION_MS } from "./retention"
|
||||
import { formatLogFilenameTimestamp, formatLogMessageTimestamp } from "./timestamp"
|
||||
|
||||
/**
|
||||
* Simple logging utility for the extension's backend code.
|
||||
* In VS Code mode, logs to console and a file.
|
||||
* In standalone mode, logs to console (which is redirected to file by the parent process).
|
||||
*/
|
||||
export class Logger {
|
||||
public readonly channelName = "Cline Dev Logger"
|
||||
private static fileStream?: fs.WriteStream
|
||||
private static logFilePath?: string
|
||||
|
||||
/**
|
||||
* Ensures log file is ready for writing. Creates a new log file if needed.
|
||||
* This method is called automatically on first use.
|
||||
*
|
||||
* Note: If the active log file is deleted during the session, the WriteStream may keep writing to
|
||||
* an unlinked file descriptor (so logs stop persisting to disk).
|
||||
*/
|
||||
private static ensureLogFileReady(): void {
|
||||
// Skip file logging in standalone mode - console is redirected by parent process
|
||||
if (process.env.IS_STANDALONE === "true") {
|
||||
return
|
||||
}
|
||||
|
||||
if (Logger.fileStream) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const logsDir = getVSCodeLogsDir()
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true })
|
||||
}
|
||||
|
||||
const timestamp = formatLogFilenameTimestamp()
|
||||
const pid = process.pid
|
||||
const logFileName = `cline-vscode-${timestamp}-${pid}.log`
|
||||
|
||||
Logger.logFilePath = path.join(logsDir, logFileName)
|
||||
Logger.fileStream = fs.createWriteStream(Logger.logFilePath, { flags: "a" })
|
||||
|
||||
void cleanupLogsOlderThan({
|
||||
logsDir,
|
||||
retentionMs: LOG_RETENTION_MS,
|
||||
})
|
||||
|
||||
console.log(`Logger initialized - logs will be written to: ${Logger.logFilePath}`)
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize file logging:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up file logging resources.
|
||||
* Should be called in extension deactivate().
|
||||
*/
|
||||
static cleanup(): void {
|
||||
if (Logger.fileStream) {
|
||||
Logger.fileStream.end()
|
||||
Logger.fileStream = undefined
|
||||
Logger.logFilePath = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the current log file (VS Code mode only)
|
||||
*/
|
||||
static getLogFilePath(): string | undefined {
|
||||
return Logger.logFilePath
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the log file is initialized and returns its path.
|
||||
* This is useful when you need the log file path and want to guarantee it exists.
|
||||
* VS Code mode: Creates the log file if needed and returns the path.
|
||||
* Standalone mode: Returns undefined (logs are managed by parent process).
|
||||
*/
|
||||
static ensureLogFileAndGetPath(): string | undefined {
|
||||
Logger.ensureLogFileReady()
|
||||
return Logger.logFilePath
|
||||
}
|
||||
|
||||
static error(message: string, error?: Error) {
|
||||
Logger.#output("ERROR", message, error)
|
||||
Logger.output("ERROR", message, error)
|
||||
ErrorService.get().logMessage(message, "error")
|
||||
error && ErrorService.get().logException(error)
|
||||
}
|
||||
|
||||
static warn(message: string) {
|
||||
Logger.#output("WARN", message)
|
||||
Logger.output("WARN", message)
|
||||
ErrorService.get().logMessage(message, "warning")
|
||||
}
|
||||
|
||||
static log(message: string) {
|
||||
Logger.#output("LOG", message)
|
||||
Logger.output("LOG", message)
|
||||
}
|
||||
|
||||
static debug(message: string) {
|
||||
Logger.#output("DEBUG", message)
|
||||
Logger.output("DEBUG", message)
|
||||
}
|
||||
|
||||
static info(message: string) {
|
||||
Logger.#output("INFO", message)
|
||||
Logger.output("INFO", message)
|
||||
}
|
||||
static trace(message: string) {
|
||||
Logger.#output("TRACE", message)
|
||||
}
|
||||
static #output(level: string, message: string, error?: Error) {
|
||||
|
||||
private static output(level: string, message: string, error?: Error) {
|
||||
Logger.ensureLogFileReady()
|
||||
|
||||
let fullMessage = message
|
||||
if (error?.message) {
|
||||
fullMessage += ` ${error.message}`
|
||||
}
|
||||
|
||||
// Log to the VS Code output channel
|
||||
HostProvider.get().logToChannel(`${level} ${fullMessage}`)
|
||||
if (error?.stack) {
|
||||
console.log(`Stack trace:\n${error.stack}`)
|
||||
}
|
||||
|
||||
// Pass through to standard output naturally - no formatting
|
||||
switch (level) {
|
||||
case "ERROR":
|
||||
if (error) {
|
||||
console.error(message, error)
|
||||
} else {
|
||||
console.error(message)
|
||||
}
|
||||
break
|
||||
case "WARN":
|
||||
console.warn(message)
|
||||
break
|
||||
case "DEBUG":
|
||||
console.debug(message)
|
||||
break
|
||||
case "INFO":
|
||||
console.info(message)
|
||||
break
|
||||
default:
|
||||
console.log(message)
|
||||
break
|
||||
}
|
||||
|
||||
// If VS Code, format and write to file
|
||||
if (Logger.fileStream) {
|
||||
const timestamp = formatLogMessageTimestamp()
|
||||
const formattedMessage = `[${timestamp}] ${level} ${fullMessage}`
|
||||
|
||||
try {
|
||||
Logger.fileStream.write(formattedMessage + "\n")
|
||||
if (error?.stack) {
|
||||
Logger.fileStream.write(`Stack trace:\n${error.stack}\n`)
|
||||
}
|
||||
} catch (writeError) {
|
||||
console.error("Failed to write to log file:", writeError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
|
||||
import { cleanupLogsOlderThan } from "../retention"
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
return await fs.mkdtemp(path.join(os.tmpdir(), "cline-logs-retention-"))
|
||||
}
|
||||
|
||||
async function touchWithMtime(filePath: string, mtime: Date): Promise<void> {
|
||||
await fs.writeFile(filePath, "test")
|
||||
await fs.utimes(filePath, mtime, mtime)
|
||||
}
|
||||
|
||||
describe("logging retention", () => {
|
||||
it("deletes files older than the retention window", async () => {
|
||||
const dir = await createTempDir()
|
||||
const oldFile = path.join(dir, "old.log")
|
||||
const newFile = path.join(dir, "new.log")
|
||||
|
||||
const now = Date.now()
|
||||
await touchWithMtime(oldFile, new Date(now - 10_000))
|
||||
await touchWithMtime(newFile, new Date(now - 500))
|
||||
|
||||
await cleanupLogsOlderThan({ logsDir: dir, retentionMs: 1_000 })
|
||||
|
||||
await fs.access(newFile)
|
||||
await fs
|
||||
.access(oldFile)
|
||||
.then(() => {
|
||||
throw new Error("expected old file to be deleted")
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
|
||||
it("does not delete the active log file", async () => {
|
||||
const dir = await createTempDir()
|
||||
const recentlyWrittenFile = path.join(dir, "recent.log")
|
||||
const oldFile = path.join(dir, "old.log")
|
||||
|
||||
const now = Date.now()
|
||||
await touchWithMtime(recentlyWrittenFile, new Date(now - 500))
|
||||
await touchWithMtime(oldFile, new Date(now - 10_000))
|
||||
|
||||
await cleanupLogsOlderThan({ logsDir: dir, retentionMs: 1_000 })
|
||||
|
||||
await fs.access(recentlyWrittenFile)
|
||||
await fs
|
||||
.access(oldFile)
|
||||
.then(() => {
|
||||
throw new Error("expected other old file to be deleted")
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
|
||||
it("no-ops when the logs directory does not exist", async () => {
|
||||
const dir = path.join(os.tmpdir(), `cline-logs-retention-missing-${Date.now()}`)
|
||||
await cleanupLogsOlderThan({ logsDir: dir, retentionMs: 1_000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
|
||||
/**
|
||||
* Gets the VS Code logs directory path (~/.cline/logs/vscode)
|
||||
* This is the centralized location for all VS Code extension logs.
|
||||
*/
|
||||
export function getVSCodeLogsDir(): string {
|
||||
return path.join(os.homedir(), ".cline", "logs", "vscode")
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { promises as fs, type Stats } from "fs"
|
||||
import * as path from "path"
|
||||
|
||||
export const LOG_RETENTION_DAYS = 30
|
||||
export const LOG_RETENTION_MS = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
||||
|
||||
export interface CleanupLogsOlderThanOptions {
|
||||
logsDir: string
|
||||
retentionMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunistically removes old log files.
|
||||
*
|
||||
* Deletes regular files in `logsDir` whose `mtime` is older than `retentionMs`.
|
||||
* `mtime` is treated as "last active" (appends update it), which is safe across multiple VS Code windows.
|
||||
*
|
||||
* This function is non-fatal: it swallows filesystem errors (e.g. locked files on Windows,
|
||||
* concurrent writers, files disappearing between stat/unlink) so logging/startup is never impacted.
|
||||
*/
|
||||
export async function cleanupLogsOlderThan(options: CleanupLogsOlderThanOptions): Promise<void> {
|
||||
const { logsDir, retentionMs = LOG_RETENTION_MS } = options
|
||||
|
||||
// If the logs directory doesn't exist, nothing to do.
|
||||
try {
|
||||
await fs.access(logsDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await fs.readdir(logsDir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const cutoffMs = Date.now() - retentionMs
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const filePath = path.join(logsDir, entry)
|
||||
|
||||
let stat: Stats
|
||||
try {
|
||||
stat = await fs.stat(filePath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stat.mtimeMs < cutoffMs) {
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Shared timestamp formatting utilities used across different logging contexts.
|
||||
* This ensures consistent timestamp formats in logs across VS Code, CLI, and standalone modes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats a date into the timestamp format used in log filenames.
|
||||
* Format: YYYY-MM-DDTHH-mm-ss (UTC)
|
||||
* Example: 2025-01-15T14-30-45
|
||||
*/
|
||||
export function formatLogFilenameTimestamp(date: Date = new Date()): string {
|
||||
// Use ISO format and replace colons with dashes for filename compatibility
|
||||
return date.toISOString().replace(/:/g, "-").split(".")[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date into the timestamp format used in log messages.
|
||||
* Format: YYYY-MM-DDTHH:mm:ss.SSSZ (UTC)
|
||||
* Example: 2025-01-15T14:30:45.123Z
|
||||
*/
|
||||
export function formatLogMessageTimestamp(date: Date = new Date()): string {
|
||||
return date.toISOString()
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export class TelemetryProviderFactory {
|
||||
if (meterProvider || loggerProvider) {
|
||||
return await new OpenTelemetryTelemetryProvider().initialize()
|
||||
}
|
||||
Logger.info("TelemetryProviderFactory: OpenTelemetry providers not available")
|
||||
Logger.log("TelemetryProviderFactory: OpenTelemetry providers not available")
|
||||
return new NoOpTelemetryProvider()
|
||||
}
|
||||
case "no-op":
|
||||
@@ -119,10 +119,10 @@ export class NoOpTelemetryProvider implements ITelemetryProvider {
|
||||
Logger.log(`[NoOpTelemetryProvider] REQUIRED ${_event}: ${JSON.stringify(_properties)}`)
|
||||
}
|
||||
identifyUser(_userInfo: any, _properties?: TelemetryProperties): void {
|
||||
Logger.info(`[NoOpTelemetryProvider] identifyUser - ${JSON.stringify(_userInfo)} - ${JSON.stringify(_properties)}`)
|
||||
Logger.log(`[NoOpTelemetryProvider] identifyUser - ${JSON.stringify(_userInfo)} - ${JSON.stringify(_properties)}`)
|
||||
}
|
||||
setOptIn(_optIn: boolean): void {
|
||||
Logger.info(`[NoOpTelemetryProvider] setOptIn(${_optIn})`)
|
||||
Logger.log(`[NoOpTelemetryProvider] setOptIn(${_optIn})`)
|
||||
this.isOptIn = _optIn
|
||||
}
|
||||
isEnabled(): boolean {
|
||||
@@ -163,6 +163,6 @@ export class NoOpTelemetryProvider implements ITelemetryProvider {
|
||||
// no-op
|
||||
}
|
||||
async dispose(): Promise<void> {
|
||||
Logger.info(`[NoOpTelemetryProvider] Disposing (optIn=${this.isOptIn})`)
|
||||
Logger.log(`[NoOpTelemetryProvider] Disposing (optIn=${this.isOptIn})`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("SharedUriHandler", () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock Logger methods to avoid HostProvider dependency
|
||||
sandbox.stub(Logger, "info").returns()
|
||||
sandbox.stub(Logger, "log").returns()
|
||||
sandbox.stub(Logger, "error").returns()
|
||||
// Mock ErrorService to avoid telemetry dependency
|
||||
const mockErrorService = {
|
||||
|
||||
@@ -19,7 +19,7 @@ export class SharedUriHandler {
|
||||
const queryString = parsedUrl.search.slice(1) // Remove leading '?'
|
||||
const query = new URLSearchParams(queryString.replace(/\+/g, "%2B"))
|
||||
|
||||
Logger.info(
|
||||
Logger.log(
|
||||
"SharedUriHandler: Processing URI:" +
|
||||
JSON.stringify({
|
||||
path: path,
|
||||
@@ -58,7 +58,7 @@ export class SharedUriHandler {
|
||||
case "/auth": {
|
||||
const provider = query.get("provider")
|
||||
|
||||
Logger.info(`SharedUriHandler - Auth callback received for ${provider} - ${path}`)
|
||||
Logger.log(`SharedUriHandler - Auth callback received for ${provider} - ${path}`)
|
||||
|
||||
const token = query.get("refreshToken") || query.get("idToken") || query.get("code")
|
||||
if (token) {
|
||||
|
||||
+2
-11
@@ -2,19 +2,10 @@ import * as protoLoader from "@grpc/proto-loader"
|
||||
import * as fs from "fs"
|
||||
import * as health from "grpc-health-check"
|
||||
import { StreamingCallbacks } from "@/hosts/host-provider-types"
|
||||
import { formatLogMessageTimestamp } from "@/services/logging/timestamp"
|
||||
|
||||
const log = (...args: unknown[]) => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(now.getDate()).padStart(2, "0")
|
||||
const hours = String(now.getHours()).padStart(2, "0")
|
||||
const minutes = String(now.getMinutes()).padStart(2, "0")
|
||||
const seconds = String(now.getSeconds()).padStart(2, "0")
|
||||
const milliseconds = String(now.getMilliseconds()).padStart(3, "0")
|
||||
|
||||
const timestamp = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}`
|
||||
|
||||
const timestamp = formatLogMessageTimestamp()
|
||||
console.log(`[${timestamp}]`, "#bot.cline.server.ts", ...args)
|
||||
}
|
||||
|
||||
|
||||
+217
-345
@@ -1,407 +1,279 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha";
|
||||
import "should";
|
||||
import * as sinon from "sinon";
|
||||
import { Controller } from "../core/controller";
|
||||
import { getAvailableSlashCommands } from "../core/controller/slash/getAvailableSlashCommands";
|
||||
import { EmptyRequest } from "../shared/proto/cline/common";
|
||||
import { BASE_SLASH_COMMANDS } from "../shared/slashCommands";
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as sinon from "sinon"
|
||||
import { Controller } from "../core/controller"
|
||||
import { getAvailableSlashCommands } from "../core/controller/slash/getAvailableSlashCommands"
|
||||
import { EmptyRequest } from "../shared/proto/cline/common"
|
||||
import { BASE_SLASH_COMMANDS } from "../shared/slashCommands"
|
||||
|
||||
/**
|
||||
* Unit tests for getAvailableSlashCommands RPC endpoint
|
||||
* Tests the slash command discovery and filtering functionality
|
||||
*/
|
||||
describe("getAvailableSlashCommands", () => {
|
||||
let mockController: Partial<Controller>;
|
||||
let mockStateManager: {
|
||||
getWorkspaceStateKey: sinon.SinonStub;
|
||||
getGlobalSettingsKey: sinon.SinonStub;
|
||||
getGlobalStateKey: sinon.SinonStub;
|
||||
getRemoteConfigSettings: sinon.SinonStub;
|
||||
};
|
||||
let mockController: Partial<Controller>
|
||||
let mockStateManager: {
|
||||
getWorkspaceStateKey: sinon.SinonStub
|
||||
getGlobalSettingsKey: sinon.SinonStub
|
||||
getGlobalStateKey: sinon.SinonStub
|
||||
getRemoteConfigSettings: sinon.SinonStub
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockStateManager = {
|
||||
getWorkspaceStateKey: sinon.stub(),
|
||||
getGlobalSettingsKey: sinon.stub(),
|
||||
getGlobalStateKey: sinon.stub(),
|
||||
getRemoteConfigSettings: sinon.stub(),
|
||||
};
|
||||
beforeEach(() => {
|
||||
mockStateManager = {
|
||||
getWorkspaceStateKey: sinon.stub(),
|
||||
getGlobalSettingsKey: sinon.stub(),
|
||||
getGlobalStateKey: sinon.stub(),
|
||||
getRemoteConfigSettings: sinon.stub(),
|
||||
}
|
||||
|
||||
// Default stubs return empty/null values
|
||||
mockStateManager.getWorkspaceStateKey.returns(null);
|
||||
mockStateManager.getGlobalSettingsKey.returns(null);
|
||||
mockStateManager.getGlobalStateKey.returns(null);
|
||||
mockStateManager.getRemoteConfigSettings.returns(null);
|
||||
// Default stubs return empty/null values
|
||||
mockStateManager.getWorkspaceStateKey.returns(null)
|
||||
mockStateManager.getGlobalSettingsKey.returns(null)
|
||||
mockStateManager.getGlobalStateKey.returns(null)
|
||||
mockStateManager.getRemoteConfigSettings.returns(null)
|
||||
|
||||
mockController = {
|
||||
stateManager: mockStateManager as any,
|
||||
};
|
||||
});
|
||||
mockController = {
|
||||
stateManager: mockStateManager as any,
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("Base Slash Commands", () => {
|
||||
it("should return all base slash commands", async () => {
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
describe("Base Slash Commands", () => {
|
||||
it("should return all base slash commands", async () => {
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should have at least all base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(
|
||||
BASE_SLASH_COMMANDS.length
|
||||
);
|
||||
// Should have at least all base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
|
||||
|
||||
// Verify each base command is present
|
||||
for (const baseCmd of BASE_SLASH_COMMANDS) {
|
||||
const found = response.commands.find(
|
||||
(cmd) => cmd.name === baseCmd.name
|
||||
);
|
||||
found!.should.not.be.undefined();
|
||||
found!.description.should.equal(baseCmd.description);
|
||||
found!.section.should.equal("default");
|
||||
found!.cliCompatible.should.equal(baseCmd.cliCompatible ?? false);
|
||||
}
|
||||
});
|
||||
// Verify each base command is present
|
||||
for (const baseCmd of BASE_SLASH_COMMANDS) {
|
||||
const found = response.commands.find((cmd) => cmd.name === baseCmd.name)
|
||||
found!.should.not.be.undefined()
|
||||
found!.description.should.equal(baseCmd.description)
|
||||
found!.section.should.equal("default")
|
||||
found!.cliCompatible.should.equal(baseCmd.cliCompatible ?? false)
|
||||
}
|
||||
})
|
||||
|
||||
it("should mark base commands with section 'default'", async () => {
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
it("should mark base commands with section 'default'", async () => {
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const baseCommandNames = BASE_SLASH_COMMANDS.map((cmd) => cmd.name);
|
||||
for (const cmd of response.commands) {
|
||||
if (baseCommandNames.includes(cmd.name)) {
|
||||
cmd.section.should.equal("default");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const baseCommandNames = BASE_SLASH_COMMANDS.map((cmd) => cmd.name)
|
||||
for (const cmd of response.commands) {
|
||||
if (baseCommandNames.includes(cmd.name)) {
|
||||
cmd.section.should.equal("default")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Local Workflow Toggles", () => {
|
||||
it("should include enabled local workflows", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"/path/to/my-workflow.md": true,
|
||||
"/path/to/another-workflow.md": true,
|
||||
});
|
||||
describe("Local Workflow Toggles", () => {
|
||||
it("should include enabled local workflows", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/path/to/my-workflow.md": true,
|
||||
"/path/to/another-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const myWorkflow = response.commands.find(
|
||||
(cmd) => cmd.name === "my-workflow.md"
|
||||
);
|
||||
myWorkflow!.should.not.be.undefined();
|
||||
myWorkflow!.section.should.equal("custom");
|
||||
myWorkflow!.cliCompatible.should.equal(true);
|
||||
const myWorkflow = response.commands.find((cmd) => cmd.name === "my-workflow.md")
|
||||
myWorkflow!.should.not.be.undefined()
|
||||
myWorkflow!.section.should.equal("custom")
|
||||
myWorkflow!.cliCompatible.should.equal(true)
|
||||
|
||||
const anotherWorkflow = response.commands.find(
|
||||
(cmd) => cmd.name === "another-workflow.md"
|
||||
);
|
||||
anotherWorkflow!.should.not.be.undefined();
|
||||
});
|
||||
const anotherWorkflow = response.commands.find((cmd) => cmd.name === "another-workflow.md")
|
||||
anotherWorkflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
it("should exclude disabled local workflows", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"/path/to/enabled-workflow.md": true,
|
||||
"/path/to/disabled-workflow.md": false,
|
||||
});
|
||||
it("should exclude disabled local workflows", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/path/to/enabled-workflow.md": true,
|
||||
"/path/to/disabled-workflow.md": false,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const enabled = response.commands.find(
|
||||
(cmd) => cmd.name === "enabled-workflow.md"
|
||||
);
|
||||
enabled!.should.not.be.undefined();
|
||||
const enabled = response.commands.find((cmd) => cmd.name === "enabled-workflow.md")
|
||||
enabled!.should.not.be.undefined()
|
||||
|
||||
const disabled = response.commands.find(
|
||||
(cmd) => cmd.name === "disabled-workflow.md"
|
||||
);
|
||||
(disabled === undefined).should.be.true();
|
||||
});
|
||||
const disabled = response.commands.find((cmd) => cmd.name === "disabled-workflow.md")
|
||||
;(disabled === undefined).should.be.true()
|
||||
})
|
||||
|
||||
it("should extract filename from full path", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"/Users/test/project/.clinerules/workflows/deep-analysis.md": true,
|
||||
});
|
||||
it("should extract filename from full path", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/Users/test/project/.clinerules/workflows/deep-analysis.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "deep-analysis.md"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "deep-analysis.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
it("should handle Windows-style paths", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"C:\\Users\\test\\project\\.clinerules\\workflows\\windows-workflow.md":
|
||||
true,
|
||||
});
|
||||
it("should handle Windows-style paths", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"C:\\Users\\test\\project\\.clinerules\\workflows\\windows-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "windows-workflow.md"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
});
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "windows-workflow.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Global Workflow Toggles", () => {
|
||||
it("should include enabled global workflows", async () => {
|
||||
mockStateManager.getGlobalSettingsKey
|
||||
.withArgs("globalWorkflowToggles")
|
||||
.returns({
|
||||
"/global/path/global-workflow.md": true,
|
||||
});
|
||||
describe("Global Workflow Toggles", () => {
|
||||
it("should include enabled global workflows", async () => {
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/global-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "global-workflow.md"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
workflow!.section.should.equal("custom");
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "global-workflow.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
workflow!.section.should.equal("custom")
|
||||
})
|
||||
|
||||
it("should exclude disabled global workflows", async () => {
|
||||
mockStateManager.getGlobalSettingsKey
|
||||
.withArgs("globalWorkflowToggles")
|
||||
.returns({
|
||||
"/global/path/disabled-global.md": false,
|
||||
});
|
||||
it("should exclude disabled global workflows", async () => {
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/disabled-global.md": false,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "disabled-global.md"
|
||||
);
|
||||
(workflow === undefined).should.be.true();
|
||||
});
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "disabled-global.md")
|
||||
;(workflow === undefined).should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Workflow Deduplication", () => {
|
||||
it("should prefer local workflows over global workflows with same name", async () => {
|
||||
// Same filename in both local and global
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"/local/path/shared-workflow.md": true,
|
||||
});
|
||||
mockStateManager.getGlobalSettingsKey
|
||||
.withArgs("globalWorkflowToggles")
|
||||
.returns({
|
||||
"/global/path/shared-workflow.md": true,
|
||||
});
|
||||
describe("Workflow Deduplication", () => {
|
||||
it("should prefer local workflows over global workflows with same name", async () => {
|
||||
// Same filename in both local and global
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/local/path/shared-workflow.md": true,
|
||||
})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/shared-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should only appear once
|
||||
const matches = response.commands.filter(
|
||||
(cmd) => cmd.name === "shared-workflow.md"
|
||||
);
|
||||
matches.length.should.equal(1);
|
||||
});
|
||||
// Should only appear once
|
||||
const matches = response.commands.filter((cmd) => cmd.name === "shared-workflow.md")
|
||||
matches.length.should.equal(1)
|
||||
})
|
||||
|
||||
it("should include global workflow if local with same name is disabled", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"/local/path/shared-workflow.md": false, // disabled locally
|
||||
});
|
||||
mockStateManager.getGlobalSettingsKey
|
||||
.withArgs("globalWorkflowToggles")
|
||||
.returns({
|
||||
"/global/path/shared-workflow.md": true, // enabled globally
|
||||
});
|
||||
it("should include global workflow if local with same name is disabled", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/local/path/shared-workflow.md": false, // disabled locally
|
||||
})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/shared-workflow.md": true, // enabled globally
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Global should appear since local is disabled
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "shared-workflow.md"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
});
|
||||
});
|
||||
// Global should appear since local is disabled
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "shared-workflow.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Remote Workflows", () => {
|
||||
it("should include alwaysEnabled remote workflows", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [
|
||||
{ name: "always-on-workflow", alwaysEnabled: true },
|
||||
],
|
||||
});
|
||||
describe("Remote Workflows", () => {
|
||||
it("should include alwaysEnabled remote workflows", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "always-on-workflow", alwaysEnabled: true }],
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "always-on-workflow"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
workflow!.section.should.equal("custom");
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "always-on-workflow")
|
||||
workflow!.should.not.be.undefined()
|
||||
workflow!.section.should.equal("custom")
|
||||
})
|
||||
|
||||
it("should include remote workflows enabled by toggle", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [
|
||||
{ name: "toggle-workflow", alwaysEnabled: false },
|
||||
],
|
||||
});
|
||||
mockStateManager.getGlobalStateKey
|
||||
.withArgs("remoteWorkflowToggles")
|
||||
.returns({
|
||||
"toggle-workflow": true, // not explicitly disabled
|
||||
});
|
||||
it("should include remote workflows enabled by toggle", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "toggle-workflow", alwaysEnabled: false }],
|
||||
})
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
|
||||
"toggle-workflow": true, // not explicitly disabled
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "toggle-workflow"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "toggle-workflow")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
it("should exclude remote workflows explicitly disabled by toggle", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [
|
||||
{ name: "disabled-remote", alwaysEnabled: false },
|
||||
],
|
||||
});
|
||||
mockStateManager.getGlobalStateKey
|
||||
.withArgs("remoteWorkflowToggles")
|
||||
.returns({
|
||||
"disabled-remote": false,
|
||||
});
|
||||
it("should exclude remote workflows explicitly disabled by toggle", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "disabled-remote", alwaysEnabled: false }],
|
||||
})
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
|
||||
"disabled-remote": false,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "disabled-remote"
|
||||
);
|
||||
(workflow === undefined).should.be.true();
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "disabled-remote")
|
||||
;(workflow === undefined).should.be.true()
|
||||
})
|
||||
|
||||
it("should include remote workflows by default if not explicitly disabled", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [
|
||||
{ name: "default-enabled", alwaysEnabled: false },
|
||||
],
|
||||
});
|
||||
// No toggle entry for this workflow
|
||||
mockStateManager.getGlobalStateKey
|
||||
.withArgs("remoteWorkflowToggles")
|
||||
.returns({});
|
||||
it("should include remote workflows by default if not explicitly disabled", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "default-enabled", alwaysEnabled: false }],
|
||||
})
|
||||
// No toggle entry for this workflow
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "default-enabled"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
});
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "default-enabled")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle null/undefined state values gracefully", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.returns(null);
|
||||
mockStateManager.getGlobalSettingsKey.returns(undefined);
|
||||
mockStateManager.getGlobalStateKey.returns(null);
|
||||
mockStateManager.getRemoteConfigSettings.returns(null);
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle null/undefined state values gracefully", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.returns(null)
|
||||
mockStateManager.getGlobalSettingsKey.returns(undefined)
|
||||
mockStateManager.getGlobalStateKey.returns(null)
|
||||
mockStateManager.getRemoteConfigSettings.returns(null)
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should still return base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(
|
||||
BASE_SLASH_COMMANDS.length
|
||||
);
|
||||
});
|
||||
// Should still return base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
|
||||
})
|
||||
|
||||
it("should handle empty workflow toggle objects", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({});
|
||||
mockStateManager.getGlobalSettingsKey
|
||||
.withArgs("globalWorkflowToggles")
|
||||
.returns({});
|
||||
mockStateManager.getGlobalStateKey
|
||||
.withArgs("remoteWorkflowToggles")
|
||||
.returns({});
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [],
|
||||
});
|
||||
it("should handle empty workflow toggle objects", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({})
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [],
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should only have base commands
|
||||
response.commands.length.should.equal(BASE_SLASH_COMMANDS.length);
|
||||
});
|
||||
// Should only have base commands
|
||||
response.commands.length.should.equal(BASE_SLASH_COMMANDS.length)
|
||||
})
|
||||
|
||||
it("should handle remote config with no remoteGlobalWorkflows property", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({});
|
||||
it("should handle remote config with no remoteGlobalWorkflows property", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should not throw, just return base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(
|
||||
BASE_SLASH_COMMANDS.length
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
// Should not throw, just return base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { PLATFORM_CONFIG, PlatformType } from "../../../config/platform.config"
|
||||
import { FileServiceClient } from "../../../services/grpc-client"
|
||||
import { Button } from "../../ui/button"
|
||||
import Section from "../Section"
|
||||
|
||||
interface AboutSectionProps {
|
||||
@@ -45,6 +48,39 @@ const AboutSection = ({ version, renderSectionHeader }: AboutSectionProps) => {
|
||||
{" • "}
|
||||
<VSCodeLink href="https://cline.bot/">https://cline.bot</VSCodeLink>
|
||||
</p>
|
||||
|
||||
{PLATFORM_CONFIG.type === PlatformType.VSCODE && (
|
||||
<>
|
||||
<h3 className="text-md font-semibold">Logs</h3>
|
||||
<p className="text-sm text-(--vscode-descriptionForeground)">
|
||||
Cline writes detailed logs to help diagnose issues. Each session creates a separate log file. If
|
||||
you encounter an error, these logs can be shared with the Cline team for troubleshooting.
|
||||
</p>
|
||||
<p className="text-xs text-(--vscode-descriptionForeground)">Old logs are automatically removed.</p>
|
||||
|
||||
<div className="flex flex-col gap-2 max-w-md">
|
||||
<Button
|
||||
className="w-full whitespace-normal min-h-[32px]"
|
||||
onClick={() => FileServiceClient.openLogFile({})}
|
||||
variant="secondary">
|
||||
Current Log
|
||||
</Button>
|
||||
<p className="text-xs text-(--vscode-descriptionForeground) -mt-1 ml-1">
|
||||
View the log file for this session
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full whitespace-normal min-h-[32px]"
|
||||
onClick={() => FileServiceClient.openLogsFolder({})}
|
||||
variant="secondary">
|
||||
All Logs
|
||||
</Button>
|
||||
<p className="text-xs text-(--vscode-descriptionForeground) -mt-1 ml-1">
|
||||
Browse all session logs in your file manager
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user