Chris Estreich 1 місяць тому
батько
коміт
efbba9e8d5

+ 17 - 2
apps/cli/scripts/build.sh

@@ -193,6 +193,7 @@ create_tarball() {
 
 
 import { fileURLToPath } from 'url';
 import { fileURLToPath } from 'url';
 import { dirname, join } from 'path';
 import { dirname, join } from 'path';
+import { existsSync } from 'fs';
 
 
 const __filename = fileURLToPath(import.meta.url);
 const __filename = fileURLToPath(import.meta.url);
 const __dirname = dirname(__filename);
 const __dirname = dirname(__filename);
@@ -200,7 +201,10 @@ const __dirname = dirname(__filename);
 // Set environment variables for the CLI
 // Set environment variables for the CLI
 process.env.ROO_CLI_ROOT = join(__dirname, '..');
 process.env.ROO_CLI_ROOT = join(__dirname, '..');
 process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');
 process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');
-process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');
+const ripgrepPath = join(__dirname, 'rg');
+if (existsSync(ripgrepPath)) {
+  process.env.ROO_RIPGREP_PATH = ripgrepPath;
+}
 
 
 // Import and run the actual CLI
 // Import and run the actual CLI
 await import(join(__dirname, '..', 'lib', 'index.js'));
 await import(join(__dirname, '..', 'lib', 'index.js'));
@@ -211,10 +215,21 @@ WRAPPER_EOF
     # Create empty .env file
     # Create empty .env file
     touch "$RELEASE_DIR/.env"
     touch "$RELEASE_DIR/.env"
 
 
+    # Strip macOS metadata artifacts before packaging.
+    find "$RELEASE_DIR" -type f -name "._*" -delete
+    find "$RELEASE_DIR" -type f -name ".DS_Store" -delete
+    find "$RELEASE_DIR" -type d -name "__MACOSX" -prune -exec rm -rf {} +
+
     # Create tarball
     # Create tarball
     info "Creating tarball..."
     info "Creating tarball..."
     cd "$REPO_ROOT"
     cd "$REPO_ROOT"
-    tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")"
+    COPYFILE_DISABLE=1 tar \
+        --exclude="._*" \
+        --exclude=".DS_Store" \
+        --exclude="__MACOSX" \
+        --exclude="*/._*" \
+        --exclude="*/.DS_Store" \
+        -czvf "$TARBALL" "$(basename "$RELEASE_DIR")"
 
 
     # Clean up release directory
     # Clean up release directory
     rm -rf "$RELEASE_DIR"
     rm -rf "$RELEASE_DIR"

+ 41 - 0
apps/cli/src/agent/__tests__/extension-host.test.ts

@@ -80,13 +80,28 @@ function spyOnPrivate(host: ExtensionHost, method: string) {
 }
 }
 
 
 describe("ExtensionHost", () => {
 describe("ExtensionHost", () => {
+	const initialRooCliRuntimeEnv = process.env.ROO_CLI_RUNTIME
+
 	beforeEach(() => {
 	beforeEach(() => {
 		vi.resetAllMocks()
 		vi.resetAllMocks()
+		if (initialRooCliRuntimeEnv === undefined) {
+			delete process.env.ROO_CLI_RUNTIME
+		} else {
+			process.env.ROO_CLI_RUNTIME = initialRooCliRuntimeEnv
+		}
 		// Clean up globals
 		// Clean up globals
 		delete (global as Record<string, unknown>).vscode
 		delete (global as Record<string, unknown>).vscode
 		delete (global as Record<string, unknown>).__extensionHost
 		delete (global as Record<string, unknown>).__extensionHost
 	})
 	})
 
 
+	afterAll(() => {
+		if (initialRooCliRuntimeEnv === undefined) {
+			delete process.env.ROO_CLI_RUNTIME
+		} else {
+			process.env.ROO_CLI_RUNTIME = initialRooCliRuntimeEnv
+		}
+	})
+
 	describe("constructor", () => {
 	describe("constructor", () => {
 		it("should store options correctly", () => {
 		it("should store options correctly", () => {
 			const options: ExtensionHostOptions = {
 			const options: ExtensionHostOptions = {
@@ -135,6 +150,12 @@ describe("ExtensionHost", () => {
 			expect(getPrivate(host, "promptManager")).toBeDefined()
 			expect(getPrivate(host, "promptManager")).toBeDefined()
 			expect(getPrivate(host, "askDispatcher")).toBeDefined()
 			expect(getPrivate(host, "askDispatcher")).toBeDefined()
 		})
 		})
+
+		it("should mark process as CLI runtime", () => {
+			delete process.env.ROO_CLI_RUNTIME
+			createTestHost()
+			expect(process.env.ROO_CLI_RUNTIME).toBe("1")
+		})
 	})
 	})
 
 
 	describe("webview provider registration", () => {
 	describe("webview provider registration", () => {
@@ -429,6 +450,26 @@ describe("ExtensionHost", () => {
 
 
 			expect(restoreConsoleSpy).toHaveBeenCalled()
 			expect(restoreConsoleSpy).toHaveBeenCalled()
 		})
 		})
+
+		it("should clear ROO_CLI_RUNTIME on dispose when it was previously unset", async () => {
+			delete process.env.ROO_CLI_RUNTIME
+			host = createTestHost()
+			expect(process.env.ROO_CLI_RUNTIME).toBe("1")
+
+			await host.dispose()
+
+			expect(process.env.ROO_CLI_RUNTIME).toBeUndefined()
+		})
+
+		it("should restore prior ROO_CLI_RUNTIME value on dispose", async () => {
+			process.env.ROO_CLI_RUNTIME = "preexisting-value"
+			host = createTestHost()
+			expect(process.env.ROO_CLI_RUNTIME).toBe("1")
+
+			await host.dispose()
+
+			expect(process.env.ROO_CLI_RUNTIME).toBe("preexisting-value")
+		})
 	})
 	})
 
 
 	describe("runTask", () => {
 	describe("runTask", () => {

+ 12 - 0
apps/cli/src/agent/extension-host.ts

@@ -135,6 +135,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
 
 
 	// Ephemeral storage.
 	// Ephemeral storage.
 	private ephemeralStorageDir: string | null = null
 	private ephemeralStorageDir: string | null = null
+	private previousCliRuntimeEnv: string | undefined
 
 
 	// ==========================================================================
 	// ==========================================================================
 	// Managers - These do all the heavy lifting
 	// Managers - These do all the heavy lifting
@@ -172,6 +173,10 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
 		super()
 		super()
 
 
 		this.options = options
 		this.options = options
+		// Mark this process as CLI runtime so extension code can apply
+		// CLI-specific behavior without affecting VS Code desktop usage.
+		this.previousCliRuntimeEnv = process.env.ROO_CLI_RUNTIME
+		process.env.ROO_CLI_RUNTIME = "1"
 
 
 		// Enable file-based debug logging only when --debug is passed.
 		// Enable file-based debug logging only when --debug is passed.
 		if (options.debug) {
 		if (options.debug) {
@@ -570,5 +575,12 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
 				// NO-OP
 				// NO-OP
 			}
 			}
 		}
 		}
+
+		// Restore previous CLI runtime marker for process hygiene in tests.
+		if (this.previousCliRuntimeEnv === undefined) {
+			delete process.env.ROO_CLI_RUNTIME
+		} else {
+			process.env.ROO_CLI_RUNTIME = this.previousCliRuntimeEnv
+		}
 	}
 	}
 }
 }

+ 13 - 2
src/core/webview/ClineProvider.ts

@@ -963,6 +963,12 @@ export class ClineProvider
 		historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task },
 		historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task },
 		options?: { startTask?: boolean },
 		options?: { startTask?: boolean },
 	) {
 	) {
+		const isCliRuntime = process.env.ROO_CLI_RUNTIME === "1"
+		// CLI injects runtime provider settings from command flags/env at startup.
+		// Restoring provider profiles from task history can overwrite those
+		// runtime settings with stale/incomplete persisted profiles.
+		const skipProfileRestoreFromHistory = isCliRuntime
+
 		// Check if we're rehydrating the current task to avoid flicker
 		// Check if we're rehydrating the current task to avoid flicker
 		const currentTask = this.getCurrentTask()
 		const currentTask = this.getCurrentTask()
 		const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id
 		const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id
@@ -991,7 +997,8 @@ export class ClineProvider
 			// Skip mode-based profile activation if historyItem.apiConfigName exists,
 			// Skip mode-based profile activation if historyItem.apiConfigName exists,
 			// since the task's specific provider profile will override it anyway.
 			// since the task's specific provider profile will override it anyway.
 			const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false)
 			const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false)
-			if (!historyItem.apiConfigName && !lockApiConfigAcrossModes) {
+
+			if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) {
 				const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
 				const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
 				const listApiConfig = await this.providerSettingsManager.listConfig()
 				const listApiConfig = await this.providerSettingsManager.listConfig()
 
 
@@ -1033,7 +1040,7 @@ export class ClineProvider
 		// If the history item has a saved API config name (provider profile), restore it.
 		// If the history item has a saved API config name (provider profile), restore it.
 		// This overrides any mode-based config restoration above, because the task's
 		// This overrides any mode-based config restoration above, because the task's
 		// specific provider profile takes precedence over mode defaults.
 		// specific provider profile takes precedence over mode defaults.
-		if (historyItem.apiConfigName) {
+		if (historyItem.apiConfigName && !skipProfileRestoreFromHistory) {
 			const listApiConfig = await this.providerSettingsManager.listConfig()
 			const listApiConfig = await this.providerSettingsManager.listConfig()
 			// Keep global state/UI in sync with latest profiles for parity with mode restoration above.
 			// Keep global state/UI in sync with latest profiles for parity with mode restoration above.
 			await this.updateGlobalState("listApiConfigMeta", listApiConfig)
 			await this.updateGlobalState("listApiConfigMeta", listApiConfig)
@@ -1059,6 +1066,10 @@ export class ClineProvider
 					`Provider profile '${historyItem.apiConfigName}' from history no longer exists. Using current configuration.`,
 					`Provider profile '${historyItem.apiConfigName}' from history no longer exists. Using current configuration.`,
 				)
 				)
 			}
 			}
+		} else if (historyItem.apiConfigName && skipProfileRestoreFromHistory) {
+			this.log(
+				`Skipping restore of provider profile '${historyItem.apiConfigName}' for task ${historyItem.id} in CLI runtime.`,
+			)
 		}
 		}
 
 
 		const { apiConfiguration, enableCheckpoints, checkpointTimeout, experiments, cloudUserInfo, taskSyncEnabled } =
 		const { apiConfiguration, enableCheckpoints, checkpointTimeout, experiments, cloudUserInfo, taskSyncEnabled } =

+ 77 - 1
src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts

@@ -201,10 +201,13 @@ describe("ClineProvider - Sticky Provider Profile", () => {
 	let mockOutputChannel: vscode.OutputChannel
 	let mockOutputChannel: vscode.OutputChannel
 	let mockWebviewView: vscode.WebviewView
 	let mockWebviewView: vscode.WebviewView
 	let mockPostMessage: any
 	let mockPostMessage: any
+	let originalRooCliRuntimeEnv: string | undefined
 
 
 	beforeEach(async () => {
 	beforeEach(async () => {
 		vi.clearAllMocks()
 		vi.clearAllMocks()
 		taskIdCounter = 0
 		taskIdCounter = 0
+		originalRooCliRuntimeEnv = process.env.ROO_CLI_RUNTIME
+		delete process.env.ROO_CLI_RUNTIME
 
 
 		if (!TelemetryService.hasInstance()) {
 		if (!TelemetryService.hasInstance()) {
 			TelemetryService.createInstance([])
 			TelemetryService.createInstance([])
@@ -293,6 +296,14 @@ describe("ClineProvider - Sticky Provider Profile", () => {
 		})
 		})
 	})
 	})
 
 
+	afterEach(() => {
+		if (originalRooCliRuntimeEnv === undefined) {
+			delete process.env.ROO_CLI_RUNTIME
+		} else {
+			process.env.ROO_CLI_RUNTIME = originalRooCliRuntimeEnv
+		}
+	})
+
 	describe("activateProviderProfile", () => {
 	describe("activateProviderProfile", () => {
 		beforeEach(async () => {
 		beforeEach(async () => {
 			await provider.resolveWebviewView(mockWebviewView)
 			await provider.resolveWebviewView(mockWebviewView)
@@ -457,7 +468,7 @@ describe("ClineProvider - Sticky Provider Profile", () => {
 	})
 	})
 
 
 	describe("createTaskWithHistoryItem", () => {
 	describe("createTaskWithHistoryItem", () => {
-		it("should restore provider profile from history item when reopening task", async () => {
+		it("should restore provider profile from history item when reopening task outside CLI runtime", async () => {
 			await provider.resolveWebviewView(mockWebviewView)
 			await provider.resolveWebviewView(mockWebviewView)
 
 
 			// Create a history item with saved provider profile
 			// Create a history item with saved provider profile
@@ -495,6 +506,71 @@ describe("ClineProvider - Sticky Provider Profile", () => {
 			)
 			)
 		})
 		})
 
 
+		it("should skip restoring task apiConfigName from history in CLI runtime", async () => {
+			await provider.resolveWebviewView(mockWebviewView)
+			process.env.ROO_CLI_RUNTIME = "1"
+
+			const historyItem: HistoryItem = {
+				id: "test-task-id",
+				number: 1,
+				ts: Date.now(),
+				task: "Test task",
+				tokensIn: 100,
+				tokensOut: 200,
+				cacheWrites: 0,
+				cacheReads: 0,
+				totalCost: 0.001,
+				apiConfigName: "saved-profile",
+			}
+
+			const activateProviderProfileSpy = vi
+				.spyOn(provider, "activateProviderProfile")
+				.mockResolvedValue(undefined)
+			const logSpy = vi.spyOn(provider, "log")
+
+			vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+				{ name: "saved-profile", id: "saved-profile-id", apiProvider: "anthropic" },
+			])
+
+			await provider.createTaskWithHistoryItem(historyItem)
+
+			expect(activateProviderProfileSpy).not.toHaveBeenCalledWith({ name: "saved-profile" }, expect.anything())
+			expect(logSpy).toHaveBeenCalledWith(
+				expect.stringContaining("Skipping restore of provider profile 'saved-profile'"),
+			)
+		})
+
+		it("should skip restoring mode-based provider config from history in CLI runtime", async () => {
+			await provider.resolveWebviewView(mockWebviewView)
+			process.env.ROO_CLI_RUNTIME = "1"
+
+			const historyItem: HistoryItem = {
+				id: "test-task-id",
+				number: 1,
+				ts: Date.now(),
+				task: "Test task",
+				tokensIn: 100,
+				tokensOut: 200,
+				cacheWrites: 0,
+				cacheReads: 0,
+				totalCost: 0.001,
+				mode: "code",
+			}
+
+			const activateProviderProfileSpy = vi
+				.spyOn(provider, "activateProviderProfile")
+				.mockResolvedValue(undefined)
+
+			vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("mode-config-id")
+			vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+				{ name: "mode-profile", id: "mode-config-id", apiProvider: "anthropic" },
+			])
+
+			await provider.createTaskWithHistoryItem(historyItem)
+
+			expect(activateProviderProfileSpy).not.toHaveBeenCalled()
+		})
+
 		it("should use current profile if history item has no saved apiConfigName", async () => {
 		it("should use current profile if history item has no saved apiConfigName", async () => {
 			await provider.resolveWebviewView(mockWebviewView)
 			await provider.resolveWebviewView(mockWebviewView)
 
 

+ 11 - 2
src/i18n/setup.ts

@@ -20,7 +20,10 @@ if (!isTestEnv) {
 			const languageDirs = fs.readdirSync(localesDir, { withFileTypes: true })
 			const languageDirs = fs.readdirSync(localesDir, { withFileTypes: true })
 
 
 			const languages = languageDirs
 			const languages = languageDirs
-				.filter((dirent: { isDirectory: () => boolean }) => dirent.isDirectory())
+				.filter(
+					(dirent: { isDirectory: () => boolean; name: string }) =>
+						dirent.isDirectory() && !dirent.name.startsWith("."),
+				)
 				.map((dirent: { name: string }) => dirent.name)
 				.map((dirent: { name: string }) => dirent.name)
 
 
 			// Process each language
 			// Process each language
@@ -28,7 +31,13 @@ if (!isTestEnv) {
 				const langPath = path.join(localesDir, language)
 				const langPath = path.join(localesDir, language)
 
 
 				// Find all JSON files in the language directory
 				// Find all JSON files in the language directory
-				const files = fs.readdirSync(langPath).filter((file: string) => file.endsWith(".json"))
+				const files = fs
+					.readdirSync(langPath, { withFileTypes: true })
+					.filter(
+						(dirent: { isFile: () => boolean; name: string }) =>
+							dirent.isFile() && dirent.name.endsWith(".json") && !dirent.name.startsWith("."),
+					)
+					.map((dirent: { name: string }) => dirent.name)
 
 
 				// Initialize language in translations object
 				// Initialize language in translations object
 				if (!translations[language]) {
 				if (!translations[language]) {