Skip to content
Open
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
1,099 changes: 61 additions & 1,038 deletions src/cosmosDBShell/CosmosDBShellExtension.ts

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions src/cosmosDBShell/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/**
* Single source of truth for setting keys, command IDs, and other magic values shared
* across the Cosmos DB Shell modules. Keeping these here ensures renames stay in lockstep
* and that build/lint tooling can flag any stray literal references.
*/

/** Display name used for every Cosmos DB Shell terminal created by this extension. */
export const COSMOS_DB_SHELL_TERMINAL_NAME = 'Cosmos DB Shell';

/** Default TCP port used by the Cosmos DB Shell MCP server when the user hasn't overridden it. */
export const DEFAULT_MCP_PORT = 6128;

/** Label published to the VS Code MCP API for the Cosmos DB Shell MCP server. */
export const MCP_SERVER_NAME = 'Azure Cosmos DB Shell';

// --- VS Code setting keys (must match the contributions in package.json) ---
export const SETTING_SHELL_PATH = 'cosmosDB.shell.path';
export const SETTING_MCP_ENABLED = 'cosmosDB.shell.MCP.enabled';
export const SETTING_MCP_PORT = 'cosmosDB.shell.MCP.port';

// --- VS Code command IDs ---
export const COMMAND_LAUNCH_COSMOS_DB_SHELL = 'cosmosDB.launchCosmosDBShell';
138 changes: 138 additions & 0 deletions src/cosmosDBShell/install/dotNetSdk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as child from 'child_process';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import {
MIN_DOTNET_SDK_VERSION,
REQUESTED_DOTNET_SDK_CHANNEL,
compareDotNetVersions,
getInstalledDotNetSdkVersions,
hasRequiredDotNetSdk,
} from './dotNetSdk';

vi.mock('child_process', () => ({
execFileSync: vi.fn(),
}));

vi.mock('../../extensionVariables', () => ({
ext: {
outputChannel: {
appendLine: vi.fn(),
},
},
}));

// @microsoft/vscode-azext-utils transitively pulls in vscode via CJS requires. Stub the only
// symbol dotNetSdk uses (callWithTelemetryAndErrorHandling) so the module loads cleanly.
vi.mock('@microsoft/vscode-azext-utils', () => ({
callWithTelemetryAndErrorHandling: vi.fn(),
}));

describe('dotNetSdk.compareDotNetVersions', () => {
it('compares numeric major / minor / patch components', () => {
expect(compareDotNetVersions('10.0.0', '9.0.0')).toBeGreaterThan(0);
expect(compareDotNetVersions('9.0.0', '10.0.0')).toBeLessThan(0);
expect(compareDotNetVersions('10.0.203', '10.0.100')).toBeGreaterThan(0);
expect(compareDotNetVersions('10.1.0', '10.0.999')).toBeGreaterThan(0);
});

it('returns 0 for equal versions', () => {
expect(compareDotNetVersions('10.0.203', '10.0.203')).toBe(0);
});

it('ignores pre-release / build suffixes after "-"', () => {
expect(compareDotNetVersions('9.0.100-rc.1', '9.0.100')).toBe(0);
expect(compareDotNetVersions('10.0.203-preview.1', '10.0.203-rc.2')).toBe(0);
});

it('treats missing components as zero', () => {
expect(compareDotNetVersions('10', '10.0.0')).toBe(0);
expect(compareDotNetVersions('10.0.0', '10')).toBe(0);
expect(compareDotNetVersions('10.1', '10.0.999')).toBeGreaterThan(0);
});

it('treats non-numeric components as zero', () => {
expect(compareDotNetVersions('abc.def.ghi', '0.0.0')).toBe(0);
});
});

describe('dotNetSdk.REQUESTED_DOTNET_SDK_CHANNEL', () => {
it('is the "major.minor" prefix of MIN_DOTNET_SDK_VERSION', () => {
const expected = MIN_DOTNET_SDK_VERSION.split('.').slice(0, 2).join('.');
expect(REQUESTED_DOTNET_SDK_CHANNEL).toBe(expected);
});
});

describe('dotNetSdk.getInstalledDotNetSdkVersions', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('parses one version per non-empty line of "dotnet --list-sdks" output', () => {
(child.execFileSync as Mock).mockReturnValue(
'8.0.404 [C:\\Program Files\\dotnet\\sdk]\r\n10.0.203 [C:\\Program Files\\dotnet\\sdk]\r\n',
);
expect(getInstalledDotNetSdkVersions()).toEqual(['8.0.404', '10.0.203']);
});

it('accepts Buffer return values from execFileSync', () => {
(child.execFileSync as Mock).mockReturnValue(Buffer.from('9.0.100 [x]\n'));
expect(getInstalledDotNetSdkVersions()).toEqual(['9.0.100']);
});

it('returns an empty array when execFileSync throws (dotnet not on PATH)', () => {
(child.execFileSync as Mock).mockImplementation(() => {
throw new Error('not found');
});
expect(getInstalledDotNetSdkVersions()).toEqual([]);
});

it('skips blank lines', () => {
(child.execFileSync as Mock).mockReturnValue('\n9.0.100 [x]\n\n');
expect(getInstalledDotNetSdkVersions()).toEqual(['9.0.100']);
});

it('invokes the supplied dotnetPath instead of "dotnet" when provided', () => {
(child.execFileSync as Mock).mockReturnValue('10.0.203 [x]\n');
getInstalledDotNetSdkVersions('/custom/dotnet');
expect((child.execFileSync as Mock).mock.calls[0][0]).toBe('/custom/dotnet');
});

it('defaults to "dotnet" when no dotnetPath is provided', () => {
(child.execFileSync as Mock).mockReturnValue('10.0.203 [x]\n');
getInstalledDotNetSdkVersions();
expect((child.execFileSync as Mock).mock.calls[0][0]).toBe('dotnet');
});
});

describe('dotNetSdk.hasRequiredDotNetSdk', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns true when at least one installed SDK >= MIN_DOTNET_SDK_VERSION', () => {
(child.execFileSync as Mock).mockReturnValue(`8.0.404 [x]\n${MIN_DOTNET_SDK_VERSION} [x]\n`);
expect(hasRequiredDotNetSdk()).toBe(true);
});

it('returns false when all installed SDKs are below MIN_DOTNET_SDK_VERSION', () => {
(child.execFileSync as Mock).mockReturnValue('8.0.404 [x]\n9.0.100 [x]\n');
expect(hasRequiredDotNetSdk()).toBe(false);
});

it('returns false when no SDKs are installed (execFileSync throws)', () => {
(child.execFileSync as Mock).mockImplementation(() => {
throw new Error('not found');
});
expect(hasRequiredDotNetSdk()).toBe(false);
});

it('forwards the supplied dotnetPath to getInstalledDotNetSdkVersions', () => {
(child.execFileSync as Mock).mockReturnValue(`${MIN_DOTNET_SDK_VERSION} [x]\n`);
hasRequiredDotNetSdk('/custom/dotnet');
expect((child.execFileSync as Mock).mock.calls[0][0]).toBe('/custom/dotnet');
});
});
129 changes: 129 additions & 0 deletions src/cosmosDBShell/install/dotNetSdk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/**
* .NET SDK detection and acquisition helpers used by the Cosmos DB Shell install flow.
*
* The CosmosDBShell tool is distributed as a `dotnet tool` global package, so installing
* it (and keeping the user happy when it's missing) requires both a `dotnet` CLI on PATH
* *and* an SDK whose version satisfies {@link MIN_DOTNET_SDK_VERSION}.
*/
import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils';
import * as child from 'child_process';
import * as vscode from 'vscode';
import { ext } from '../../extensionVariables';

/**
* Minimum .NET SDK version required to install the Cosmos DB Shell global tool.
* Bumping this constant also updates the version requested via
* `dotnet.acquireGlobalSDK` and the user-facing prompt copy.
*/
export const MIN_DOTNET_SDK_VERSION = '10.0.203';

/**
* Channel (`major.minor`) requested from `dotnet.acquireGlobalSDK`. Using a
* channel rather than {@link MIN_DOTNET_SDK_VERSION} lets the .NET Install Tool
* resolve to the latest available patch on that channel while still satisfying
* the floor enforced by {@link hasRequiredDotNetSdk}.
*/
export const REQUESTED_DOTNET_SDK_CHANNEL = MIN_DOTNET_SDK_VERSION.split('.').slice(0, 2).join('.');

/**
* Compares two .NET SDK version strings (e.g. `10.0.203`, `9.0.100-rc.1`) by
* numeric major / minor / patch components. Any pre-release / build metadata
* suffix (after `-`) is ignored. Returns a negative number when `a < b`, zero
* when equal, and a positive number when `a > b`.
*/
export function compareDotNetVersions(a: string, b: string): number {
const parse = (v: string): number[] =>
v
.split('-')[0]
.split('.')
.map((part) => Number.parseInt(part, 10) || 0);
const av = parse(a);
const bv = parse(b);
const len = Math.max(av.length, bv.length);
for (let i = 0; i < len; i++) {
const diff = (av[i] ?? 0) - (bv[i] ?? 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}

/**
* Runs `dotnet --list-sdks` and returns the parsed version strings. The CLI
* output format is `<version> [<install-path>]` per line. Returns an empty
* array when `dotnet` is not on PATH or the call fails.
*/
export function getInstalledDotNetSdkVersions(dotnetPath?: string): string[] {
try {
const output = child.execFileSync(dotnetPath ?? 'dotnet', ['--list-sdks'], {
windowsHide: true,
stdio: 'pipe',
});
return output
.toString('utf8')
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => line.split(/\s+/, 1)[0]);
} catch {
return [];
}
}

export function hasRequiredDotNetSdk(dotnetPath?: string): boolean {
return getInstalledDotNetSdkVersions(dotnetPath).some(
(version) => compareDotNetVersions(version, MIN_DOTNET_SDK_VERSION) >= 0,
);
}

/**
* Awaits the `.NET Install Tool` SDK acquisition command, requesting a global
* install of the latest patch available on {@link REQUESTED_DOTNET_SDK_CHANNEL}.
* Returns the resolved `dotnet` executable path on success, or undefined when
* the acquisition failed or did not return a path.
*
* Uses `dotnet.acquireGlobalSDK` rather than `dotnet.acquireGlobalSDKPublic`
* because the public variant ignores the supplied `version` and prompts the
* user with its own recommended version instead.
*/
export async function tryInstallDotNetSdkViaExtension(): Promise<string | undefined> {
const result = await callWithTelemetryAndErrorHandling(
'cosmosDB.cosmosDBShell.install.dotnetSdk',
async (telemetryContext: IActionContext) => {
telemetryContext.errorHandling.suppressDisplay = true;
telemetryContext.telemetry.properties.requestedChannel = REQUESTED_DOTNET_SDK_CHANNEL;
const startedAt = Date.now();
try {
await vscode.commands.executeCommand('dotnet.showAcquisitionLog');
const acquisition = await vscode.commands.executeCommand<{ dotnetPath?: string } | undefined>(
'dotnet.acquireGlobalSDK',
{
version: REQUESTED_DOTNET_SDK_CHANNEL,
requestingExtensionId: ext.context.extension.id,
installType: 'global',
},
);
telemetryContext.telemetry.measurements.durationMs = Date.now() - startedAt;
const dotnetPath = acquisition?.dotnetPath;
telemetryContext.telemetry.properties.pathReturned = String(!!dotnetPath);
telemetryContext.telemetry.properties.satisfiesMinSdk = dotnetPath
? String(hasRequiredDotNetSdk(dotnetPath))
: 'false';
telemetryContext.telemetry.properties.outcome = dotnetPath ? 'success' : 'noPath';
return dotnetPath;
} catch (err) {
telemetryContext.telemetry.measurements.durationMs = Date.now() - startedAt;
telemetryContext.telemetry.properties.outcome = 'failure';
ext.outputChannel.appendLine(`dotnet.acquireGlobalSDK failed: ${String(err)}`);
throw err;
}
},
);
return result;
}
Loading
Loading