Skip to content

Commit 2c579df

Browse files
committed
refactor: share managed binary installation
1 parent ceee77b commit 2c579df

16 files changed

Lines changed: 914 additions & 757 deletions

File tree

src/core/tools/ExecuteCommandTool.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
142142
// Resolve through the managed installer on use so an extension update
143143
// automatically installs the newly pinned and verified DCG version.
144144
const binaryPath = await ensureDcgInstalled(provider.context.globalStorageUri.fsPath)
145+
if (!binaryPath) {
146+
throw new Error(t("common:errors.destructiveCommandGuard.unavailable"))
147+
}
145148
const workingDirectory = customCwd
146149
? path.isAbsolute(customCwd)
147150
? customCwd

src/core/tools/__tests__/executeCommandTool.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,30 @@ describe("executeCommandTool", () => {
308308
expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled()
309309
})
310310

311+
it("fails closed when DCG is unavailable for the current platform", async () => {
312+
const provider = await mockCline.providerRef.deref()
313+
provider.context = { globalStorageUri: { fsPath: "/test/storage" } }
314+
provider.getState.mockResolvedValue({
315+
destructiveCommandGuardEnabled: true,
316+
terminalShellIntegrationDisabled: true,
317+
})
318+
mockEnsureDcgInstalled.mockResolvedValue(undefined)
319+
320+
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
321+
askApproval: mockAskApproval as unknown as AskApproval,
322+
handleError: mockHandleError as unknown as HandleError,
323+
pushToolResult: mockPushToolResult as unknown as PushToolResult,
324+
})
325+
326+
expect(mockHandleError).toHaveBeenCalledWith(
327+
"executing command",
328+
expect.objectContaining({ message: "errors.destructiveCommandGuard.unavailable" }),
329+
)
330+
expect(mockRunDcg).not.toHaveBeenCalled()
331+
expect(mockAskApproval).not.toHaveBeenCalled()
332+
expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled()
333+
})
334+
311335
it("should handle missing command parameter", async () => {
312336
// Setup
313337
mockToolUse.params.command = undefined

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,6 +1134,23 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => {
11341134
)
11351135
})
11361136

1137+
it("disables the setting when DCG is unavailable for the current platform", async () => {
1138+
vi.mocked(ensureDcgInstalled).mockResolvedValue(undefined)
1139+
1140+
await webviewMessageHandler(mockClineProvider, {
1141+
type: "updateSettings",
1142+
updatedSettings: { destructiveCommandGuardEnabled: true },
1143+
})
1144+
1145+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false)
1146+
expect(t).toHaveBeenCalledWith("common:errors.destructive_command_guard_enable_failed", {
1147+
error: "common:errors.destructiveCommandGuard.unavailable",
1148+
})
1149+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
1150+
"common:errors.destructive_command_guard_enable_failed",
1151+
)
1152+
})
1153+
11371154
it("reports non-Error installation failures", async () => {
11381155
vi.mocked(ensureDcgInstalled).mockRejectedValue("download unavailable")
11391156

src/core/webview/webviewMessageHandler.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,10 @@ export const webviewMessageHandler = async (
684684
if (message.updatedSettings.destructiveCommandGuardEnabled === true) {
685685
try {
686686
const { ensureDcgInstalled } = await import("../../services/destructive-command-guard")
687-
await ensureDcgInstalled(provider.context.globalStorageUri.fsPath)
687+
const binaryPath = await ensureDcgInstalled(provider.context.globalStorageUri.fsPath)
688+
if (!binaryPath) {
689+
throw new Error(t("common:errors.destructiveCommandGuard.unavailable"))
690+
}
688691
} catch (error) {
689692
message.updatedSettings.destructiveCommandGuardEnabled = false
690693
vscode.window.showErrorMessage(

src/eslint-suppressions.json

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,11 +1499,6 @@
14991499
"count": 3
15001500
}
15011501
},
1502-
"services/code-index/semble/semble-downloader.ts": {
1503-
"@typescript-eslint/no-explicit-any": {
1504-
"count": 1
1505-
}
1506-
},
15071502
"services/code-index/shared/__tests__/validation-helpers.spec.ts": {
15081503
"@typescript-eslint/no-explicit-any": {
15091504
"count": 4

src/services/code-index/semble/__tests__/semble-downloader.spec.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -263,12 +263,14 @@ describe("semble-downloader", () => {
263263
)
264264
// Version file should be written
265265
expect(fs.writeFile).toHaveBeenCalledWith(
266-
path.join("/storage", "semble", ".semble-version"),
266+
path.join("/storage", "semble.new", ".semble-version"),
267267
"v0.4.1",
268268
"utf-8",
269269
)
270270
// Archive should be cleaned up (version-prefixed local cache path)
271-
expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-x64-fast.tar.gz"))
271+
expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-x64-fast.tar.gz"), {
272+
force: true,
273+
})
272274
} finally {
273275
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform)
274276
if (originalArch) Object.defineProperty(process, "arch", originalArch)
@@ -325,7 +327,9 @@ describe("semble-downloader", () => {
325327

326328
try {
327329
await expect(downloadSemble("/storage")).rejects.toThrow("Failed to download semble")
328-
expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-arm64-fast.tar.gz"))
330+
expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-arm64-fast.tar.gz"), {
331+
force: true,
332+
})
329333
// Should clean up staging directory, not the original
330334
expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "semble.new"), {
331335
recursive: true,
@@ -589,7 +593,7 @@ describe("semble-downloader", () => {
589593
})
590594

591595
// Archive cleanup fails but should not throw (only archive removal after extraction)
592-
;(fs.unlink as any).mockRejectedValue(new Error("unlink cleanup failed"))
596+
;(fs.rm as any).mockRejectedValueOnce(new Error("archive cleanup failed"))
593597

594598
try {
595599
const result = await downloadSemble("/storage")
@@ -641,7 +645,7 @@ describe("semble-downloader", () => {
641645
expect(https.get).toHaveBeenCalledWith(expect.stringContaining("v0.4.1"), expect.any(Function))
642646
// Should write the new version file
643647
expect(fs.writeFile).toHaveBeenCalledWith(
644-
path.join("/storage", "semble", ".semble-version"),
648+
path.join("/storage", "semble.new", ".semble-version"),
645649
"v0.4.1",
646650
"utf-8",
647651
)
@@ -700,19 +704,23 @@ describe("semble-downloader", () => {
700704
)
701705
// The stale archive is removed before the fresh download to guarantee
702706
// a clean package is verified against the new checksum.
703-
expect(fs.unlink).toHaveBeenCalledWith(versionedArchive)
707+
expect(fs.rm).toHaveBeenCalledWith(versionedArchive, { force: true })
704708
// The prior-version archive (v0.4.0-*) is swept by cleanupStaleArchives
705709
// after a successful install, so a version upgrade doesn't accumulate
706710
// orphaned packages on disk.
707-
expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz"))
711+
expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz"), {
712+
force: true,
713+
})
708714
// The legacy unversioned archive (pre-v0.4.0 cache layout) is also
709715
// swept, covering the v0.3.1 → v0.4.1 upgrade path.
710-
expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz"))
716+
expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz"), {
717+
force: true,
718+
})
711719
// Unrelated files in the storage dir must not be touched.
712720
expect(fs.unlink).not.toHaveBeenCalledWith(path.join("/storage", "unrelated-file.txt"))
713721
// The new version file is recorded
714722
expect(fs.writeFile).toHaveBeenCalledWith(
715-
path.join("/storage", "semble", ".semble-version"),
723+
path.join("/storage", "semble.new", ".semble-version"),
716724
"v0.4.1",
717725
"utf-8",
718726
)
@@ -789,7 +797,7 @@ describe("semble-downloader", () => {
789797
)
790798
// Should write version file again
791799
expect(fs.writeFile).toHaveBeenCalledWith(
792-
path.join("/storage", "semble", ".semble-version"),
800+
path.join("/storage", "semble.new", ".semble-version"),
793801
"v0.4.1",
794802
"utf-8",
795803
)
@@ -831,7 +839,7 @@ describe("semble-downloader", () => {
831839
)
832840
// Should write version file
833841
expect(fs.writeFile).toHaveBeenCalledWith(
834-
path.join("/storage", "semble", ".semble-version"),
842+
path.join("/storage", "semble.new", ".semble-version"),
835843
"v0.4.1",
836844
"utf-8",
837845
)
@@ -903,8 +911,12 @@ describe("semble-downloader", () => {
903911

904912
const currentArchive = path.join("/storage", "v0.4.1-semble-linux-x64-fast.tar.gz")
905913
// Stale versioned + legacy unversioned archives are swept
906-
expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz"))
907-
expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz"))
914+
expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz"), {
915+
force: true,
916+
})
917+
expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz"), {
918+
force: true,
919+
})
908920
// The current archive is never swept by cleanupStaleArchives (it is
909921
// excluded by the currentArchivePath guard). It is unlinked only by
910922
// the pre-download partial-archive cleanup and the post-install
@@ -913,8 +925,8 @@ describe("semble-downloader", () => {
913925
// Sanity: the current archive path is never passed to the stale sweep.
914926
// It is unlinked exactly twice (pre-download cleanup + post-install
915927
// archive cleanup), never via cleanupStaleArchives.
916-
const currentUnlinks = (fs.unlink as any).mock.calls.filter((c: any[]) => c[0] === currentArchive)
917-
expect(currentUnlinks.length).toBe(2)
928+
const currentRemovals = (fs.rm as any).mock.calls.filter((c: any[]) => c[0] === currentArchive)
929+
expect(currentRemovals.length).toBe(2)
918930
} finally {
919931
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform)
920932
if (originalArch) Object.defineProperty(process, "arch", originalArch)

0 commit comments

Comments
 (0)