diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf
index eb20267163b..ab766716917 100644
--- a/extension/loc/xlf/aspire-vscode.xlf
+++ b/extension/loc/xlf/aspire-vscode.xlf
@@ -58,6 +58,9 @@
Aspire CLI is not available on PATH. Please install it and restart VS Code.
+
+ Aspire CLI {0} at {1} has a newer version available for its current channel: {2}.
+
Aspire Extension
@@ -1003,6 +1006,9 @@
Unable to add folder to workspace: {0}
+
+ Update Aspire CLI
+
Update Aspire CLI
diff --git a/extension/package.nls.json b/extension/package.nls.json
index 887b1e468a8..0a25c81219a 100644
--- a/extension/package.nls.json
+++ b/extension/package.nls.json
@@ -86,6 +86,8 @@
"aspire-vscode.strings.aspireCliPathEnvironmentDescription": "Forwards aspire.aspireCliExecutablePath as AspireCliPath so MSBuild bundle resolution and integrated terminals use the configured Aspire CLI.",
"aspire-vscode.strings.configuredCliPathRejected": "The configured Aspire CLI path could not be used, so a different Aspire CLI is running instead: {0}. Check that aspire.aspireCliExecutablePath points to the aspire executable itself and that it runs.",
"aspire-vscode.strings.configuredCliPathRejectedOpenSetting": "Open Setting",
+ "aspire-vscode.strings.outdatedAspireCliWarning": "Aspire CLI {0} at {1} has a newer version available for its current channel: {2}.",
+ "aspire-vscode.strings.updateAspireCliAction": "Update Aspire CLI",
"aspire-vscode.strings.installCliPlaceholder": "Select how to install the Aspire CLI",
"aspire-vscode.strings.installCliViewAllOptions": "View all installation options",
"aspire-vscode.strings.installCliViewAllOptionsDescription": "Open the installation guide (includes the install script)",
diff --git a/extension/src/activation/registerCliCommands.ts b/extension/src/activation/registerCliCommands.ts
index e65b41e8ab2..2f5596942cf 100644
--- a/extension/src/activation/registerCliCommands.ts
+++ b/extension/src/activation/registerCliCommands.ts
@@ -28,9 +28,19 @@ import { getCliPathTargetForUri } from '../utils/cliPathVariables';
interface CommandInvocation {
readonly target: CliPathResolutionTarget;
readonly appHost?: AppHostCommandTarget;
+ readonly cliPath?: string;
}
type CommandSource = 'command_palette' | 'tree';
+const cliCheckExcludedCommands = new Set([
+ 'aspire-vscode.settings',
+ 'aspire-vscode.configureLaunchJson',
+ 'aspire-vscode.updateSelf',
+]);
+const cliCheckDeferredCommands = new Set([
+ 'aspire-vscode.deploy',
+ 'aspire-vscode.publish',
+]);
export function registerCliCommands(
terminalProvider: AspireTerminalProvider,
@@ -48,7 +58,12 @@ export function registerCliCommands(
const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', terminalProvider, () => publishCommand(editorCommandProvider)));
const cliDoCommandRegistration = vscode.commands.registerCommand('aspire-vscode.do', () => tryExecuteCommand('aspire-vscode.do', terminalProvider, (_tp, invocation, cliPath) => doCommand(configInfoProvider, editorCommandProvider, invocation.appHost?.appHostPath, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider, true)));
const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, (tp, invocation, cliPath) => updateCommand(tp, editorCommandProvider, invocation.appHost ?? {}, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider)));
- const cliUpdateSelfCommandRegistration = vscode.commands.registerCommand('aspire-vscode.updateSelf', () => tryExecuteCommand('aspire-vscode.updateSelf', terminalProvider, updateSelfCommand));
+ const cliUpdateSelfCommandRegistration = vscode.commands.registerCommand('aspire-vscode.updateSelf', (target: CliPathResolutionTarget = windowCliPathTarget, cliPath?: string) =>
+ tryExecuteCommand(
+ 'aspire-vscode.updateSelf',
+ terminalProvider,
+ (tp, invocation, resolvedCliPath) => updateSelfCommand(tp, invocation.target, resolvedCliPath || undefined),
+ async () => ({ target, cliPath })));
const openTerminalCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openTerminal', () => tryExecuteCommand('aspire-vscode.openTerminal', terminalProvider, (tp, invocation, cliPath) => openTerminalCommand(tp, invocation.target, cliPath), selectCommandInvocation));
const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', terminalProvider, configureLaunchJsonCommand));
const settingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.settings', () => tryExecuteCommand('aspire-vscode.settings', terminalProvider, settingsCommand));
@@ -142,9 +157,8 @@ async function tryExecuteCommand(
try {
await withCommandTelemetry(commandName, async () => {
const invocation = await prepareInvocation();
- let cliPath = '';
- const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson", "aspire-vscode.updateSelf"];
- if (!cliCheckExcludedCommands.includes(commandName)) {
+ let cliPath = invocation.cliPath ?? '';
+ if (!cliCheckExcludedCommands.has(commandName)) {
if (isE2eBridgeEnabled() && process.env.ASPIRE_EXTENSION_E2E_FORCE_CLI_UNAVAILABLE === 'true') {
vscode.window.showErrorMessage(
cliNotAvailable,
@@ -154,16 +168,17 @@ async function tryExecuteCommand(
throw new vscode.CancellationError();
}
- const result = await checkCliAvailableOrRedirect('command_gate', invocation.target);
- if (!result.available) {
- // The command body never ran — the user was redirected to install the
- // CLI. Throwing a cancellation makes withCommandTelemetry record this
- // as `canceled` rather than a false `success`, and the catch below
- // suppresses the error toast (the redirect already informed the user).
- throw new vscode.CancellationError();
+ if (!cliCheckDeferredCommands.has(commandName)) {
+ const result = await checkCliAvailableOrRedirect('command_gate', invocation.target);
+ if (!result.available) {
+ // The command body never ran — the user was redirected to install the
+ // CLI. Throwing a cancellation makes withCommandTelemetry record this
+ // as `canceled` rather than a false `success`, and the catch below
+ // suppresses the error toast (the redirect already informed the user).
+ throw new vscode.CancellationError();
+ }
+ cliPath = result.cliPath;
}
- cliPath = result.cliPath;
-
}
await command(terminalProvider, invocation, cliPath);
diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts
index 06a96bb76df..0cdacb8eba4 100644
--- a/extension/src/commands/update.ts
+++ b/extension/src/commands/update.ts
@@ -13,6 +13,11 @@ export async function updateCommand(
await terminalProvider.sendAspireCommandToAspireTerminal('update', true, appHost.args, { target, cliPath });
}
-export async function updateSelfCommand(terminalProvider: AspireTerminalProvider) {
- await terminalProvider.sendAspireCommandToAspireTerminal('update --self', true, undefined, { target: windowCliPathTarget });
+export async function updateSelfCommand(
+ terminalProvider: AspireTerminalProvider,
+ target: CliPathResolutionTarget = windowCliPathTarget,
+ cliPath?: string,
+) {
+ const resolvedCliPath = cliPath ?? await terminalProvider.getAspireCliExecutablePath(target);
+ await terminalProvider.sendAspireCommandToAspireTerminal('update --self', true, undefined, { target, cliPath: resolvedCliPath });
}
diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts
index 8e247453a06..1663c31e860 100644
--- a/extension/src/data/AppHostDataRepository.ts
+++ b/extension/src/data/AppHostDataRepository.ts
@@ -18,6 +18,7 @@ import { isMatchingAppHostInstance, isMatchingAppHostPath, isPathInWorkspace } f
import { AppHostPsPoller } from './appHostPsPoller';
import { filterResourceCommandStatusOutput } from './resourceCommandStatusOutput';
import { getCliPathTargetForUri } from '../utils/cliPathVariables';
+import { reportCliResolvedForOperation } from '../utils/cliOperationResolution';
export * from './appHostCliContracts';
export { shortenPath, shortenPaths };
@@ -1013,6 +1014,7 @@ export class AppHostDataRepository {
if (this._disposed || this._describeStreams.get(appHostPath) !== stream || startVersion !== stream.version) {
return;
}
+ reportCliResolvedForOperation(target, cliPath);
// The capability is a property of the CLI this AppHost resolves to, not of the window: a
// multi-root workspace can point each folder at a different aspire.cliPath, so a single
diff --git a/extension/src/data/appHostCliRunner.ts b/extension/src/data/appHostCliRunner.ts
index f0fd7ee57ec..cd3d1f50c22 100644
--- a/extension/src/data/appHostCliRunner.ts
+++ b/extension/src/data/appHostCliRunner.ts
@@ -8,6 +8,7 @@ import { isNoLogoUnsupportedOutput, noLogoOption, removeRootNoLogoOption } from
import { AspireCliFailedError, AspireCliNotInstalledError } from './appHostCliContracts';
import { normalizeResourceCommandStatusLine } from './resourceCommandStatusOutput';
import { CliPathResolutionTarget, windowCliPathTarget } from '../utils/cliPathVariables';
+import { reportCliResolvedForOperation } from '../utils/cliOperationResolution';
export const oneShotOutputBufferLimit = 64 * 1024;
@@ -78,14 +79,16 @@ export class AppHostCliRunner implements vscode.Disposable {
}
async runCliCommand(command: string, args: string[], options: RunCliCommandOptions = {}): Promise<{ stdout: string; stderr: string }> {
+ const target = options.target ?? windowCliPathTarget;
const cliPath = options.cliPath
- ?? await this._terminalProvider.getAspireCliExecutablePath(options.target ?? windowCliPathTarget).catch(error => {
+ ?? await this._terminalProvider.getAspireCliExecutablePath(target).catch(error => {
throw new AspireCliNotInstalledError(String(error));
});
if (options.cancellationToken?.isCancellationRequested) {
throw new vscode.CancellationError();
}
+ reportCliResolvedForOperation(target, cliPath);
const invocationArgs = this.normalizeNoLogoArgs(cliPath, args);
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
diff --git a/extension/src/data/appHostPsPoller.ts b/extension/src/data/appHostPsPoller.ts
index 69e571048b3..4b6832549d3 100644
--- a/extension/src/data/appHostPsPoller.ts
+++ b/extension/src/data/appHostPsPoller.ts
@@ -6,6 +6,7 @@ import { extensionLogOutputChannel } from '../utils/logging';
import { errorFetchingAppHosts } from '../loc/strings';
import { AppHostCliRunner, LimitedOutputBuffer, oneShotOutputBufferLimit } from './appHostCliRunner';
import { windowCliPathTarget } from '../utils/cliPathVariables';
+import { reportCliResolvedForOperation } from '../utils/cliOperationResolution';
export interface PsOutput {
readonly stdout: string;
@@ -160,6 +161,7 @@ export class AppHostPsPoller implements vscode.Disposable {
if (!this._isCurrentPsFetch(fetchVersion)) {
return;
}
+ reportCliResolvedForOperation(windowCliPathTarget, cliPath);
let psProcess: ChildProcessWithoutNullStreams | undefined;
let psProcessCompletedSynchronously = false;
@@ -345,6 +347,7 @@ export class AppHostPsPoller implements vscode.Disposable {
if (!isCurrentPsCommand()) {
return;
}
+ reportCliResolvedForOperation(windowCliPathTarget, cliPath);
const invocationArgs = this._cliRunner.normalizeNoLogoArgs(cliPath, args);
let stdout = '';
diff --git a/extension/src/extension.ts b/extension/src/extension.ts
index 8a17c9aeaea..2f32bcac9bf 100644
--- a/extension/src/extension.ts
+++ b/extension/src/extension.ts
@@ -38,6 +38,9 @@ import { registerCliCommands } from './activation/registerCliCommands';
import { registerTreeViewCommands } from './activation/registerTreeViewCommands';
import { registerCodeLensCommands } from './activation/registerCodeLensCommands';
import { initializeHotReloadAdvisory } from './debugger/hotReload';
+import { OutdatedCliNotifier } from './utils/outdatedCliNotifier';
+import { onDidResolveCliForOperation } from './utils/cliOperationResolution';
+import { FileSystemOutdatedCliSuppressionStore } from './utils/outdatedCliSuppressionStore';
let aspireExtensionContext = new AspireExtensionContext();
@@ -101,6 +104,17 @@ export async function activate(context: vscode.ExtensionContext) {
terminalProvider.closeAllOpenAspireTerminals();
const configInfoProvider = new ConfigInfoProvider(terminalProvider);
+ const outdatedCliNotifier = new OutdatedCliNotifier(
+ configInfoProvider,
+ undefined,
+ Date.now,
+ new FileSystemOutdatedCliSuppressionStore(context.globalStorageUri.fsPath));
+ context.subscriptions.push(outdatedCliNotifier);
+ context.subscriptions.push(onDidResolveCliForOperation(({ target, cliPath }) => {
+ void outdatedCliNotifier.notifyIfOutdated(target, cliPath).catch(error => {
+ extensionLogOutputChannel.warn(`Unable to check Aspire CLI version: ${String(error)}`);
+ });
+ }));
const appHostDiscoveryService = new AppHostDiscoveryService(terminalProvider, configInfoProvider);
context.subscriptions.push(appHostDiscoveryService);
diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts
index 083df352649..47c4461f15d 100644
--- a/extension/src/loc/strings.ts
+++ b/extension/src/loc/strings.ts
@@ -185,6 +185,8 @@ export const failedToGetConfigInfo = (exitCode: number) => vscode.l10n.t('Failed
export const failedToParseConfigInfo = (error: any) => vscode.l10n.t('Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error);
export const errorGettingConfigInfo = (error: any) => vscode.l10n.t('Error getting Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error);
export const configInfoTimedOut = (seconds: number) => vscode.l10n.t('Aspire config info timed out after {0} seconds.', seconds);
+export const outdatedAspireCliWarning = (version: string, cliPath: string, recommendedVersion: string) => vscode.l10n.t('Aspire CLI {0} at {1} has a newer version available for its current channel: {2}.', version, cliPath, recommendedVersion);
+export const updateAspireCliAction = vscode.l10n.t({ message: 'Update Aspire CLI', comment: 'Button label that updates the installed Aspire CLI.' });
export const invalidLaunchConfiguration = (projectPath: string) => vscode.l10n.t('Invalid launch configuration for {0}.', projectPath);
export const browserDisplayName = (url: string) => vscode.l10n.t('Browser: {0}', url);
export const browserLabel = vscode.l10n.t('Browser');
diff --git a/extension/src/services/AppHostStopper.ts b/extension/src/services/AppHostStopper.ts
index 6101cf0273e..8961365651a 100644
--- a/extension/src/services/AppHostStopper.ts
+++ b/extension/src/services/AppHostStopper.ts
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
import { spawnCliProcess, terminateCliProcess } from '../utils/process/cliProcess';
import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
import { getCliPathTargetForUri } from '../utils/cliPathVariables';
+import { reportCliResolvedForOperation } from '../utils/cliOperationResolution';
const maxRetainedStderrLength = 16 * 1024;
@@ -11,10 +12,12 @@ export async function stopExternalAppHost(
cancellationToken: vscode.CancellationToken,
): Promise {
- const cliPath = await terminalProvider.getAspireCliExecutablePath(getCliPathTargetForUri(vscode.Uri.file(appHostPath)));
+ const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath));
+ const cliPath = await terminalProvider.getAspireCliExecutablePath(target);
if (cancellationToken.isCancellationRequested) {
throw new vscode.CancellationError();
}
+ reportCliResolvedForOperation(target, cliPath);
await new Promise((resolve, reject) => {
let settled = false;
diff --git a/extension/src/test/appHostCliRunner.test.ts b/extension/src/test/appHostCliRunner.test.ts
index 9481fb39fc0..999e1337b51 100644
--- a/extension/src/test/appHostCliRunner.test.ts
+++ b/extension/src/test/appHostCliRunner.test.ts
@@ -7,6 +7,7 @@ import { AspireCliFailedError } from '../data/appHostCliContracts';
import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables';
import * as cliModule from '../utils/process/cliProcess';
+import { onDidResolveCliForOperation } from '../utils/cliOperationResolution';
class TestChildProcess extends EventEmitter {
killed = false;
@@ -150,6 +151,8 @@ suite('data/appHostCliRunner tests', () => {
});
const runner = new AppHostCliRunner(terminalProvider);
+ const resolutions: string[] = [];
+ const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath));
try {
await runner.runCliCommand('list pipeline steps', ['do', '--list-steps'], {
cliPath: '/repo/tools/aspire',
@@ -157,7 +160,10 @@ suite('data/appHostCliRunner tests', () => {
assert.strictEqual(getCliPathStub.called, false);
assert.strictEqual(spawnStub.firstCall.args[1], '/repo/tools/aspire');
- } finally {
+ assert.deepStrictEqual(resolutions, ['/repo/tools/aspire']);
+ }
+ finally {
+ subscription.dispose();
runner.dispose();
}
});
diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts
index ce0764f33e1..d1eb7c30b9e 100644
--- a/extension/src/test/appHostDataRepository.test.ts
+++ b/extension/src/test/appHostDataRepository.test.ts
@@ -15,6 +15,7 @@ import * as configInfoProvider from '../utils/configInfoProvider';
import { describeIncludeDisabledCommandsCapability, lsJsonStreamCapability } from '../types/configInfo';
import { errorFetchingAppHosts } from '../loc/strings';
import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables';
+import { onDidResolveCliForOperation } from '../utils/cliOperationResolution';
import { removeDirectorySafely } from './testHelpers';
class TestChildProcess extends EventEmitter {
@@ -2745,6 +2746,31 @@ suite('AppHostDataRepository', () => {
}
});
+ test('describe reports the exact CLI path for each active workspace folder', async () => {
+ const resolutions: Array<{ folder: string; cliPath: string }> = [];
+ const resolutionSubscription = onDidResolveCliForOperation(resolution => {
+ if (resolution.target.kind === 'workspaceFolder') {
+ resolutions.push({
+ folder: resolution.target.workspaceFolder.name,
+ cliPath: resolution.cliPath,
+ });
+ }
+ });
+
+ const context = await startTwoFolderDescribeStreams();
+
+ try {
+ assert.deepStrictEqual(resolutions.sort((left, right) => left.folder.localeCompare(right.folder)), [
+ { folder: 'peer', cliPath: '/cli/peer/aspire' },
+ { folder: 'selected', cliPath: '/cli/selected/aspire' },
+ ]);
+ }
+ finally {
+ resolutionSubscription.dispose();
+ context.dispose();
+ }
+ });
+
test('working peer CLI does not clear the selected AppHost compatibility error', async () => {
const context = await startTwoFolderDescribeStreams();
diff --git a/extension/src/test/appHostStopper.test.ts b/extension/src/test/appHostStopper.test.ts
index 4d63237c27f..3ad66201e23 100644
--- a/extension/src/test/appHostStopper.test.ts
+++ b/extension/src/test/appHostStopper.test.ts
@@ -7,6 +7,7 @@ import * as vscode from 'vscode';
import { stopExternalAppHost } from '../services/AppHostStopper';
import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables';
+import { onDidResolveCliForOperation } from '../utils/cliOperationResolution';
suite('AppHostStopper', () => {
test('waits for aspire stop to exit successfully', async () => {
@@ -53,6 +54,8 @@ suite('AppHostStopper', () => {
createEnvironment: () => ({}),
sendAspireCommandToAspireTerminal: async () => { },
} as unknown as AspireTerminalProvider;
+ const resolutions: Array<{ target: unknown; cliPath: string }> = [];
+ const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution));
try {
const stopping = stopExternalAppHost(
@@ -62,12 +65,17 @@ suite('AppHostStopper', () => {
await new Promise(resolve => setImmediate(resolve));
assert.ok(getAspireCliExecutablePathStub.calledOnceWith(workspaceFolderCliPathTarget(folder)));
+ assert.deepStrictEqual(resolutions, [{
+ target: workspaceFolderCliPathTarget(folder),
+ cliPath: '/repo/bin/aspire',
+ }]);
childState.exitCode = 0;
child.emit('close', 0);
await stopping;
}
finally {
+ subscription.dispose();
getWorkspaceFolderStub.restore();
(nodeChildProcess.spawn as sinon.SinonStub).restore();
}
diff --git a/extension/src/test/aspirePackageRestoreProvider.test.ts b/extension/src/test/aspirePackageRestoreProvider.test.ts
index a0137900cb6..b8df9005161 100644
--- a/extension/src/test/aspirePackageRestoreProvider.test.ts
+++ b/extension/src/test/aspirePackageRestoreProvider.test.ts
@@ -1,13 +1,17 @@
import * as assert from 'assert';
import { ChildProcessWithoutNullStreams } from 'child_process';
import { EventEmitter } from 'events';
+import * as fs from 'fs';
+import * as os from 'os';
import * as path from 'path';
import * as sinon from 'sinon';
import * as vscode from 'vscode';
import { AspirePackageRestoreProvider } from '../utils/AspirePackageRestoreProvider';
import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables';
+import { onDidResolveCliForOperation } from '../utils/cliOperationResolution';
import * as cliProcessModule from '../utils/process/cliProcess';
+import * as workspaceModule from '../utils/workspace';
suite('AspirePackageRestoreProvider', () => {
let sandbox: sinon.SinonSandbox;
@@ -32,9 +36,11 @@ suite('AspirePackageRestoreProvider', () => {
});
return childProcess as unknown as ChildProcessWithoutNullStreams;
});
+ const resolutions: string[] = [];
+ const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath));
try {
- await (provider as any)._runRestore(configUri, folder.uri.fsPath, 'aspire.config.json');
+ await (provider as any)._runRestore(configUri, folder.uri.fsPath, 'aspire.config.json', false);
assert.ok(getAspireCliExecutablePath.calledOnceWith(workspaceFolderCliPathTarget(folder)));
assert.ok(spawnStub.calledOnceWith(
@@ -42,11 +48,139 @@ suite('AspirePackageRestoreProvider', () => {
'/repo/workspace/bin/aspire',
['restore'],
sinon.match({ workingDirectory: folder.uri.fsPath })));
+ assert.deepStrictEqual(resolutions, []);
} finally {
+ subscription.dispose();
provider.dispose();
}
});
+ test('reports the exact CLI selected for a manual restore', async () => {
+ const folder = createWorkspaceFolder('/repo/workspace');
+ const configUri = vscode.Uri.file(path.join(folder.uri.fsPath, 'aspire.config.json'));
+ sandbox.stub(vscode.workspace, 'getWorkspaceFolder').withArgs(configUri).returns(folder);
+ const getAspireCliExecutablePath = sandbox.stub().resolves('/repo/workspace/bin/aspire');
+ const provider = new AspirePackageRestoreProvider({ getAspireCliExecutablePath } as unknown as AspireTerminalProvider);
+ const childProcess = createChildProcess();
+ sandbox.stub(cliProcessModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => {
+ queueMicrotask(() => {
+ options?.exitCallback?.(0);
+ childProcess.emit('close', 0);
+ });
+ return childProcess as unknown as ChildProcessWithoutNullStreams;
+ });
+ const resolutions: Array<{ target: unknown; cliPath: string }> = [];
+ const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution));
+
+ try {
+ await (provider as any)._runRestore(configUri, folder.uri.fsPath, 'aspire.config.json', true);
+
+ assert.deepStrictEqual(resolutions, [{
+ target: workspaceFolderCliPathTarget(folder),
+ cliPath: '/repo/workspace/bin/aspire',
+ }]);
+ } finally {
+ subscription.dispose();
+ provider.dispose();
+ }
+ });
+
+ test('runs and reports a manual restore when auto-restore is disabled', async () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-manual-restore-'));
+ const folder = createWorkspaceFolder(directory);
+ const configUri = vscode.Uri.file(path.join(folder.uri.fsPath, 'aspire.config.json'));
+ fs.writeFileSync(configUri.fsPath, '{}');
+ sandbox.stub(vscode.workspace, 'getWorkspaceFolder').withArgs(configUri).returns(folder);
+ sandbox.stub(vscode.workspace, 'getConfiguration').returns({
+ get: () => false as T,
+ } as unknown as vscode.WorkspaceConfiguration);
+ const getAspireCliExecutablePath = sandbox.stub().resolves('/repo/workspace/bin/aspire');
+ const provider = new AspirePackageRestoreProvider({ getAspireCliExecutablePath } as unknown as AspireTerminalProvider);
+ const childProcess = createChildProcess();
+ const spawnStub = sandbox.stub(cliProcessModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => {
+ queueMicrotask(() => {
+ options?.exitCallback?.(0);
+ childProcess.emit('close', 0);
+ });
+ return childProcess as unknown as ChildProcessWithoutNullStreams;
+ });
+ const resolutions: string[] = [];
+ const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath));
+
+ try {
+ await (provider as any)._restoreIfChanged(configUri, true, true);
+
+ assert.strictEqual(spawnStub.callCount, 1);
+ assert.deepStrictEqual(resolutions, ['/repo/workspace/bin/aspire']);
+ } finally {
+ subscription.dispose();
+ provider.dispose();
+ fs.rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
+ }
+ });
+
+ test('preserves a manual restore queued behind an active automatic restore', async () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-queued-restore-'));
+ const folder = createWorkspaceFolder(directory);
+ const configUri = vscode.Uri.file(path.join(folder.uri.fsPath, 'aspire.config.json'));
+ fs.writeFileSync(configUri.fsPath, '{}');
+ sandbox.stub(vscode.workspace, 'getWorkspaceFolder').withArgs(configUri).returns(folder);
+ sandbox.stub(vscode.workspace, 'getConfiguration').returns({
+ get: () => true as T,
+ } as unknown as vscode.WorkspaceConfiguration);
+ sandbox.stub(workspaceModule, 'findAspireSettingsFiles').resolves([configUri]);
+ const getAspireCliExecutablePath = sandbox.stub().resolves('/repo/workspace/bin/aspire');
+ const provider = new AspirePackageRestoreProvider({ getAspireCliExecutablePath } as unknown as AspireTerminalProvider);
+ const childProcesses = [createChildProcess(), createChildProcess()];
+ const restoreCompletions: Array<() => void> = [];
+ let signalFirstSpawn!: () => void;
+ let signalSecondSpawn!: () => void;
+ const firstSpawned = new Promise(resolve => signalFirstSpawn = resolve);
+ const secondSpawned = new Promise(resolve => signalSecondSpawn = resolve);
+ let spawnIndex = 0;
+ const spawnStub = sandbox.stub(cliProcessModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => {
+ const childProcess = childProcesses.shift()!;
+ restoreCompletions.push(() => {
+ options?.exitCallback?.(0);
+ childProcess.emit('close', 0);
+ });
+ (spawnIndex++ === 0 ? signalFirstSpawn : signalSecondSpawn)();
+ return childProcess as unknown as ChildProcessWithoutNullStreams;
+ });
+ const resolutions: string[] = [];
+ const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath));
+ let signalQueued!: () => void;
+ const queued = new Promise(resolve => signalQueued = resolve);
+ const pendingRestore = provider['_pendingRestore'];
+ const setPendingRestore = pendingRestore.set.bind(pendingRestore);
+ sandbox.stub(pendingRestore, 'set').callsFake((key, value) => {
+ const result = setPendingRestore(key, value);
+ signalQueued();
+ return result;
+ });
+
+ try {
+ const automaticRestore = (provider as any)._restoreIfChanged(configUri, true, false) as Promise;
+ await firstSpawned;
+ const manualRestore = provider.retryRestore();
+ await queued;
+
+ restoreCompletions.shift()?.();
+ await secondSpawned;
+ restoreCompletions.shift()?.();
+ await Promise.all([automaticRestore, manualRestore]);
+
+ assert.strictEqual(spawnStub.callCount, 2);
+ assert.deepStrictEqual(resolutions, ['/repo/workspace/bin/aspire']);
+ assert.strictEqual(provider['_completed'], 2);
+ assert.strictEqual(provider['_total'], 2);
+ } finally {
+ subscription.dispose();
+ provider.dispose();
+ fs.rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
+ }
+ });
+
test('does not spawn restore when disposed during CLI resolution', async () => {
let resolveCliPath!: (cliPath: string) => void;
const cliPath = new Promise(resolve => {
@@ -64,7 +198,11 @@ suite('AspirePackageRestoreProvider', () => {
});
const configUri = vscode.Uri.file('/repo/workspace/aspire.config.json');
- const restore = (provider as any)._runRestore(configUri, '/repo/workspace', 'aspire.config.json') as Promise;
+ const restore = (provider as any)._runRestore(
+ configUri,
+ '/repo/workspace',
+ 'aspire.config.json',
+ false) as Promise;
assert.ok(getAspireCliExecutablePath.calledOnce);
provider.dispose();
resolveCliPath('/repo/workspace/bin/aspire');
diff --git a/extension/src/test/aspireTerminalProvider.test.ts b/extension/src/test/aspireTerminalProvider.test.ts
index 2d78e2f949d..388b922d8b3 100644
--- a/extension/src/test/aspireTerminalProvider.test.ts
+++ b/extension/src/test/aspireTerminalProvider.test.ts
@@ -11,6 +11,7 @@ import { createWorkspaceFolder, removeDirectorySafely } from './testHelpers';
import { EnvironmentVariables } from '../utils/environment';
import { extensionLogOutputChannel } from '../utils/logging';
import { terminalCommandArgumentControlCharacters, terminalCommandUnsafeLiteral } from '../loc/strings';
+import { onDidResolveCliForOperation } from '../utils/cliOperationResolution';
suite('AspireTerminalProvider tests', () => {
let terminalProvider: AspireTerminalProvider;
@@ -303,6 +304,8 @@ suite('AspireTerminalProvider tests', () => {
show: () => { },
dispose: () => { },
} as unknown as vscode.Terminal);
+ const resolutions: string[] = [];
+ const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath));
try {
await terminalProvider.sendAspireCommandToAspireTerminal('logs', false, undefined, { target });
@@ -313,8 +316,10 @@ suite('AspireTerminalProvider tests', () => {
assert.ok(executedCommand?.includes(cliPath), executedCommand);
assert.ok(executedCommand?.endsWith(' logs'), executedCommand);
assert.ok(createEnvironmentStub.calledOnceWith(undefined, undefined, undefined, cliPath));
+ assert.deepStrictEqual(resolutions, [cliPath]);
}
finally {
+ subscription.dispose();
createTerminalStub.restore();
createEnvironmentStub.restore();
}
@@ -336,6 +341,8 @@ suite('AspireTerminalProvider tests', () => {
show: () => { },
dispose: () => { },
} as unknown as vscode.Terminal);
+ const resolutions: string[] = [];
+ const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath));
try {
await terminalProvider.sendAspireCommandToAspireTerminal('new', false, undefined, { target, cliPath });
@@ -343,8 +350,10 @@ suite('AspireTerminalProvider tests', () => {
assert.strictEqual(resolveCliPathStub.called, false);
assert.ok(executedCommand?.includes(cliPath), executedCommand);
assert.strictEqual(createEnvironmentStub.firstCall.args[3], cliPath);
+ assert.deepStrictEqual(resolutions, []);
}
finally {
+ subscription.dispose();
createTerminalStub.restore();
createEnvironmentStub.restore();
}
diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts
index 7a0b09014c4..9a942fa5399 100644
--- a/extension/src/test/configInfoProvider.test.ts
+++ b/extension/src/test/configInfoProvider.test.ts
@@ -4,7 +4,10 @@ import { EventEmitter } from 'events';
import * as sinon from 'sinon';
import * as vscode from 'vscode';
import type { ChildProcessWithoutNullStreams } from 'child_process';
-import { ConfigInfoProvider, getConfigInfo, parseConfigInfoOutput } from '../utils/configInfoProvider';
+import { mkdtemp, rename, rm, writeFile } from 'fs/promises';
+import * as os from 'os';
+import * as path from 'path';
+import { ConfigInfoProvider, getConfigInfo, parseCliUpdateRecommendationOutput, parseConfigInfoOutput } from '../utils/configInfoProvider';
import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
import * as cliModule from '../utils/process/cliProcess';
import { AppHostDiscoveryService } from '../utils/appHostDiscovery';
@@ -24,6 +27,31 @@ function emitConfigInfo(options: cliModule.SpawnProcessOptions | undefined, capa
options?.exitCallback?.(0);
}
+function createDoctorVersionOutput(
+ currentVersion: string,
+ latestVersion?: string,
+ updateCheckError?: string,
+ identityChannel: string | null = 'stable',
+ latestVersionChannel: string | null = latestVersion
+ ? (latestVersion.includes('-') ? 'prerelease' : 'stable')
+ : null,
+): string {
+ return JSON.stringify({
+ checks: [{
+ name: 'cli-version',
+ metadata: {
+ currentVersion,
+ latestVersion,
+ updateCheckError,
+ ...(identityChannel === null ? {} : { identityChannel }),
+ ...(latestVersionChannel === null ? {} : { latestVersionChannel }),
+ },
+ }],
+ summary: { passed: 0, warnings: 0, failed: 0 },
+ installations: [],
+ });
+}
+
suite('configInfoProvider tests', () => {
teardown(() => sinon.restore());
@@ -267,6 +295,182 @@ suite('configInfoProvider tests', () => {
assert.deepStrictEqual(spawnStub.secondCall.args[2], ['config', 'info', '--json']);
});
+ test('parseCliUpdateRecommendationOutput accepts stable and prerelease recommendations', () => {
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.4.0', '13.5.2')),
+ { status: 'available', currentVersion: '13.4.0', version: '13.5.2' });
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.5.0', '13.6.0')),
+ { status: 'available', currentVersion: '13.5.0', version: '13.6.0' });
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.6.0-preview.2', '13.7.0-preview.1', undefined, 'daily')),
+ { status: 'available', currentVersion: '13.6.0-preview.2', version: '13.7.0-preview.1' });
+ // Doctor reports only one stable-first recommendation. Mark that cross-lane result
+ // ineligible because an unchanged prerelease identity cannot make it actionable.
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.6.0-preview.2', '13.6.0', undefined, 'daily')),
+ { status: 'ineligible', currentVersion: '13.6.0-preview.2' });
+ // The CLI's stable update rule cannot produce this payload, but reject it defensively so a
+ // stable installation is never nudged onto a prerelease channel.
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.6.0', '13.7.0-preview.1')),
+ { status: 'ineligible', currentVersion: '13.6.0' });
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.6.0')),
+ { status: 'none', currentVersion: '13.6.0' });
+ for (const identityChannel of ['local', 'pr-19670', 'run-42', 'default', 'future']) {
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.6.0-dev', '13.7.0-preview.1', undefined, identityChannel)),
+ { status: 'ineligible', currentVersion: '13.6.0-dev' });
+ }
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.6.0-preview.1', '13.7.0-preview.1', undefined, null)),
+ { status: 'ineligible', currentVersion: '13.6.0-preview.1' });
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.6.0-preview.1', '13.7.0-preview.1', undefined, 'daily', null)),
+ { status: 'available', currentVersion: '13.6.0-preview.1', version: '13.7.0-preview.1' });
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.6.0-dev', undefined, 'offline', 'local')),
+ { status: 'ineligible', currentVersion: '13.6.0-dev' });
+ assert.deepStrictEqual(parseCliUpdateRecommendationOutput(
+ createDoctorVersionOutput('13.5.0', undefined, 'offline')),
+ { status: 'unavailable' });
+ });
+
+ test('getCliUpdateRecommendation accepts structured doctor output on a nonzero exit', async () => {
+ const terminalProvider = {
+ getAspireCliExecutablePath: async () => '/unused/aspire',
+ createEnvironment: () => ({}),
+ } as unknown as AspireTerminalProvider;
+ const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => {
+ assert.strictEqual(command, '/exact/aspire');
+ assert.deepStrictEqual(args, ['doctor', '--format', 'json', '--nologo']);
+ assert.deepStrictEqual(options?.env, [{ name: 'ASPIRE_NON_INTERACTIVE', value: 'true' }]);
+ assert.strictEqual(options?.workingDirectory, '/captured/workspace');
+ const output = JSON.parse(createDoctorVersionOutput('13.5.0', '13.6.0'));
+ output.checks.push({
+ name: 'unrelated-check',
+ details: 'A nested command used --nologo.',
+ });
+ options?.stdoutCallback?.(JSON.stringify(output));
+ // `aspire doctor` exits nonzero when an unrelated prerequisite check fails, but its
+ // structured CLI update metadata is still valid. Text from another check must not be
+ // mistaken for the root command rejecting --nologo.
+ options?.exitCallback?.(1);
+ return {} as ChildProcessWithoutNullStreams;
+ });
+ const provider = new ConfigInfoProvider(terminalProvider);
+
+ assert.deepStrictEqual(
+ await provider.getCliUpdateRecommendation({
+ cliPath: '/exact/aspire',
+ workingDirectory: '/captured/workspace',
+ }),
+ { status: 'available', currentVersion: '13.5.0', version: '13.6.0' });
+ assert.strictEqual(spawnStub.callCount, 1);
+ });
+
+ test('getCliUpdateRecommendation retries without nologo and keeps unavailable checks silent', async () => {
+ const terminalProvider = {
+ getAspireCliExecutablePath: async () => '/unused/aspire',
+ createEnvironment: () => ({}),
+ } as unknown as AspireTerminalProvider;
+ let attempt = 0;
+ const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => {
+ if (attempt++ === 0) {
+ options?.stderrCallback?.("Unrecognized command or argument '--nologo'.");
+ options?.exitCallback?.(1);
+ } else {
+ options?.stdoutCallback?.('not json');
+ options?.exitCallback?.(0);
+ }
+ return {} as ChildProcessWithoutNullStreams;
+ });
+ const showErrorMessage = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined);
+ const provider = new ConfigInfoProvider(terminalProvider);
+
+ assert.deepStrictEqual(
+ await provider.getCliUpdateRecommendation({ cliPath: '/exact/aspire' }),
+ { status: 'unavailable' });
+ assert.deepStrictEqual(spawnStub.getCalls().map(call => call.args[2]), [
+ ['doctor', '--format', 'json', '--nologo'],
+ ['doctor', '--format', 'json'],
+ ]);
+ assert.strictEqual(showErrorMessage.callCount, 0);
+ });
+
+ test('getCliVersion identifies an executable replaced with the same version', async () => {
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'aspire-cli-version-'));
+ const cliPath = path.join(directory, 'aspire');
+ const replacementPath = path.join(directory, 'replacement');
+ await writeFile(cliPath, 'first executable');
+ const terminalProvider = {
+ getAspireCliExecutablePath: async () => cliPath,
+ createEnvironment: () => ({}),
+ } as unknown as AspireTerminalProvider;
+ const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => {
+ assert.strictEqual(command, cliPath);
+ assert.deepStrictEqual(args, ['--version']);
+ options?.stdoutCallback?.('13.5.0');
+ options?.exitCallback?.(0);
+ return {} as ChildProcessWithoutNullStreams;
+ });
+ const provider = new ConfigInfoProvider(terminalProvider);
+
+ try {
+ const first = await provider.getCliVersion({ cliPath });
+ await writeFile(replacementPath, 'replacement executable');
+ await rename(replacementPath, cliPath);
+ const second = await provider.getCliVersion({ cliPath });
+
+ assert.strictEqual(first?.version, '13.5.0');
+ assert.strictEqual(second?.version, '13.5.0');
+ assert.notStrictEqual(first?.executableIdentity, second?.executableIdentity);
+ assert.strictEqual(spawnStub.callCount, 2);
+ }
+ finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test('version and update probes do not settle before cancellation termination completes', async () => {
+ const terminalProvider = {
+ getAspireCliExecutablePath: async () => '/unused/aspire',
+ createEnvironment: () => ({}),
+ } as unknown as AspireTerminalProvider;
+ const childProcess = { kill: () => true } as unknown as ChildProcessWithoutNullStreams;
+ sinon.stub(cliModule, 'spawnCliProcess').returns(childProcess);
+ const terminations: Array<() => void> = [];
+ sinon.stub(cliModule, 'terminateCliProcess').callsFake(() =>
+ new Promise(resolve => terminations.push(resolve)));
+ const provider = new ConfigInfoProvider(terminalProvider);
+
+ for (const startProbe of [
+ (cancellation: vscode.CancellationTokenSource) => provider.getCliVersion({
+ cliPath: '/exact/aspire',
+ cancellationToken: cancellation.token,
+ }),
+ (cancellation: vscode.CancellationTokenSource) => provider.getCliUpdateRecommendation({
+ cliPath: '/exact/aspire',
+ cancellationToken: cancellation.token,
+ }),
+ ]) {
+ const cancellation = new vscode.CancellationTokenSource();
+ const probe = startProbe(cancellation);
+ let settled = false;
+ void probe.then(() => settled = true);
+
+ cancellation.cancel();
+ await Promise.resolve();
+ assert.strictEqual(settled, false);
+
+ terminations.shift()?.();
+ await probe;
+ assert.strictEqual(settled, true);
+ cancellation.dispose();
+ }
+ });
+
test('getCapabilityStatus uses advertised capabilities before the minimum-version fallback', async () => {
const terminalProvider = {
getAspireCliExecutablePath: async () => '/unused/aspire',
diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts
new file mode 100644
index 00000000000..84f5e0de2ed
--- /dev/null
+++ b/extension/src/test/outdatedCliNotifier.test.ts
@@ -0,0 +1,602 @@
+import * as assert from 'assert';
+import * as sinon from 'sinon';
+import * as vscode from 'vscode';
+import * as strings from '../loc/strings';
+import {
+ CliUpdateRecommendation,
+ CliUpdateRecommendationOptions,
+ CliVersionInfo,
+ CliVersionStatusOptions,
+} from '../utils/configInfoProvider';
+import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables';
+import { OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier';
+import {
+ OutdatedCliNotificationClaim,
+ OutdatedCliSuppressionStore,
+} from '../utils/outdatedCliSuppressionStore';
+
+suite('outdatedCliNotifier', () => {
+ const defaultExecutableIdentity = 'identity-1';
+
+ class FakeVersionProvider {
+ identity: CliVersionInfo | null = {
+ cliPath: '/cli/aspire',
+ version: '13.5.0',
+ executableIdentity: defaultExecutableIdentity,
+ };
+ identityPromise: Promise | undefined;
+ currentVersion: CliVersionInfo | null | undefined;
+ recommendation: CliUpdateRecommendation = {
+ status: 'available',
+ currentVersion: '13.5.0',
+ version: '13.6.0',
+ };
+ recommendationPromise: Promise | undefined;
+ readonly versionCalls: Array = [];
+ readonly recommendationCalls: Array = [];
+
+ async getCliVersion(options?: CliVersionStatusOptions): Promise {
+ this.versionCalls.push(options);
+ return this.currentVersion !== undefined
+ ? this.currentVersion
+ : await (this.identityPromise ?? this.identity);
+ }
+
+ async getCliUpdateRecommendation(options?: CliUpdateRecommendationOptions): Promise {
+ this.recommendationCalls.push(options);
+ return await (this.recommendationPromise ?? this.recommendation);
+ }
+ }
+
+ class FakeSurface implements OutdatedCliNotificationSurface {
+ readonly warnings: Array<{ message: string; actions: string[] }> = [];
+ readonly commands: Array<{ command: string; args: unknown[] }> = [];
+ selection: string | undefined;
+ selectionPromise: Promise | undefined;
+
+ showWarning(message: string, ...actions: string[]): Thenable {
+ this.warnings.push({ message, actions });
+ return this.selectionPromise ?? Promise.resolve(this.selection);
+ }
+
+ executeCommand(command: string, ...args: unknown[]): Thenable {
+ this.commands.push({ command, args });
+ return Promise.resolve(undefined);
+ }
+ }
+
+ function createNotifier(now: () => number = Date.now, suppressionStore?: OutdatedCliSuppressionStore): {
+ notifier: OutdatedCliNotifier;
+ versionProvider: FakeVersionProvider;
+ surface: FakeSurface;
+ } {
+ const versionProvider = new FakeVersionProvider();
+ const surface = new FakeSurface();
+ return {
+ notifier: new OutdatedCliNotifier(versionProvider, surface, now, suppressionStore),
+ versionProvider,
+ surface,
+ };
+ }
+
+ function createSuppressionStore(values = new Set()): OutdatedCliSuppressionStore {
+ return {
+ readAll: async () => [...values],
+ add: async notificationKey => void values.add(notificationKey),
+ tryClaimNotification: async notificationKey => values.has(notificationKey)
+ ? undefined
+ : createNotificationClaim(),
+ };
+ }
+
+ function createNotificationClaim(): OutdatedCliNotificationClaim {
+ return {
+ isValid: () => true,
+ release: async () => undefined,
+ };
+ }
+
+ async function waitFor(predicate: () => boolean, message: string): Promise {
+ for (let attempt = 0; attempt < 100; attempt++) {
+ if (predicate()) {
+ return;
+ }
+ await new Promise(resolve => setImmediate(resolve));
+ }
+ assert.fail(message);
+ }
+
+ test('warns once and forwards the exact target and path', async () => {
+ const { notifier, versionProvider, surface } = createNotifier();
+ const target = workspaceFolderCliPathTarget({
+ uri: vscode.Uri.file('/workspace/a'),
+ name: 'a',
+ index: 0,
+ });
+ versionProvider.identity = {
+ cliPath: '/workspace/a/.aspire/bin/aspire',
+ version: '13.4.0',
+ executableIdentity: defaultExecutableIdentity,
+ };
+ versionProvider.recommendation = {
+ status: 'available',
+ currentVersion: '13.4.0',
+ version: '13.5.2',
+ };
+ versionProvider.currentVersion = versionProvider.identity;
+ surface.selection = strings.updateAspireCliAction;
+
+ await notifier.notifyIfOutdated(target, '/workspace/a/.aspire/bin/aspire');
+ await notifier.notifyIfOutdated(target, '/workspace/a/.aspire/bin/aspire');
+
+ assert.strictEqual(surface.warnings.length, 1);
+ assert.strictEqual(
+ surface.warnings[0].message,
+ 'Aspire CLI 13.4.0 at /workspace/a/.aspire/bin/aspire has a newer version available for its current channel: 13.5.2.');
+ assert.deepStrictEqual(surface.warnings[0].actions, [
+ strings.updateAspireCliAction,
+ strings.dontShowAgainLabel,
+ ]);
+ assert.deepStrictEqual(surface.commands, [{
+ command: 'aspire-vscode.updateSelf',
+ args: [target, '/workspace/a/.aspire/bin/aspire'],
+ }]);
+ notifier.dispose();
+ });
+
+ test("Don't Show Again persists for the exact CLI path and version", async () => {
+ const values = new Set();
+ const first = createNotifier(Date.now, createSuppressionStore(values));
+ const second = createNotifier(Date.now, createSuppressionStore(values));
+ first.surface.selection = strings.dontShowAgainLabel;
+ let completeSecondRecommendation!: (recommendation: CliUpdateRecommendation) => void;
+ second.versionProvider.recommendationPromise = new Promise(resolve => completeSecondRecommendation = resolve);
+
+ const secondNotification = second.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ await waitFor(
+ () => second.versionProvider.recommendationCalls.length === 1,
+ 'Expected the second window update probe to start.');
+
+ await first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ completeSecondRecommendation({
+ status: 'available',
+ currentVersion: '13.5.0',
+ version: '13.6.0',
+ });
+ await secondNotification;
+
+ assert.strictEqual(first.surface.warnings.length, 1);
+ assert.deepStrictEqual(first.surface.commands, []);
+ first.notifier.dispose();
+
+ assert.deepStrictEqual(second.surface.warnings, []);
+ assert.strictEqual(second.versionProvider.versionCalls.length, 1);
+ assert.strictEqual(second.versionProvider.recommendationCalls.length, 1);
+ second.notifier.dispose();
+ });
+
+ test('reserves the notification before awaiting a cross-window claim', async () => {
+ const target = workspaceFolderCliPathTarget({
+ uri: vscode.Uri.file('/workspace/a'),
+ name: 'a',
+ index: 0,
+ });
+ let completeClaim!: (claim: OutdatedCliNotificationClaim) => void;
+ let claimCalls = 0;
+ const suppressionStore: OutdatedCliSuppressionStore = {
+ readAll: async () => [],
+ add: async () => undefined,
+ tryClaimNotification: async () => {
+ claimCalls++;
+ return claimCalls === 1
+ ? await new Promise(resolve => completeClaim = resolve)
+ : createNotificationClaim();
+ },
+ };
+ const { notifier, surface } = createNotifier(Date.now, suppressionStore);
+
+ const first = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ await waitFor(() => claimCalls === 1, 'Expected the first notification claim.');
+ const second = notifier.notifyIfOutdated(target, '/cli/aspire');
+ await second;
+ completeClaim(createNotificationClaim());
+ await first;
+
+ assert.strictEqual(claimCalls, 1);
+ assert.strictEqual(surface.warnings.length, 1);
+ notifier.dispose();
+ });
+
+ test('holds the cross-window claim only through warning dispatch', async () => {
+ let claimReleased = false;
+ let completeSelection!: (selection: string | undefined) => void;
+ const suppressionStore: OutdatedCliSuppressionStore = {
+ readAll: async () => [],
+ add: async () => undefined,
+ tryClaimNotification: async () => ({
+ isValid: () => true,
+ release: async () => {
+ claimReleased = true;
+ },
+ }),
+ };
+ const { notifier, surface } = createNotifier(Date.now, suppressionStore);
+ surface.selectionPromise = new Promise(resolve => completeSelection = resolve);
+ const showWarning = surface.showWarning.bind(surface);
+ surface.showWarning = (message, ...actions) => {
+ assert.strictEqual(claimReleased, false);
+ return showWarning(message, ...actions);
+ };
+
+ const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ await waitFor(() => claimReleased, 'Expected the claim to be released after warning dispatch.');
+ assert.strictEqual(surface.warnings.length, 1);
+
+ completeSelection(undefined);
+ await notification;
+ notifier.dispose();
+ });
+
+ test('does not warn from an expired cross-window claim', async () => {
+ let released = false;
+ const suppressionStore: OutdatedCliSuppressionStore = {
+ readAll: async () => [],
+ add: async () => undefined,
+ tryClaimNotification: async () => ({
+ isValid: () => false,
+ release: async () => {
+ released = true;
+ },
+ }),
+ };
+ const { notifier, surface, versionProvider } = createNotifier(Date.now, suppressionStore);
+
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+
+ assert.strictEqual(released, true);
+ assert.deepStrictEqual(surface.warnings, []);
+ assert.strictEqual(versionProvider.versionCalls.length, 2);
+ assert.strictEqual(versionProvider.recommendationCalls.length, 2);
+ notifier.dispose();
+ });
+
+ test('uses five-minute version and six-hour update refresh intervals', async () => {
+ let now = 0;
+ const { notifier, versionProvider, surface } = createNotifier(() => now);
+ versionProvider.recommendation = {
+ status: 'none',
+ currentVersion: '13.5.0',
+ };
+
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ now = 5 * 60 * 1_000 - 1;
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ assert.strictEqual(versionProvider.versionCalls.length, 1);
+ assert.strictEqual(versionProvider.recommendationCalls.length, 1);
+
+ now++;
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ assert.strictEqual(versionProvider.versionCalls.length, 2);
+ assert.strictEqual(versionProvider.recommendationCalls.length, 1);
+
+ now = 6 * 60 * 60 * 1_000;
+ versionProvider.recommendation = {
+ status: 'available',
+ currentVersion: '13.5.0',
+ version: '13.7.0',
+ };
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+
+ assert.strictEqual(versionProvider.versionCalls.length, 3);
+ assert.strictEqual(versionProvider.recommendationCalls.length, 2);
+ assert.strictEqual(surface.warnings.length, 1);
+ notifier.dispose();
+ });
+
+ test('samples version independently and caps unavailable doctor attempts per identity', async () => {
+ let now = 0;
+ const { notifier, versionProvider } = createNotifier(() => now);
+ versionProvider.recommendation = { status: 'unavailable' };
+
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ for (const minute of [5, 10, 15, 20, 25]) {
+ now = minute * 60 * 1_000;
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ }
+
+ assert.strictEqual(versionProvider.versionCalls.length, 6);
+ assert.strictEqual(versionProvider.recommendationCalls.length, 3);
+
+ now = 30 * 60 * 1_000;
+ versionProvider.identity = {
+ cliPath: '/cli/aspire',
+ version: '13.5.1',
+ executableIdentity: 'identity-2',
+ };
+ versionProvider.recommendation = {
+ status: 'none',
+ currentVersion: '13.5.1',
+ };
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+
+ assert.strictEqual(versionProvider.recommendationCalls.length, 4);
+ notifier.dispose();
+ });
+
+ test('refreshes the recommendation when the executable changes without a version change', async () => {
+ let now = 0;
+ const { notifier, versionProvider, surface } = createNotifier(() => now);
+ versionProvider.recommendation = {
+ status: 'ineligible',
+ currentVersion: '13.5.0',
+ };
+
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ now = 5 * 60 * 1_000;
+ versionProvider.identity = {
+ cliPath: '/cli/aspire',
+ version: '13.5.0',
+ executableIdentity: 'identity-2',
+ };
+ versionProvider.recommendation = {
+ status: 'available',
+ currentVersion: '13.5.0',
+ version: '13.6.0',
+ };
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+
+ assert.strictEqual(versionProvider.recommendationCalls.length, 2);
+ assert.strictEqual(surface.warnings.length, 1);
+ notifier.dispose();
+ });
+
+ test('coalesces same-path checks and serializes distinct doctors', async () => {
+ const versionProvider = new FakeVersionProvider();
+ let activeVersions = 0;
+ let maximumActiveVersions = 0;
+ const releaseVersions: Array<() => void> = [];
+ versionProvider.getCliVersion = async options => {
+ versionProvider.versionCalls.push(options);
+ activeVersions++;
+ maximumActiveVersions = Math.max(maximumActiveVersions, activeVersions);
+ return await new Promise(resolve => releaseVersions.push(() => {
+ activeVersions--;
+ resolve({
+ cliPath: options?.cliPath ?? '/cli/aspire',
+ version: '13.5.0',
+ executableIdentity: options?.cliPath ?? defaultExecutableIdentity,
+ });
+ }));
+ };
+ let activeDoctors = 0;
+ let maximumActiveDoctors = 0;
+ const releaseDoctors: Array<() => void> = [];
+ versionProvider.getCliUpdateRecommendation = async options => {
+ versionProvider.recommendationCalls.push(options);
+ activeDoctors++;
+ maximumActiveDoctors = Math.max(maximumActiveDoctors, activeDoctors);
+ return await new Promise(resolve => {
+ releaseDoctors.push(() => {
+ activeDoctors--;
+ resolve({
+ status: 'none',
+ currentVersion: '13.5.0',
+ });
+ });
+ });
+ };
+ const notifier = new OutdatedCliNotifier(versionProvider, new FakeSurface());
+
+ const shared = Array.from({ length: 10 }, () =>
+ notifier.notifyIfOutdated(windowCliPathTarget, '/shared/aspire'));
+ const distinct = notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire');
+ await waitFor(() => releaseVersions.length === 1, 'Expected first serialized version probe.');
+ releaseVersions.shift()?.();
+ await waitFor(() => releaseVersions.length === 1, 'Expected second serialized version probe.');
+ releaseVersions.shift()?.();
+ await waitFor(() => releaseDoctors.length === 1, 'Expected first serialized doctor.');
+ releaseDoctors.shift()?.();
+ await waitFor(() => releaseDoctors.length === 1, 'Expected second serialized doctor.');
+ releaseDoctors.shift()?.();
+ await Promise.all([...shared, distinct]);
+
+ assert.strictEqual(versionProvider.versionCalls.length, 2);
+ assert.strictEqual(maximumActiveVersions, 1);
+ assert.strictEqual(versionProvider.recommendationCalls.length, 2);
+ assert.strictEqual(maximumActiveDoctors, 1);
+ notifier.dispose();
+ });
+
+ test('isolates same-path update recommendations by resolution target', async () => {
+ const folderA: vscode.WorkspaceFolder = {
+ uri: vscode.Uri.file('/workspace/a'),
+ name: 'a',
+ index: 0,
+ };
+ const folderB: vscode.WorkspaceFolder = {
+ uri: vscode.Uri.file('/workspace/b'),
+ name: 'b',
+ index: 1,
+ };
+ const targetA = workspaceFolderCliPathTarget(folderA);
+ const targetB = workspaceFolderCliPathTarget(folderB);
+ const versionProvider = new FakeVersionProvider();
+ versionProvider.identity = {
+ cliPath: '/shared/aspire',
+ version: '13.5.0',
+ executableIdentity: defaultExecutableIdentity,
+ };
+ versionProvider.getCliUpdateRecommendation = async options => {
+ versionProvider.recommendationCalls.push(options);
+ return options?.target === targetA
+ ? { status: 'none', currentVersion: '13.5.0' }
+ : { status: 'available', currentVersion: '13.5.0', version: '13.6.0' };
+ };
+ const surface = new FakeSurface();
+ const notifier = new OutdatedCliNotifier(versionProvider, surface);
+
+ await Promise.all([
+ notifier.notifyIfOutdated(targetA, '/shared/aspire'),
+ notifier.notifyIfOutdated(targetB, '/shared/aspire'),
+ ]);
+
+ assert.strictEqual(versionProvider.versionCalls.length, 1);
+ assert.deepStrictEqual(
+ versionProvider.recommendationCalls.map(call => call?.target),
+ [targetA, targetB]);
+ assert.strictEqual(surface.warnings.length, 1);
+ notifier.dispose();
+ });
+
+ test('refreshes the window-scoped recommendation when its Doctor working directory changes', async () => {
+ const folderA: vscode.WorkspaceFolder = {
+ uri: vscode.Uri.file('/workspace/a'),
+ name: 'a',
+ index: 0,
+ };
+ const folderB: vscode.WorkspaceFolder = {
+ uri: vscode.Uri.file('/workspace/b'),
+ name: 'b',
+ index: 0,
+ };
+ let workspaceFolders: readonly vscode.WorkspaceFolder[] = [];
+ const workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').get(() => workspaceFolders);
+ const versionProvider = new FakeVersionProvider();
+ versionProvider.identity = {
+ cliPath: '/shared/aspire',
+ version: '13.5.0',
+ executableIdentity: defaultExecutableIdentity,
+ };
+ versionProvider.getCliUpdateRecommendation = async options => {
+ versionProvider.recommendationCalls.push(options);
+ return options?.workingDirectory === folderB.uri.fsPath
+ ? { status: 'available', currentVersion: '13.5.0', version: '13.6.0' }
+ : { status: 'none', currentVersion: '13.5.0' };
+ };
+ const surface = new FakeSurface();
+ const notifier = new OutdatedCliNotifier(versionProvider, surface);
+
+ try {
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/shared/aspire');
+ workspaceFolders = [folderA];
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/shared/aspire');
+ workspaceFolders = [folderB];
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/shared/aspire');
+
+ assert.deepStrictEqual(
+ versionProvider.recommendationCalls.map(call => call?.workingDirectory),
+ [process.cwd(), folderA.uri.fsPath, folderB.uri.fsPath]);
+ assert.strictEqual(surface.warnings.length, 1);
+ }
+ finally {
+ notifier.dispose();
+ workspaceFoldersStub.restore();
+ }
+ });
+
+ test('does not warn when the version probe and Doctor disagree', async () => {
+ const { notifier, versionProvider, surface } = createNotifier();
+ versionProvider.identity = {
+ cliPath: '/cli/aspire',
+ version: '13.7.0-preview.1',
+ executableIdentity: defaultExecutableIdentity,
+ };
+ versionProvider.recommendation = {
+ status: 'available',
+ currentVersion: '13.6.0-preview.1',
+ version: '13.7.0-preview.2',
+ };
+
+ await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+
+ assert.deepStrictEqual(surface.warnings, []);
+ notifier.dispose();
+ });
+
+ test('stale or inconclusive warning actions are suppressed', async () => {
+ const { notifier, versionProvider, surface } = createNotifier();
+ let resolveSelection!: (selection: string | undefined) => void;
+ surface.selectionPromise = new Promise(resolve => resolveSelection = resolve);
+
+ const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ await waitFor(() => surface.warnings.length === 1, 'Expected warning to open.');
+ versionProvider.versionCalls.length = 0;
+ let releaseOlderProbe!: () => void;
+ versionProvider.getCliVersion = async options => {
+ versionProvider.versionCalls.push(options);
+ if (versionProvider.versionCalls.length === 1) {
+ return await new Promise(resolve => {
+ releaseOlderProbe = () => resolve({
+ cliPath: '/cli/aspire',
+ version: '13.5.0',
+ executableIdentity: defaultExecutableIdentity,
+ });
+ });
+ }
+ return {
+ cliPath: '/cli/aspire',
+ version: '13.5.0',
+ executableIdentity: 'identity-2',
+ };
+ };
+ const backgroundCheck = notifier.notifyIfOutdated(
+ workspaceFolderCliPathTarget({
+ uri: vscode.Uri.file('/other'),
+ name: 'other',
+ index: 0,
+ }),
+ '/cli/aspire');
+ await waitFor(() => versionProvider.versionCalls.length === 1, 'Expected older probe to start.');
+ resolveSelection(strings.updateAspireCliAction);
+ await new Promise(resolve => setImmediate(resolve));
+ releaseOlderProbe();
+ await Promise.all([notification, backgroundCheck]);
+
+ assert.strictEqual(versionProvider.versionCalls.length, 2);
+ assert.deepStrictEqual(surface.commands, []);
+
+ surface.selectionPromise = undefined;
+ surface.selection = undefined;
+ await notifier.notifyIfOutdated(
+ workspaceFolderCliPathTarget({
+ uri: vscode.Uri.file('/replacement'),
+ name: 'replacement',
+ index: 0,
+ }),
+ '/cli/aspire');
+ assert.strictEqual(surface.warnings.length, 2);
+ assert.strictEqual(versionProvider.versionCalls.length, 3);
+ assert.deepStrictEqual(surface.commands, []);
+
+ const second = createNotifier();
+ second.surface.selection = strings.updateAspireCliAction;
+ second.versionProvider.currentVersion = null;
+ await second.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ assert.deepStrictEqual(second.surface.commands, []);
+ second.notifier.dispose();
+ notifier.dispose();
+ });
+
+ test('dispose cancels an active probe and suppresses continuations', async () => {
+ const versionProvider = new FakeVersionProvider();
+ let resolveVersion!: (version: CliVersionInfo | null) => void;
+ versionProvider.getCliVersion = async options => {
+ versionProvider.versionCalls.push(options);
+ return await new Promise(resolve => resolveVersion = resolve);
+ };
+ const surface = new FakeSurface();
+ const notifier = new OutdatedCliNotifier(versionProvider, surface);
+ const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire');
+ await waitFor(() => versionProvider.versionCalls.length === 1, 'Expected the version probe to start.');
+
+ notifier.dispose();
+ resolveVersion(null);
+ await notification;
+
+ assert.strictEqual(versionProvider.versionCalls[0]?.cancellationToken?.isCancellationRequested, true);
+ assert.deepStrictEqual(versionProvider.recommendationCalls, []);
+ assert.deepStrictEqual(surface.warnings, []);
+ assert.deepStrictEqual(surface.commands, []);
+ });
+});
diff --git a/extension/src/test/outdatedCliSuppressionStore.test.ts b/extension/src/test/outdatedCliSuppressionStore.test.ts
new file mode 100644
index 00000000000..b1c0dfbb88a
--- /dev/null
+++ b/extension/src/test/outdatedCliSuppressionStore.test.ts
@@ -0,0 +1,119 @@
+import * as assert from 'assert';
+import { spawn } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { FileSystemOutdatedCliSuppressionStore } from '../utils/outdatedCliSuppressionStore';
+
+suite('outdatedCliSuppressionStore', () => {
+ let directory: string;
+
+ setup(() => {
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-suppressions-'));
+ });
+
+ teardown(() => {
+ fs.rmSync(directory, { recursive: true, force: true });
+ });
+
+ test('preserves concurrent writes from separate stores', async () => {
+ const first = new FileSystemOutdatedCliSuppressionStore(directory);
+ const second = new FileSystemOutdatedCliSuppressionStore(directory);
+
+ await Promise.all([
+ first.add('/cli/a\u000013.5.0'),
+ second.add('/cli/b\u000013.5.0'),
+ ]);
+
+ assert.deepStrictEqual(
+ (await first.readAll()).sort(),
+ ['/cli/a\u000013.5.0', '/cli/b\u000013.5.0']);
+ });
+
+ test('serializes a suppression written after the final notification check', async () => {
+ const first = new FileSystemOutdatedCliSuppressionStore(directory);
+ const second = new FileSystemOutdatedCliSuppressionStore(directory);
+ const notificationKey = '/cli/aspire\u000013.5.0';
+
+ const claim = await second.tryClaimNotification(notificationKey);
+ assert.ok(claim);
+
+ let suppressionCompleted = false;
+ const suppression = first.add(notificationKey).then(() => suppressionCompleted = true);
+ await new Promise(resolve => setTimeout(resolve, 50));
+ assert.strictEqual(suppressionCompleted, false);
+
+ await claim.release();
+ await suppression;
+
+ assert.strictEqual(suppressionCompleted, true);
+ assert.strictEqual(await second.tryClaimNotification(notificationKey), undefined);
+ });
+
+ test('does not wait for a claim abandoned by another extension host', async () => {
+ const exitedProcessId = await startAndWaitForProcess();
+ createNotificationClaim(directory, '/cli/aspire\u000013.5.0', exitedProcessId);
+
+ const store = new FileSystemOutdatedCliSuppressionStore(directory);
+ await store.add('/cli/aspire\u000013.5.0');
+
+ assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']);
+ });
+
+ test('does not wait for an expired claim whose process ID has been reused', async () => {
+ createNotificationClaim(directory, '/cli/aspire\u000013.5.0', process.pid, 0);
+
+ const store = new FileSystemOutdatedCliSuppressionStore(directory);
+ await store.add('/cli/aspire\u000013.5.0');
+
+ assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']);
+ });
+
+ test('does not wait for a claim timestamped in the future', async () => {
+ createNotificationClaim(
+ directory,
+ '/cli/aspire\u000013.5.0',
+ process.pid,
+ Date.now() + 60_000);
+
+ const store = new FileSystemOutdatedCliSuppressionStore(directory);
+ await store.add('/cli/aspire\u000013.5.0');
+
+ assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']);
+ });
+
+ test('removes a malformed abandoned claim for another notification', async () => {
+ const exitedProcessId = await startAndWaitForProcess();
+ const claimPath = createNotificationClaim(directory, '/cli/other\u000013.5.0', exitedProcessId);
+ fs.writeFileSync(claimPath, '{');
+
+ const store = new FileSystemOutdatedCliSuppressionStore(directory);
+ await store.add('/cli/aspire\u000013.5.0');
+
+ assert.strictEqual(fs.existsSync(claimPath), false);
+ assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']);
+ });
+});
+
+function createNotificationClaim(
+ directory: string,
+ notificationKey: string,
+ processId: number,
+ createdAt = Date.now(),
+): string {
+ const storageDirectory = path.join(directory, 'outdated-cli-suppressions');
+ fs.mkdirSync(storageDirectory, { recursive: true });
+ const claimPath = path.join(storageDirectory, `notification-claim-${createdAt}-${processId}-0.json`);
+ fs.writeFileSync(claimPath, JSON.stringify({ notificationKey, processId, createdAt }));
+ return claimPath;
+}
+
+async function startAndWaitForProcess(): Promise {
+ const child = spawn(process.execPath, ['-e', '']);
+ assert.ok(child.pid);
+ await new Promise((resolve, reject) => {
+ child.once('error', reject);
+ child.once('exit', () => resolve());
+ });
+ return child.pid;
+}
diff --git a/extension/src/test/registerCliCommands.test.ts b/extension/src/test/registerCliCommands.test.ts
index 2a920c45c18..f1065ed08dd 100644
--- a/extension/src/test/registerCliCommands.test.ts
+++ b/extension/src/test/registerCliCommands.test.ts
@@ -20,6 +20,7 @@ suite('registerCliCommands', () => {
let terminalProvider: AspireTerminalProvider;
let sendCommandStub: sinon.SinonStub;
let getTerminalStub: sinon.SinonStub;
+ let getAspireCliExecutablePathStub: sinon.SinonStub;
let resolveCliPathStub: sinon.SinonStub;
let showWorkspaceFolderPickStub: sinon.SinonStub;
let workspaceFoldersStub: sinon.SinonStub;
@@ -28,6 +29,8 @@ suite('registerCliCommands', () => {
let getAppHostPathStub: sinon.SinonStub;
let tryExecuteDoAppHostStub: sinon.SinonStub;
let tryExecuteRunAppHostStub: sinon.SinonStub;
+ let tryExecuteDeployAppHostStub: sinon.SinonStub;
+ let tryExecutePublishAppHostStub: sinon.SinonStub;
let editorCommandProvider: AspireEditorCommandProvider;
let tempDir: string;
@@ -44,9 +47,11 @@ suite('registerCliCommands', () => {
terminal: { show: sinon.stub() },
dispose: () => { },
});
+ getAspireCliExecutablePathStub = sinon.stub().resolves('/resolved/aspire');
terminalProvider = {
sendAspireCommandToAspireTerminal: sendCommandStub,
getAspireTerminal: getTerminalStub,
+ getAspireCliExecutablePath: getAspireCliExecutablePathStub,
} as unknown as AspireTerminalProvider;
resolveCliPathStub = sandbox.stub(cliPathModule, 'resolveCliPath').resolves({
cliPath: '/resolved/aspire',
@@ -60,10 +65,14 @@ suite('registerCliCommands', () => {
getAppHostPathStub = sinon.stub().resolves(null);
tryExecuteDoAppHostStub = sinon.stub().resolves();
tryExecuteRunAppHostStub = sinon.stub().resolves();
+ tryExecuteDeployAppHostStub = sinon.stub().resolves();
+ tryExecutePublishAppHostStub = sinon.stub().resolves();
editorCommandProvider = {
getAppHostPath: getAppHostPathStub,
tryExecuteDoAppHost: tryExecuteDoAppHostStub,
tryExecuteRunAppHost: tryExecuteRunAppHostStub,
+ tryExecuteDeployAppHost: tryExecuteDeployAppHostStub,
+ tryExecutePublishAppHost: tryExecutePublishAppHostStub,
} as unknown as AspireEditorCommandProvider;
registerCliCommands(terminalProvider, editorCommandProvider, new ConfigInfoProvider(terminalProvider));
@@ -288,6 +297,15 @@ suite('registerCliCommands', () => {
assert.ok(tryExecuteDoAppHostStub.calledOnceWith(false, undefined, appHostPath, target, '/resolved/aspire'));
});
+ test('deploy and publish defer CLI resolution to the AppHost launch', async () => {
+ await callbacks.get('aspire-vscode.deploy')!();
+ await callbacks.get('aspire-vscode.publish')!();
+
+ assert.ok(tryExecuteDeployAppHostStub.calledOnceWithExactly(false));
+ assert.ok(tryExecutePublishAppHostStub.calledOnceWithExactly(false));
+ assert.strictEqual(resolveCliPathStub.called, false);
+ });
+
test('do rejects a missing AppHost before probing the CLI', async () => {
const hasCapabilityStub = sandbox.stub(ConfigInfoProvider.prototype, 'hasCapability').resolves(true);
const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined);
@@ -344,6 +362,24 @@ suite('registerCliCommands', () => {
await callbacks.get('aspire-vscode.updateSelf')!();
assert.strictEqual(resolveCliPathStub.called, false);
- assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { target: windowCliPathTarget }));
+ assert.ok(getAspireCliExecutablePathStub.calledOnceWith(windowCliPathTarget));
+ assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, {
+ target: windowCliPathTarget,
+ cliPath: '/resolved/aspire',
+ }));
+ });
+
+ test('update self uses the pre-resolved workspace CLI supplied by the warning action', async () => {
+ const folder = createWorkspaceFolder('a', '/repo/a');
+ const target = workspaceFolderCliPathTarget(folder);
+
+ await callbacks.get('aspire-vscode.updateSelf')!(target, '/repo/a/.aspire/bin/aspire');
+
+ assert.strictEqual(resolveCliPathStub.called, false);
+ assert.strictEqual(getAspireCliExecutablePathStub.called, false);
+ assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, {
+ target,
+ cliPath: '/repo/a/.aspire/bin/aspire',
+ }));
});
});
\ No newline at end of file
diff --git a/extension/src/test/workspace.test.ts b/extension/src/test/workspace.test.ts
index 6b4d33980e9..7ad9de70415 100644
--- a/extension/src/test/workspace.test.ts
+++ b/extension/src/test/workspace.test.ts
@@ -6,6 +6,7 @@ import * as sinon from 'sinon';
import * as vscode from 'vscode';
import { yesLabel } from '../loc/strings';
import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace';
+import { onDidResolveCliForOperation } from '../utils/cliOperationResolution';
import { AppHostDiscoveryService, getWorkspaceAppHostProjectSearchResult } from '../utils/appHostDiscovery';
import { getAppHostDiscoveryExcludeGlob } from '../utils/workspaceFileSearch';
import * as cliPathModule from '../utils/cliPath';
@@ -52,6 +53,29 @@ suite('utils/workspace tests', () => {
assert.ok(tryExecuteCliStub.calledOnceWithExactly('/repo/a/bin/aspire'));
assert.strictEqual(resolveCliPathStub.called, false);
});
+
+ test('reports the exact CLI selected for an active command or debug operation', async () => {
+ const target = workspaceFolderCliPathTarget(createWorkspaceFolder('a', '/repo/a'));
+ sandbox.stub(cliPathModule, 'resolveCliPath').resolves({
+ cliPath: '/repo/a/bin/aspire',
+ available: true,
+ source: 'configured',
+ });
+ const resolutions: Array<{ target: typeof target; cliPath: string }> = [];
+ const disposable = onDidResolveCliForOperation(resolution => resolutions.push(resolution));
+
+ try {
+ await checkCliAvailableOrRedirect('debug_gate', target);
+
+ assert.deepStrictEqual(resolutions, [{
+ target,
+ cliPath: '/repo/a/bin/aspire',
+ }]);
+ }
+ finally {
+ disposable.dispose();
+ }
+ });
});
test('getCommonExcludeGlob returns valid glob pattern', () => {
diff --git a/extension/src/utils/AspirePackageRestoreProvider.ts b/extension/src/utils/AspirePackageRestoreProvider.ts
index 6ce30e3e9f7..46766bdbe68 100644
--- a/extension/src/utils/AspirePackageRestoreProvider.ts
+++ b/extension/src/utils/AspirePackageRestoreProvider.ts
@@ -6,6 +6,7 @@ import { ChildProcessWithoutNullStreams } from 'child_process';
import { spawnCliProcess } from './process/cliProcess';
import { AspireTerminalProvider } from './AspireTerminalProvider';
import { getCliPathTargetForUri } from './cliPathVariables';
+import { reportCliResolvedForOperation } from './cliOperationResolution';
import { extensionLogOutputChannel } from './logging';
import { getEnableAutoRestore } from './settings';
import { runningAspireRestore, runningAspireRestoreProgress, aspireRestoreCompleted, aspireRestoreAllCompleted, aspireRestoreFailed, aspireRestoreFailedStatusBar } from '../loc/strings';
@@ -26,7 +27,7 @@ export class AspirePackageRestoreProvider implements vscode.Disposable {
private readonly _active = new Map(); // configDir → relativePath
private readonly _childProcesses = new Set();
private readonly _timeouts = new Set>();
- private readonly _pendingRestore = new Set(); // configDirs needing re-restore
+ private readonly _pendingRestore = new Map(); // configDir → force restore
private readonly _failedDirs = new Set(); // configDirs that failed
private _total = 0;
private _completed = 0;
@@ -80,13 +81,13 @@ export class AspirePackageRestoreProvider implements vscode.Disposable {
return;
}
- this._total = configs.length;
+ this._total = this._active.size + configs.length;
this._completed = 0;
this._failedDirs.clear();
const pending = new Set>();
for (const uri of configs) {
- const p = this._restoreIfChanged(uri, true).finally(() => pending.delete(p));
+ const p = this._restoreIfChanged(uri, true, force).finally(() => pending.delete(p));
pending.add(p);
if (pending.size >= AspirePackageRestoreProvider._maxConcurrency) {
await Promise.race(pending);
@@ -124,11 +125,15 @@ export class AspirePackageRestoreProvider implements vscode.Disposable {
this._total++;
}
}
- await this._restoreIfChanged(uri, false);
+ await this._restoreIfChanged(uri, false, false);
}
- private async _restoreIfChanged(uri: vscode.Uri, isInitial: boolean): Promise {
- if (this._disposed || !getEnableAutoRestore()) {
+ private async _restoreIfChanged(
+ uri: vscode.Uri,
+ isInitial: boolean,
+ force: boolean,
+ ): Promise {
+ if (this._disposed || (!force && !getEnableAutoRestore())) {
return;
}
@@ -144,7 +149,7 @@ export class AspirePackageRestoreProvider implements vscode.Disposable {
}
const prev = this._lastContent.get(uri.fsPath);
- if (!isInitial && prev === content) {
+ if (!force && !isInitial && prev === content) {
this._completed++;
this._showProgress();
this._scheduleHide();
@@ -157,12 +162,14 @@ export class AspirePackageRestoreProvider implements vscode.Disposable {
// Queue re-restore if one is already active for this config directory
if (this._active.has(configDir)) {
- this._pendingRestore.add(configDir);
+ this._pendingRestore.set(
+ configDir,
+ (this._pendingRestore.get(configDir) ?? false) || force);
return;
}
try {
- await this._runRestore(uri, configDir, relativePath);
+ await this._runRestore(uri, configDir, relativePath, force);
if (this._disposed) {
return;
}
@@ -181,12 +188,19 @@ export class AspirePackageRestoreProvider implements vscode.Disposable {
}
// If a change arrived while we were restoring, re-read and restore again
- while (!this._disposed && this._pendingRestore.delete(configDir)) {
- await this._restoreIfChanged(uri, false);
+ while (!this._disposed && this._pendingRestore.has(configDir)) {
+ const reportPendingCliUse = this._pendingRestore.get(configDir) ?? false;
+ this._pendingRestore.delete(configDir);
+ await this._restoreIfChanged(uri, false, reportPendingCliUse);
}
}
- private async _runRestore(uri: vscode.Uri, configDir: string, relativePath: string): Promise {
+ private async _runRestore(
+ uri: vscode.Uri,
+ configDir: string,
+ relativePath: string,
+ reportCliUse: boolean,
+ ): Promise {
if (this._disposed) {
return;
}
@@ -195,10 +209,14 @@ export class AspirePackageRestoreProvider implements vscode.Disposable {
this._showProgress();
try {
- const cliPath = await this._terminalProvider.getAspireCliExecutablePath(getCliPathTargetForUri(uri));
+ const target = getCliPathTargetForUri(uri);
+ const cliPath = await this._terminalProvider.getAspireCliExecutablePath(target);
if (this._disposed) {
return;
}
+ if (reportCliUse) {
+ reportCliResolvedForOperation(target, cliPath);
+ }
await new Promise((resolve, reject) => {
let settled = false;
const proc = spawnCliProcess(this._terminalProvider, cliPath, ['restore'], {
diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts
index 999fb70724f..fe1f6d41b9a 100644
--- a/extension/src/utils/AspireTerminalProvider.ts
+++ b/extension/src/utils/AspireTerminalProvider.ts
@@ -11,6 +11,7 @@ import { ASPIRE_CLI_PATH_ENV_VAR, getForwardableAspireCliPath, getForwardableRes
import { CliPathResolutionTarget, getCliPathTargetKey, windowCliPathTarget } from './cliPathVariables';
import path from 'path';
import { assertNoTerminalControlCharacters } from './cmdShim';
+import { reportCliResolvedForOperation } from './cliOperationResolution';
// Re-exported so existing importers keep a single implementation of the guard.
export { assertNoTerminalControlCharacters };
@@ -168,7 +169,11 @@ export class AspireTerminalProvider implements vscode.Disposable {
async sendAspireCommandToAspireTerminal(subcommand: AspireSubcommand, showTerminal: boolean = true, additionalArgs?: string[], options?: SendAspireCommandOptions) {
const target = options?.target ?? windowCliPathTarget;
+ const resolvedForThisCommand = options?.cliPath === undefined;
const cliPath = options?.cliPath ?? await this.getAspireCliExecutablePath(target);
+ if (resolvedForThisCommand) {
+ reportCliResolvedForOperation(target, cliPath);
+ }
const subcommandLine = formatSubcommand(subcommand);
assertNoTerminalControlCharacters(cliPath);
diff --git a/extension/src/utils/cliOperationResolution.ts b/extension/src/utils/cliOperationResolution.ts
new file mode 100644
index 00000000000..8cc27899d3a
--- /dev/null
+++ b/extension/src/utils/cliOperationResolution.ts
@@ -0,0 +1,19 @@
+import * as vscode from 'vscode';
+import { CliPathResolutionTarget } from './cliPathVariables';
+
+export interface CliOperationResolution {
+ target: CliPathResolutionTarget;
+ cliPath: string;
+}
+
+const cliOperationResolutionEmitter = new vscode.EventEmitter();
+
+/**
+ * Fires after an Aspire operation has selected the exact CLI executable it will invoke.
+ * Resolution performed only for activation-time environment setup is intentionally excluded.
+ */
+export const onDidResolveCliForOperation = cliOperationResolutionEmitter.event;
+
+export function reportCliResolvedForOperation(target: CliPathResolutionTarget, cliPath: string): void {
+ cliOperationResolutionEmitter.fire({ target, cliPath });
+}
diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts
index eac36631bc7..6da5edfaaf1 100644
--- a/extension/src/utils/configInfoProvider.ts
+++ b/extension/src/utils/configInfoProvider.ts
@@ -1,5 +1,6 @@
import * as vscode from 'vscode';
import type { ChildProcessWithoutNullStreams } from 'child_process';
+import { stat } from 'fs/promises';
import { AspireTerminalProvider } from './AspireTerminalProvider';
import { spawnCliProcess, terminateCliProcess } from './process/cliProcess';
import { extensionLogOutputChannel } from './logging';
@@ -7,10 +8,16 @@ import { CapabilityStatus, ConfigInfo, FeatureInfo, PropertyInfo, SettingsSchema
import * as strings from '../loc/strings';
import { isNoLogoUnsupportedOutput, noLogoOption, removeRootNoLogoOption } from './cliCompatibility';
import { CliPathResolutionTarget, windowCliPathTarget } from './cliPathVariables';
+import { nonInteractiveCliEnvironment } from './environment';
const configInfoTimeoutMs = 30_000;
const cliVersionProbeTimeoutMs = 30_000;
+// The only available structured update status is part of `aspire doctor`, whose complete
+// environment-check budget is two minutes. Keep enough time for that transport; a future narrow
+// update-status command should use a substantially smaller bound.
+const cliUpdateProbeTimeoutMs = 130_000;
const maxCliVersionOutputLength = 128;
+const maxCliUpdateOutputLength = 1024 * 1024;
const cliVersionPattern = /^(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,4})(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
type RawFeatureInfo = Partial & {
@@ -58,11 +65,61 @@ export interface ConfigInfoOptions {
timeoutMs?: number;
}
+export interface CliVersionStatusOptions {
+ cliPath?: string;
+ cancellationToken?: vscode.CancellationToken;
+ /** The resolution scope to use when `cliPath` is not already known. Defaults to the window scope. */
+ target?: CliPathResolutionTarget;
+ timeoutMs?: number;
+}
+
+export type CliUpdateRecommendationOptions = Omit & {
+ /** Captured Doctor working directory, when the caller must keep cache and process context aligned. */
+ workingDirectory?: string;
+};
+
+export interface CliVersionInfo {
+ cliPath: string;
+ version: string;
+ executableIdentity: string;
+}
+
+export type CliUpdateRecommendation =
+ | { status: 'available'; currentVersion: string; version: string }
+ | { status: 'none'; currentVersion: string }
+ | { status: 'ineligible'; currentVersion: string }
+ | { status: 'unavailable' };
+
+interface CliVersionStatus {
+ cliPath: string;
+ version: string;
+ status: Exclude;
+}
+
interface CliVersion {
+ value: string;
major: number;
minor: number;
patch: number;
isPrerelease: boolean;
+ prereleaseIdentifiers: string[];
+}
+
+interface CliCaptureResult {
+ stdout: string;
+ stderr: string;
+ exitCode: number | null | undefined;
+}
+
+interface CliCaptureOptions {
+ timeoutMs: number;
+ maxStdoutLength: number;
+ maxStderrLength?: number;
+ workingDirectory?: string;
+ env?: { name: string; value: string }[];
+ description: string;
+ failureMessage: string;
+ cancellationToken?: vscode.CancellationToken;
}
/**
@@ -71,14 +128,15 @@ interface CliVersion {
* The CLI discovers `aspire.config.json` by walking up from its working directory, so the folder it
* runs in decides which local settings file the answer describes. Window-scoped callers have no
* folder of their own and fall back to the first one, which is the best available guess and matches
- * how other window-scoped commands behave.
+ * how other window-scoped commands behave. With no workspace, capture the extension host's current
+ * directory rather than leaving process launch to resolve a potentially changed workspace later.
*/
-function resolveConfigInfoWorkingDirectory(target: CliPathResolutionTarget): string | undefined {
+export function resolveConfigInfoWorkingDirectory(target: CliPathResolutionTarget): string {
if (target.kind === 'workspaceFolder') {
return target.workspaceFolder.uri.fsPath;
}
- return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
+ return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd();
}
/**
@@ -188,6 +246,61 @@ export class ConfigInfoProvider {
return await this.getCapabilityStatus(capability, options) === 'supported';
}
+ /**
+ * Probes the exact resolved Aspire CLI version. The result is intentionally uncached so callers
+ * observe an executable replaced in place.
+ */
+ async getCliVersion(options?: CliVersionStatusOptions): Promise {
+ const target = options?.target ?? windowCliPathTarget;
+ const startTime = Date.now();
+ const timeoutMs = Math.min(options?.timeoutMs ?? cliVersionProbeTimeoutMs, cliVersionProbeTimeoutMs);
+ const cliPath = options?.cliPath ?? await this._resolveCliPath(true, target, options?.cancellationToken);
+ if (!cliPath || options?.cancellationToken?.isCancellationRequested) {
+ return null;
+ }
+
+ const remainingTimeoutMs = timeoutMs - (Date.now() - startTime);
+ if (remainingTimeoutMs <= 0) {
+ return null;
+ }
+
+ const version = await this._probeCliVersion(cliPath, remainingTimeoutMs, options?.cancellationToken);
+ if (!version) {
+ return null;
+ }
+
+ const executableIdentity = await getCliExecutableIdentity(
+ cliPath,
+ timeoutMs - (Date.now() - startTime),
+ options?.cancellationToken);
+ return executableIdentity
+ ? { cliPath, version: version.value, executableIdentity }
+ : null;
+ }
+
+ /**
+ * Returns a same-lane update recommended by the resolved CLI's structured update check. Stable
+ * installations follow stable releases and prerelease installations follow prerelease
+ * recommendations. Failures, cross-lane recommendations, and no-update results are silent.
+ */
+ async getCliUpdateRecommendation(options?: CliUpdateRecommendationOptions): Promise {
+ if (options?.cancellationToken?.isCancellationRequested) {
+ return { status: 'unavailable' };
+ }
+
+ const target = options?.target ?? windowCliPathTarget;
+ const cliPath = options?.cliPath ?? await this._resolveCliPath(true, target, options?.cancellationToken);
+ if (!cliPath || options?.cancellationToken?.isCancellationRequested) {
+ return { status: 'unavailable' };
+ }
+
+ const workingDirectory = options?.workingDirectory ?? resolveConfigInfoWorkingDirectory(target);
+ return await this._fetchCliUpdateRecommendation(
+ cliPath,
+ workingDirectory,
+ options?.cancellationToken);
+ }
+
/**
* Distinguishes a successful probe of an older CLI from a probe that could not complete.
* Callers that must honor an explicit capability-dependent choice cannot safely treat both
@@ -232,23 +345,102 @@ export class ConfigInfoProvider {
return 'unavailable';
}
- return await this._getCliMinimumVersionStatus(cliPath, minimumVersion, remainingTimeoutMs, options.cancellationToken);
+ return (await this._getCliVersionStatus(cliPath, minimumVersion, remainingTimeoutMs, options.cancellationToken))?.status
+ ?? 'unavailable';
}
- private _getCliMinimumVersionStatus(
+ private async _getCliVersionStatus(
cliPath: string,
minimumVersion: CliVersion,
timeoutMs: number,
cancellationToken?: vscode.CancellationToken,
- ): Promise {
- return new Promise((resolve) => {
+ ): Promise {
+ const version = await this._probeCliVersion(cliPath, timeoutMs, cancellationToken);
+ if (!version) {
+ return null;
+ }
+
+ return {
+ cliPath,
+ version: version.value,
+ status: compareCliVersions(version, minimumVersion) >= 0 ? 'supported' : 'unsupported',
+ };
+ }
+
+ private async _probeCliVersion(
+ cliPath: string,
+ timeoutMs: number,
+ cancellationToken?: vscode.CancellationToken,
+ ): Promise {
+ const result = await this._runCliCapture(cliPath, ['--version'], {
+ timeoutMs: Math.min(timeoutMs, cliVersionProbeTimeoutMs),
+ maxStdoutLength: maxCliVersionOutputLength,
+ description: 'Aspire CLI version probe',
+ failureMessage: 'Unable to probe Aspire CLI version',
+ cancellationToken,
+ });
+ return result?.exitCode === 0 ? parseCliVersion(result.stdout) ?? null : null;
+ }
+
+ private async _fetchCliUpdateRecommendation(
+ cliPath: string,
+ workingDirectory: string,
+ cancellationToken?: vscode.CancellationToken,
+ ): Promise {
+ const deadline = Date.now() + cliUpdateProbeTimeoutMs;
+ let args = ['doctor', '--format', 'json', noLogoOption];
+
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const result = await this._runCliCapture(cliPath, args, {
+ timeoutMs: deadline - Date.now(),
+ maxStdoutLength: maxCliUpdateOutputLength,
+ maxStderrLength: maxCliUpdateOutputLength,
+ workingDirectory,
+ env: nonInteractiveCliEnvironment,
+ description: 'Aspire CLI update probe',
+ failureMessage: 'Unable to check for Aspire CLI updates',
+ cancellationToken,
+ });
+ if (!result) {
+ return { status: 'unavailable' };
+ }
+
+ try {
+ const recommendation = parseCliUpdateRecommendationOutput(result.stdout);
+ if (recommendation.status !== 'unavailable' ||
+ isStructuredDoctorOutput(result.stdout) ||
+ !isNoLogoUnsupportedOutput(args, result.stdout, result.stderr)) {
+ return recommendation;
+ }
+ }
+ catch (error) {
+ if (!isNoLogoUnsupportedOutput(args, result.stdout, result.stderr)) {
+ extensionLogOutputChannel.warn(`Unable to parse Aspire CLI update status: ${String(error)}`);
+ return { status: 'unavailable' };
+ }
+ }
+
+ args = removeRootNoLogoOption(args);
+ }
+
+ return { status: 'unavailable' };
+ }
+
+ private _runCliCapture(
+ cliPath: string,
+ args: string[],
+ options: CliCaptureOptions,
+ ): Promise {
+ return new Promise((resolve) => {
let childProcess: ChildProcessWithoutNullStreams | undefined;
- let output = '';
- let outputTooLong = false;
+ let termination: Promise | undefined;
+ let stdout = '';
+ let stderr = '';
+ let stdoutTooLong = false;
let settled = false;
let timeout: ReturnType | undefined;
let cancellation: vscode.Disposable | undefined;
- const settle = (result: CapabilityStatus) => {
+ const settle = (result: CliCaptureResult | null) => {
if (settled) {
return;
}
@@ -261,69 +453,67 @@ export class ConfigInfoProvider {
resolve(result);
};
const reportUnavailable = (error: unknown) => {
- if (settled) {
+ if (settled || termination) {
return;
}
- extensionLogOutputChannel.warn(`Unable to probe Aspire CLI version: ${String(error)}`);
- settle('unavailable');
+ extensionLogOutputChannel.warn(`${options.failureMessage}: ${String(error)}`);
+ settle(null);
};
- if (cancellationToken?.isCancellationRequested) {
- settle('unavailable');
+ const terminateAndSettle = (reason: 'timed-out' | 'cancelled') => {
+ if (settled || termination) {
+ return;
+ }
+ if (!childProcess) {
+ settle(null);
+ return;
+ }
+
+ const description = `${reason} ${options.description}`;
+ termination = terminateCliProcess(childProcess, description)
+ .catch(error => extensionLogOutputChannel.error(`Failed to terminate ${description}: ${String(error)}`))
+ .then(() => settle(null));
+ };
+ if (options.timeoutMs <= 0 || options.cancellationToken?.isCancellationRequested) {
+ settle(null);
return;
}
timeout = setTimeout(() => {
- settle('unavailable');
- if (childProcess) {
- void terminateCliProcess(childProcess, 'timed-out Aspire CLI version probe').catch(error => {
- extensionLogOutputChannel.error(`Failed to terminate timed-out Aspire CLI version probe: ${String(error)}`);
- });
- }
- }, Math.min(timeoutMs, cliVersionProbeTimeoutMs));
- cancellation = cancellationToken?.onCancellationRequested(() => {
- settle('unavailable');
- if (childProcess) {
- void terminateCliProcess(childProcess, 'cancelled Aspire CLI version probe').catch(error => {
- extensionLogOutputChannel.error(`Failed to terminate cancelled Aspire CLI version probe: ${String(error)}`);
- });
- }
- });
+ terminateAndSettle('timed-out');
+ }, options.timeoutMs);
+ cancellation = options.cancellationToken?.onCancellationRequested(() => terminateAndSettle('cancelled'));
try {
- childProcess = spawnCliProcess(this._terminalProvider, cliPath, ['--version'], {
+ childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, {
createProcessGroup: true,
+ workingDirectory: options.workingDirectory,
+ env: options.env,
+ noExtensionVariables: true,
stdoutCallback: value => {
- if (output.length + value.length > maxCliVersionOutputLength) {
- outputTooLong = true;
+ if (stdout.length + value.length > options.maxStdoutLength) {
+ stdoutTooLong = true;
return;
}
-
- output += value;
+ stdout += value;
+ },
+ stderrCallback: value => {
+ const maxStderrLength = options.maxStderrLength ?? 0;
+ if (stderr.length < maxStderrLength) {
+ stderr += value.slice(0, maxStderrLength - stderr.length);
+ }
},
exitCallback: code => {
- if (code !== 0 || outputTooLong) {
- settle('unavailable');
+ if (termination) {
return;
}
-
- const version = parseCliVersion(output);
- if (!version) {
- settle('unavailable');
+ if (stdoutTooLong) {
+ settle(null);
return;
}
-
- const comparison = compareCliVersions(version, minimumVersion);
- // A prerelease at the exact minimum core predates the stable version that
- // introduced the option, so treat it as known unsupported. Prerelease/dev
- // builds with a higher numeric core remain supported because the feature
- // already exists on that later release line.
- settle(comparison > 0 || (comparison === 0 && (!version.isPrerelease || minimumVersion.isPrerelease))
- ? 'supported'
- : 'unsupported');
+ settle({ stdout, stderr, exitCode: code });
},
errorCallback: reportUnavailable,
- noExtensionVariables: true,
});
}
catch (error) {
@@ -585,11 +775,44 @@ export class ConfigInfoProvider {
}
}
+async function getCliExecutableIdentity(
+ cliPath: string,
+ timeoutMs: number,
+ cancellationToken?: vscode.CancellationToken,
+): Promise {
+ if (timeoutMs <= 0 || cancellationToken?.isCancellationRequested) {
+ return undefined;
+ }
+
+ let timeout: ReturnType | undefined;
+ let cancellation: vscode.Disposable | undefined;
+ const callerCompletion = new Promise(resolve => {
+ timeout = setTimeout(() => resolve(undefined), timeoutMs);
+ cancellation = cancellationToken?.onCancellationRequested(() => resolve(undefined));
+ });
+ const fileIdentity = stat(cliPath, { bigint: true })
+ // Device/inode detects atomic replacement; size and timestamps also detect in-place rewrites.
+ .then(value =>
+ `${value.dev}:${value.ino}:${value.mode}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`)
+ .catch(() => undefined);
+
+ try {
+ return await Promise.race([fileIdentity, callerCompletion]);
+ }
+ finally {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ cancellation?.dispose();
+ }
+}
+
function parseCliVersion(value: string): CliVersion | undefined {
// `aspire --version` emits a bare semver-like value, for example:
// 13.2.0
// 13.2.0-preview.1.12345.6+abcdef
- // Only the bounded numeric core participates in comparison; any other output is unavailable.
+ // Comparison follows SemVer precedence, including prerelease identifiers, while build metadata
+ // remains informational. Any other output is unavailable.
const normalized = value.trim();
if (normalized.length === 0 || normalized.length > maxCliVersionOutputLength) {
return undefined;
@@ -601,17 +824,172 @@ function parseCliVersion(value: string): CliVersion | undefined {
}
return {
+ value: normalized,
major: Number.parseInt(match[1], 10),
minor: Number.parseInt(match[2], 10),
patch: Number.parseInt(match[3], 10),
isPrerelease: match[4] !== undefined,
+ prereleaseIdentifiers: match[4]?.split('.') ?? [],
};
}
function compareCliVersions(left: CliVersion, right: CliVersion): number {
- return left.major - right.major ||
+ const coreComparison = left.major - right.major ||
left.minor - right.minor ||
left.patch - right.patch;
+ if (coreComparison !== 0) {
+ return coreComparison;
+ }
+ if (left.isPrerelease !== right.isPrerelease) {
+ return left.isPrerelease ? -1 : 1;
+ }
+
+ for (let index = 0; index < Math.max(left.prereleaseIdentifiers.length, right.prereleaseIdentifiers.length); index++) {
+ const leftIdentifier = left.prereleaseIdentifiers[index];
+ const rightIdentifier = right.prereleaseIdentifiers[index];
+ if (leftIdentifier === undefined || rightIdentifier === undefined) {
+ return leftIdentifier === undefined ? -1 : 1;
+ }
+ if (leftIdentifier === rightIdentifier) {
+ continue;
+ }
+
+ const leftIsNumeric = /^\d+$/.test(leftIdentifier);
+ const rightIsNumeric = /^\d+$/.test(rightIdentifier);
+ if (leftIsNumeric !== rightIsNumeric) {
+ return leftIsNumeric ? -1 : 1;
+ }
+ if (leftIsNumeric) {
+ const lengthComparison = leftIdentifier.length - rightIdentifier.length;
+ if (lengthComparison !== 0) {
+ return lengthComparison;
+ }
+ }
+
+ return leftIdentifier < rightIdentifier ? -1 : 1;
+ }
+
+ return 0;
+}
+
+export function compareCliVersionValues(left: string, right: string): number | undefined {
+ const leftVersion = parseCliVersion(left);
+ const rightVersion = parseCliVersion(right);
+ return leftVersion && rightVersion ? compareCliVersions(leftVersion, rightVersion) : undefined;
+}
+
+export function parseCliUpdateRecommendationOutput(output: string): CliUpdateRecommendation {
+ // `aspire doctor --format json` emits:
+ // { "checks": [{ "name": "cli-version", "metadata": {
+ // "currentVersion": "13.5.0", "latestVersion": "13.6.0",
+ // "identityChannel": "stable", "latestVersionChannel": "stable" } }] }
+ // Doctor reports the channel baked into the installed assembly. This keeps local/PR/run builds
+ // silent even when their process environment emulates a published channel.
+ const response = JSON.parse(output.trim()) as { checks?: unknown };
+ if (!Array.isArray(response.checks)) {
+ return { status: 'unavailable' };
+ }
+
+ const check = response.checks.find(value =>
+ typeof value === 'object' &&
+ value !== null &&
+ (value as { name?: unknown }).name === 'cli-version') as { metadata?: unknown } | undefined;
+ if (typeof check?.metadata !== 'object' || check.metadata === null) {
+ return { status: 'unavailable' };
+ }
+
+ const metadata = check.metadata as {
+ currentVersion?: unknown;
+ latestVersion?: unknown;
+ identityChannel?: unknown;
+ latestVersionChannel?: unknown;
+ updateCheckError?: unknown;
+ };
+ if (typeof metadata.currentVersion !== 'string') {
+ return { status: 'unavailable' };
+ }
+
+ const currentVersion = parseCliVersion(metadata.currentVersion);
+ if (!currentVersion) {
+ return { status: 'unavailable' };
+ }
+
+ const identityChannel = normalizePublishedCliChannel(metadata.identityChannel);
+ if (!identityChannel) {
+ return { status: 'ineligible', currentVersion: currentVersion.value };
+ }
+ if (typeof metadata.updateCheckError === 'string' && metadata.updateCheckError.length > 0) {
+ return { status: 'unavailable' };
+ }
+
+ if (metadata.latestVersion === undefined || metadata.latestVersion === null) {
+ return { status: 'none', currentVersion: currentVersion.value };
+ }
+ if (typeof metadata.latestVersion !== 'string') {
+ return { status: 'unavailable' };
+ }
+
+ const latestVersion = parseCliVersion(metadata.latestVersion);
+ if (!latestVersion) {
+ return { status: 'unavailable' };
+ }
+
+ const latestVersionChannel = metadata.latestVersionChannel === undefined
+ ? (latestVersion.isPrerelease ? 'prerelease' : 'stable')
+ : normalizeRecommendationChannel(metadata.latestVersionChannel);
+ if (!latestVersionChannel) {
+ return { status: 'unavailable' };
+ }
+
+ const expectedRecommendationChannel = identityChannel === 'stable' ? 'stable' : 'prerelease';
+ if (latestVersionChannel !== expectedRecommendationChannel) {
+ // Doctor returns its stable recommendation before considering prerelease updates. For an
+ // unchanged identity that cross-lane result cannot become actionable, so classify it as
+ // ineligible and avoid rerunning the full Doctor battery on the normal refresh interval.
+ return { status: 'ineligible', currentVersion: currentVersion.value };
+ }
+
+ return { status: 'available', currentVersion: currentVersion.value, version: latestVersion.value };
+}
+
+function isStructuredDoctorOutput(output: string): boolean {
+ try {
+ const response = JSON.parse(output.trim()) as { checks?: unknown };
+ return Array.isArray(response.checks);
+ }
+ catch {
+ return false;
+ }
+}
+
+function normalizePublishedCliChannel(value: unknown): 'stable' | 'daily' | 'staging' | undefined {
+ if (typeof value !== 'string') {
+ return undefined;
+ }
+
+ if (value.length === 0 || value !== value.trim()) {
+ return undefined;
+ }
+
+ const channel = value.toLowerCase();
+ return channel === 'stable' || channel === 'daily' || channel === 'staging'
+ ? channel
+ : undefined;
+}
+
+function normalizeRecommendationChannel(value: unknown): 'stable' | 'prerelease' | undefined {
+ if (typeof value !== 'string') {
+ return undefined;
+ }
+
+ if (value.length === 0 || value !== value.trim()) {
+ return undefined;
+ }
+
+ const channel = value.toLowerCase();
+ return channel === 'stable' || channel === 'prerelease'
+ ? channel
+ : undefined;
}
export function parseConfigInfoOutput(output: string): ConfigInfo {
diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts
new file mode 100644
index 00000000000..b69193e8ad9
--- /dev/null
+++ b/extension/src/utils/outdatedCliNotifier.ts
@@ -0,0 +1,437 @@
+import * as path from 'path';
+import * as vscode from 'vscode';
+import * as strings from '../loc/strings';
+import {
+ CliUpdateRecommendation,
+ CliVersionInfo,
+ compareCliVersionValues,
+ ConfigInfoProvider,
+ resolveConfigInfoWorkingDirectory,
+} from './configInfoProvider';
+import { CliPathResolutionTarget, getCliPathTargetKey } from './cliPathVariables';
+import { extensionLogOutputChannel } from './logging';
+import { OutdatedCliNotificationClaim, OutdatedCliSuppressionStore } from './outdatedCliSuppressionStore';
+import { getComparisonKey } from './paths/comparison';
+
+const updateAspireCliCommand = 'aspire-vscode.updateSelf';
+const versionRefreshIntervalMs = 5 * 60 * 1_000;
+const versionFailureRetryMs = 60 * 1_000;
+const completedUpdateRefreshIntervalMs = 6 * 60 * 60 * 1_000;
+const unavailableRetryBaseMs = 60 * 1_000;
+const unavailableRetryMaximumMs = 30 * 60 * 1_000;
+const maximumUnavailableAttemptsPerIdentity = 3;
+
+type CliVersionProvider = Pick;
+
+export interface OutdatedCliNotificationSurface {
+ showWarning(message: string, ...actions: string[]): Thenable;
+ executeCommand(command: string, ...args: unknown[]): Thenable;
+}
+
+interface CliCheckState {
+ identity: CliVersionInfo | undefined;
+ versionValidUntil: number;
+ updateStatus: 'complete' | 'ineligible' | 'unavailable' | undefined;
+ updateValidUntil: number;
+ failureCount: number;
+}
+
+interface PendingNotification {
+ target: CliPathResolutionTarget;
+ cli: CliVersionInfo;
+ recommendedVersion: string;
+}
+
+const defaultSurface: OutdatedCliNotificationSurface = {
+ showWarning: (message, ...actions) => vscode.window.showWarningMessage(message, ...actions),
+ executeCommand: (command, ...args) => vscode.commands.executeCommand(command, ...args),
+};
+
+/**
+ * Checks actively used Aspire CLIs for a same-channel update. Version sampling is cheap and
+ * periodic; the heavyweight doctor adapter is limited to one active probe and cached independently.
+ */
+export class OutdatedCliNotifier implements vscode.Disposable {
+ private readonly _stateByCheckKey = new Map();
+ private readonly _notifiedCliVersions = new Set();
+ private readonly _persistentlySuppressedCliVersions: Set;
+ private readonly _inFlightByCheckKey = new Map>();
+ private readonly _inFlightVersionByCliPath = new Map>();
+ private readonly _cancellationSource = new vscode.CancellationTokenSource();
+ private readonly _versionQueue = new AsyncSerialQueue();
+ private readonly _doctorQueue = new AsyncSerialQueue();
+ private _disposed = false;
+
+ constructor(
+ private readonly _versionProvider: CliVersionProvider,
+ private readonly _surface: OutdatedCliNotificationSurface = defaultSurface,
+ private readonly _now: () => number = Date.now,
+ private readonly _suppressionStore?: OutdatedCliSuppressionStore,
+ ) {
+ this._persistentlySuppressedCliVersions = new Set();
+ }
+
+ async notifyIfOutdated(target: CliPathResolutionTarget, cliPath: string): Promise {
+ if (this._disposed) {
+ return;
+ }
+
+ const workingDirectory = resolveConfigInfoWorkingDirectory(target);
+ const checkKey = getCliCheckKey(target, cliPath, workingDirectory);
+ if ((this._stateByCheckKey.get(checkKey)?.versionValidUntil ?? 0) > this._now()) {
+ return;
+ }
+ if (!await this._refreshPersistedSuppressions() || this._disposed) {
+ return;
+ }
+ const existingProbe = this._inFlightByCheckKey.get(checkKey);
+ if (existingProbe) {
+ await existingProbe;
+ return;
+ }
+
+ const checkStartedAt = this._now();
+ let probe!: Promise;
+ probe = this._checkForUpdate(
+ target,
+ checkKey,
+ cliPath,
+ workingDirectory,
+ checkStartedAt).finally(() => {
+ if (this._inFlightByCheckKey.get(checkKey) === probe) {
+ this._inFlightByCheckKey.delete(checkKey);
+ }
+ });
+ this._inFlightByCheckKey.set(checkKey, probe);
+ const notification = await probe;
+ if (this._disposed || !notification) {
+ return;
+ }
+
+ const notificationKey = getNotificationKey(notification.cli.cliPath, notification.cli.version);
+ const sessionNotificationKey = getSessionNotificationKey(notification.cli);
+ if (this._notifiedCliVersions.has(sessionNotificationKey) ||
+ this._persistentlySuppressedCliVersions.has(notificationKey)) {
+ return;
+ }
+ this._notifiedCliVersions.add(sessionNotificationKey);
+
+ let claim: OutdatedCliNotificationClaim | undefined;
+ if (this._suppressionStore) {
+ try {
+ claim = await this._suppressionStore.tryClaimNotification(notificationKey);
+ }
+ catch (error) {
+ this._notifiedCliVersions.delete(sessionNotificationKey);
+ extensionLogOutputChannel.warn(`Unable to claim Aspire CLI update notification: ${String(error)}`);
+ this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli);
+ return;
+ }
+ if (!claim) {
+ this._persistentlySuppressedCliVersions.add(notificationKey);
+ return;
+ }
+ try {
+ if (!claim.isValid()) {
+ this._notifiedCliVersions.delete(sessionNotificationKey);
+ this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli);
+ await this._releaseNotificationClaim(claim);
+ return;
+ }
+ }
+ catch (error) {
+ this._notifiedCliVersions.delete(sessionNotificationKey);
+ extensionLogOutputChannel.warn(`Unable to validate Aspire CLI update notification claim: ${String(error)}`);
+ this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli);
+ await this._releaseNotificationClaim(claim);
+ return;
+ }
+ }
+ if (this._disposed) {
+ await this._releaseNotificationClaim(claim);
+ return;
+ }
+
+ let selectionPromise: Thenable;
+ try {
+ selectionPromise = this._surface.showWarning(
+ strings.outdatedAspireCliWarning(
+ notification.cli.version,
+ notification.cli.cliPath,
+ notification.recommendedVersion),
+ strings.updateAspireCliAction,
+ strings.dontShowAgainLabel);
+ }
+ finally {
+ await this._releaseNotificationClaim(claim);
+ }
+ const selection = await selectionPromise;
+ if (this._disposed) {
+ return;
+ }
+ if (selection === strings.dontShowAgainLabel) {
+ await this._suppressNotification(notificationKey);
+ return;
+ }
+ if (selection !== strings.updateAspireCliAction) {
+ return;
+ }
+
+ // This user-initiated guard intentionally bypasses the five-minute cache.
+ const currentVersionProbe = await this._getCliVersion(
+ notification.target,
+ notification.cli.cliPath,
+ false);
+ if (this._disposed) {
+ return;
+ }
+ if (!currentVersionProbe || !areCliIdentitiesEqual(currentVersionProbe, notification.cli)) {
+ return;
+ }
+
+ await this._surface.executeCommand(updateAspireCliCommand, notification.target, notification.cli.cliPath);
+ }
+
+ private async _refreshPersistedSuppressions(): Promise {
+ if (!this._suppressionStore) {
+ return true;
+ }
+
+ try {
+ for (const notificationKey of await this._suppressionStore.readAll()) {
+ this._persistentlySuppressedCliVersions.add(notificationKey);
+ }
+ return true;
+ }
+ catch (error) {
+ extensionLogOutputChannel.warn(`Unable to read Aspire CLI warning suppressions: ${String(error)}`);
+ return false;
+ }
+ }
+
+ private _invalidateRecommendationAfterSuppressionFailure(
+ checkKey: string,
+ identity: CliVersionInfo,
+ ): void {
+ const state = this._stateByCheckKey.get(checkKey);
+ if (state?.identity && areCliIdentitiesEqual(state.identity, identity)) {
+ state.versionValidUntil = 0;
+ state.updateStatus = undefined;
+ state.updateValidUntil = 0;
+ state.failureCount = 0;
+ }
+ }
+
+ private async _releaseNotificationClaim(claim: OutdatedCliNotificationClaim | undefined): Promise {
+ try {
+ await claim?.release();
+ }
+ catch (error) {
+ extensionLogOutputChannel.warn(`Unable to release Aspire CLI update notification claim: ${String(error)}`);
+ }
+ }
+
+ private _getCliVersion(
+ target: CliPathResolutionTarget,
+ cliPath: string,
+ coalesce = true,
+ ): Promise {
+ const cliPathKey = getComparisonKey(path.normalize(cliPath));
+ const startProbe = () => this._versionQueue.run(
+ () => this._versionProvider.getCliVersion({
+ target,
+ cliPath,
+ cancellationToken: this._cancellationSource.token,
+ }),
+ () => this._disposed);
+ if (!coalesce) {
+ return startProbe();
+ }
+
+ const existing = this._inFlightVersionByCliPath.get(cliPathKey);
+ if (existing) {
+ return existing;
+ }
+
+ let probe!: Promise;
+ probe = startProbe()
+ .finally(() => {
+ if (this._inFlightVersionByCliPath.get(cliPathKey) === probe) {
+ this._inFlightVersionByCliPath.delete(cliPathKey);
+ }
+ });
+ this._inFlightVersionByCliPath.set(cliPathKey, probe);
+ return probe;
+ }
+
+ private async _checkForUpdate(
+ target: CliPathResolutionTarget,
+ checkKey: string,
+ cliPath: string,
+ workingDirectory: string,
+ checkStartedAt: number,
+ ): Promise {
+ const versionProbe = await this._getCliVersion(target, cliPath);
+ if (this._disposed) {
+ return undefined;
+ }
+
+ const now = this._now();
+ const previous = this._stateByCheckKey.get(checkKey);
+ const identity = versionProbe;
+ if (!identity) {
+ this._stateByCheckKey.set(checkKey, {
+ identity: previous?.identity,
+ versionValidUntil: checkStartedAt + versionFailureRetryMs,
+ updateStatus: previous?.updateStatus,
+ updateValidUntil: previous?.updateValidUntil ?? 0,
+ failureCount: previous?.failureCount ?? 0,
+ });
+ return undefined;
+ }
+
+ const identityChanged = !areCliIdentitiesEqual(previous?.identity, identity);
+ const state: CliCheckState = identityChanged
+ ? {
+ identity,
+ versionValidUntil: checkStartedAt + versionRefreshIntervalMs,
+ updateStatus: undefined,
+ updateValidUntil: 0,
+ failureCount: 0,
+ }
+ : {
+ identity,
+ versionValidUntil: checkStartedAt + versionRefreshIntervalMs,
+ updateStatus: previous?.updateStatus,
+ updateValidUntil: previous?.updateValidUntil ?? 0,
+ failureCount: previous?.failureCount ?? 0,
+ };
+ this._stateByCheckKey.set(checkKey, state);
+
+ const notificationKey = getNotificationKey(identity.cliPath, identity.version);
+ if (this._persistentlySuppressedCliVersions.has(notificationKey)) {
+ return undefined;
+ }
+
+ if (!identityChanged &&
+ (state.updateStatus === 'ineligible' ||
+ (state.updateStatus !== undefined && state.updateValidUntil > now))) {
+ return undefined;
+ }
+
+ const recommendation = await this._doctorQueue.run(
+ () => this._versionProvider.getCliUpdateRecommendation({
+ target,
+ cliPath,
+ workingDirectory,
+ cancellationToken: this._cancellationSource.token,
+ }),
+ () => this._disposed);
+ if (recommendation === undefined || this._disposed) {
+ return undefined;
+ }
+
+ if (recommendation.status === 'ineligible') {
+ state.updateStatus = 'ineligible';
+ state.updateValidUntil = Number.POSITIVE_INFINITY;
+ state.failureCount = 0;
+ return undefined;
+ }
+ if (recommendation.status === 'unavailable' ||
+ compareCliVersionValues(identity.version, recommendation.currentVersion) !== 0) {
+ this._recordUnavailable(state);
+ return undefined;
+ }
+
+ state.updateStatus = 'complete';
+ state.updateValidUntil = this._now() + completedUpdateRefreshIntervalMs;
+ state.failureCount = 0;
+ if (recommendation.status !== 'available') {
+ return undefined;
+ }
+
+ const comparison = compareCliVersionValues(identity.version, recommendation.version);
+ return comparison !== undefined && comparison < 0
+ ? { target, cli: identity, recommendedVersion: recommendation.version }
+ : undefined;
+ }
+
+ private _recordUnavailable(state: CliCheckState): void {
+ state.failureCount = state.updateStatus === 'unavailable' ? state.failureCount + 1 : 1;
+ state.updateStatus = 'unavailable';
+ if (state.failureCount >= maximumUnavailableAttemptsPerIdentity) {
+ // Doctor runs the full environment-check battery. Stop retrying an unchanged identity
+ // for this session after a few silent failures; five-minute version sampling continues,
+ // so replacing the CLI resets the state and permits a fresh update check.
+ state.updateValidUntil = Number.POSITIVE_INFINITY;
+ return;
+ }
+ state.updateValidUntil = this._now() + Math.min(
+ unavailableRetryBaseMs * 2 ** (state.failureCount - 1),
+ unavailableRetryMaximumMs);
+ }
+
+ private async _suppressNotification(notificationKey: string): Promise {
+ this._persistentlySuppressedCliVersions.add(notificationKey);
+
+ try {
+ await this._suppressionStore?.add(notificationKey);
+ }
+ catch (error) {
+ extensionLogOutputChannel.warn(`Unable to persist Aspire CLI warning suppression: ${String(error)}`);
+ }
+ }
+
+ dispose(): void {
+ if (this._disposed) {
+ return;
+ }
+
+ this._disposed = true;
+ this._cancellationSource.cancel();
+ this._cancellationSource.dispose();
+ this._inFlightByCheckKey.clear();
+ this._inFlightVersionByCliPath.clear();
+ this._stateByCheckKey.clear();
+ this._notifiedCliVersions.clear();
+ }
+}
+
+function getNotificationKey(cliPath: string, version: string): string {
+ return `${getComparisonKey(path.normalize(cliPath))}\u0000${version}`;
+}
+
+function getSessionNotificationKey(cli: CliVersionInfo): string {
+ return `${getNotificationKey(cli.cliPath, cli.version)}\u0000${cli.executableIdentity}`;
+}
+
+function getCliCheckKey(
+ target: CliPathResolutionTarget,
+ cliPath: string,
+ workingDirectory: string,
+): string {
+ return `${getCliPathTargetKey(target)}\u0000${getComparisonKey(path.normalize(cliPath))}\u0000${getComparisonKey(path.normalize(workingDirectory))}`;
+}
+
+function areCliIdentitiesEqual(left: CliVersionInfo | undefined, right: CliVersionInfo): boolean {
+ return left?.version === right.version &&
+ left.executableIdentity === right.executableIdentity;
+}
+
+class AsyncSerialQueue {
+ private _tail: Promise = Promise.resolve();
+
+ async run(action: () => Promise, isCancelled: () => boolean): Promise {
+ const previous = this._tail;
+ let release!: () => void;
+ this._tail = new Promise(resolve => release = resolve);
+ await previous;
+
+ try {
+ return isCancelled() ? undefined : await action();
+ }
+ finally {
+ release();
+ }
+ }
+}
diff --git a/extension/src/utils/outdatedCliSuppressionStore.ts b/extension/src/utils/outdatedCliSuppressionStore.ts
new file mode 100644
index 00000000000..d1ade9071a3
--- /dev/null
+++ b/extension/src/utils/outdatedCliSuppressionStore.ts
@@ -0,0 +1,240 @@
+import { mkdir, readFile, readdir, rename, unlink, writeFile } from 'fs/promises';
+import * as path from 'path';
+import { setTimeout as delay } from 'timers/promises';
+
+const suppressionDirectoryName = 'outdated-cli-suppressions';
+const suppressionFilePrefix = 'suppression-';
+const notificationClaimFilePrefix = 'notification-claim-';
+const markerFileSuffix = '.json';
+const notificationClaimRetryIntervalMs = 10;
+const notificationClaimLeaseMs = 60_000;
+let markerSequence = 0;
+
+/** Represents a published claim that remains valid until its bounded lease expires. */
+export interface OutdatedCliNotificationClaim {
+ isValid(): boolean;
+ release(): Promise;
+}
+
+export interface OutdatedCliSuppressionStore {
+ readAll(): Promise;
+ add(notificationKey: string): Promise;
+ tryClaimNotification(notificationKey: string): Promise;
+}
+
+interface NotificationClaimMarker {
+ notificationKey?: unknown;
+ processId?: unknown;
+ createdAt?: unknown;
+}
+
+/**
+ * Uses immutable markers to order notifications and suppressions across extension hosts. A claim is
+ * published before its final suppression read. A suppression is published before it waits for older
+ * claims to dispatch, so it cannot complete before a warning that already passed the final read.
+ */
+export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppressionStore {
+ private readonly _directoryPath: string;
+
+ constructor(globalStoragePath: string) {
+ this._directoryPath = path.join(globalStoragePath, suppressionDirectoryName);
+ }
+
+ async readAll(): Promise {
+ await mkdir(this._directoryPath, { recursive: true });
+ return await this._readAllSuppressions();
+ }
+
+ async add(notificationKey: string): Promise {
+ await this._publishMarker(suppressionFilePrefix, notificationKey);
+ await this._waitForNotificationClaims(notificationKey);
+ }
+
+ async tryClaimNotification(notificationKey: string): Promise {
+ const createdAt = Date.now();
+ const claimPath = await this._publishMarker(notificationClaimFilePrefix, {
+ notificationKey,
+ processId: process.pid,
+ createdAt,
+ });
+ let released = false;
+ const claim: OutdatedCliNotificationClaim = {
+ isValid: () => isLeaseCurrent(createdAt),
+ release: async () => {
+ if (released) {
+ return;
+ }
+ await unlink(claimPath).catch(error => {
+ if (!hasErrorCode(error, 'ENOENT')) {
+ throw error;
+ }
+ });
+ released = true;
+ },
+ };
+
+ try {
+ if ((await this._readAllSuppressions()).includes(notificationKey)) {
+ await claim.release();
+ return undefined;
+ }
+
+ return claim;
+ }
+ catch (error) {
+ await claim.release();
+ throw error;
+ }
+ }
+
+ private async _readAllSuppressions(): Promise {
+ const entries = await readdir(this._directoryPath, { withFileTypes: true });
+ const suppressions: string[] = [];
+
+ for (const entry of entries) {
+ if (!entry.isFile() ||
+ !entry.name.startsWith(suppressionFilePrefix) ||
+ !entry.name.endsWith(markerFileSuffix)) {
+ continue;
+ }
+
+ // Each suppression marker contains a JSON string:
+ // "C:\\tools\\aspire.exe\u000013.5.0"
+ const notificationKey = JSON.parse(
+ await readFile(path.join(this._directoryPath, entry.name), 'utf8')) as unknown;
+ if (typeof notificationKey !== 'string') {
+ throw new Error(`Invalid Aspire CLI warning suppression file: ${entry.name}`);
+ }
+ suppressions.push(notificationKey);
+ }
+
+ return suppressions;
+ }
+
+ private async _waitForNotificationClaims(notificationKey: string): Promise {
+ while (true) {
+ let hasActiveClaim = false;
+ const entries = await readdir(this._directoryPath, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isFile() ||
+ !entry.name.startsWith(notificationClaimFilePrefix) ||
+ !entry.name.endsWith(markerFileSuffix)) {
+ continue;
+ }
+
+ const claimPath = path.join(this._directoryPath, entry.name);
+ const markerIdentity = getNotificationClaimIdentity(entry.name);
+ // Claim markers contain:
+ // { "notificationKey": "\\u0000",
+ // "processId": 12345, "createdAt": 1788280000000 }
+ const contents = await readFile(claimPath, 'utf8').catch(error => {
+ if (hasErrorCode(error, 'ENOENT')) {
+ return undefined;
+ }
+ throw error;
+ });
+ if (contents === undefined) {
+ continue;
+ }
+ let claim: NotificationClaimMarker | null = null;
+ try {
+ claim = JSON.parse(contents) as NotificationClaimMarker | null;
+ }
+ catch (error) {
+ if (!(error instanceof SyntaxError)) {
+ throw error;
+ }
+ }
+
+ if (claim === null ||
+ !markerIdentity ||
+ typeof claim.notificationKey !== 'string' ||
+ claim.processId !== markerIdentity.processId ||
+ claim.createdAt !== markerIdentity.createdAt) {
+ if (markerIdentity &&
+ isLeaseCurrent(markerIdentity.createdAt) &&
+ isProcessRunning(markerIdentity.processId)) {
+ hasActiveClaim = true;
+ } else {
+ await removeFileIfPresent(claimPath);
+ }
+ continue;
+ }
+
+ if (isLeaseCurrent(claim.createdAt) && isProcessRunning(claim.processId)) {
+ if (claim.notificationKey === notificationKey) {
+ hasActiveClaim = true;
+ }
+ continue;
+ }
+
+ await removeFileIfPresent(claimPath);
+ }
+
+ if (!hasActiveClaim) {
+ return;
+ }
+ await delay(notificationClaimRetryIntervalMs);
+ }
+ }
+
+ private async _publishMarker(prefix: string, value: unknown): Promise {
+ await mkdir(this._directoryPath, { recursive: true });
+ const generation = `${Date.now()}-${process.pid}-${markerSequence++}`;
+ const fileName = `${prefix}${generation}${markerFileSuffix}`;
+ const temporaryPath = path.join(this._directoryPath, `.${fileName}.tmp`);
+ const finalPath = path.join(this._directoryPath, fileName);
+
+ await writeFile(temporaryPath, JSON.stringify(value), { encoding: 'utf8', flag: 'wx' });
+ try {
+ await rename(temporaryPath, finalPath);
+ }
+ catch (error) {
+ await unlink(temporaryPath).catch(cleanupError => {
+ if (!hasErrorCode(cleanupError, 'ENOENT')) {
+ throw cleanupError;
+ }
+ });
+ throw error;
+ }
+
+ return finalPath;
+ }
+}
+
+function isLeaseCurrent(createdAt: number): boolean {
+ const age = Date.now() - createdAt;
+ return age >= 0 && age < notificationClaimLeaseMs;
+}
+
+function getNotificationClaimIdentity(
+ fileName: string,
+): { createdAt: number; processId: number } | undefined {
+ const match = /^notification-claim-(\d+)-(\d+)-\d+\.json$/.exec(fileName);
+ return match
+ ? { createdAt: Number(match[1]), processId: Number(match[2]) }
+ : undefined;
+}
+
+function isProcessRunning(processId: number): boolean {
+ try {
+ process.kill(processId, 0);
+ return true;
+ }
+ catch (error) {
+ return !hasErrorCode(error, 'ESRCH');
+ }
+}
+
+async function removeFileIfPresent(filePath: string): Promise {
+ await unlink(filePath).catch(error => {
+ if (!hasErrorCode(error, 'ENOENT')) {
+ throw error;
+ }
+ });
+}
+
+function hasErrorCode(error: unknown, ...codes: string[]): boolean {
+ const code = error instanceof Error && 'code' in error ? error.code : undefined;
+ return typeof code === 'string' && codes.includes(code);
+}
diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts
index 6fc22680429..0bd39691bf6 100644
--- a/extension/src/utils/workspace.ts
+++ b/extension/src/utils/workspace.ts
@@ -8,6 +8,7 @@ import { CliPathResolutionTarget } from './cliPathVariables';
import { AppHostDiscoveryService, AppHostProjectSearchResult, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult } from './appHostDiscovery';
import { sendTelemetryEvent } from './telemetry';
import { getCommonExcludeGlob } from './workspaceFileSearch';
+import { reportCliResolvedForOperation } from './cliOperationResolution';
export { getCommonExcludeGlob } from './workspaceFileSearch';
@@ -282,6 +283,8 @@ export async function checkCliAvailableOrRedirect(
});
if (result.available) {
+ reportCliResolvedForOperation(target, result.cliPath);
+
// Show informational message if CLI was found at default path (not on PATH)
if (result.source === 'default-install') {
extensionLogOutputChannel.info(`Using Aspire CLI from default install location: ${result.cliPath}`);