Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ca91813
Warn when Aspire CLI is outdated
ellahathaway Aug 25, 2026
9d30114
Route outdated CLI updates to the warned executable
ellahathaway Aug 25, 2026
7f75bbf
Fix outdated CLI warning lifecycle
ellahathaway Aug 25, 2026
0301818
Stabilize outdated CLI notification E2E
ellahathaway Aug 25, 2026
067268e
Warn from exact Aspire CLI operations
ellahathaway Aug 25, 2026
30954a2
Transfer CLI probe slots directly
ellahathaway Aug 25, 2026
c492da2
Report one-shot Aspire CLI operations
ellahathaway Aug 25, 2026
9bfc2ba
Generalize outdated Aspire CLI warnings
ellahathaway Aug 27, 2026
8670c6e
Require verified CLI before self-update
ellahathaway Aug 27, 2026
225341e
Cover general stable CLI updates
ellahathaway Aug 27, 2026
c10a0cf
Resample CLIs for active follow operations
ellahathaway Aug 27, 2026
b66564d
Bound Aspire CLI identity sampling
ellahathaway Aug 27, 2026
a853391
Narrow CLI update recommendation options
ellahathaway Aug 27, 2026
b9bffec
Persist outdated CLI warning suppression
ellahathaway Aug 28, 2026
f06937b
Add persistent outdated CLI suppression
ellahathaway Aug 28, 2026
3562860
Harden outdated CLI notification checks
ellahathaway Aug 28, 2026
f0580a9
Fix multi-root CLI update state
ellahathaway Aug 28, 2026
61f64a4
Persist CLI warning suppression atomically
ellahathaway Aug 28, 2026
6bb4784
Simplify outdated CLI notifications
ellahathaway Aug 28, 2026
20b388e
Report external stop CLI resolution
ellahathaway Aug 28, 2026
bb69a8a
Correct stable CLI update example
ellahathaway Aug 28, 2026
f70d5bf
Bound concurrent CLI version probes
ellahathaway Aug 28, 2026
91fa61f
Fix cross-window CLI warning suppression
ellahathaway Sep 1, 2026
bf2d6ea
Make CLI warning suppression linearizable
ellahathaway Sep 1, 2026
a0dbdd0
Make suppression lease recovery transferable
ellahathaway Sep 1, 2026
3c4de2e
Bound suppression lease identity
ellahathaway Sep 1, 2026
64f5ce5
Replace suppression lock with marker handshake
ellahathaway Sep 1, 2026
6e174f9
Expire claims after clock rollback
ellahathaway Sep 1, 2026
e7fcb22
Correct Doctor payload example
ellahathaway Sep 1, 2026
dd78aff
Report manual restore CLI use
ellahathaway Sep 1, 2026
752fc2e
Track physical CLI replacements
ellahathaway Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions extension/loc/xlf/aspire-vscode.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
Comment thread
ellahathaway marked this conversation as resolved.
"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)",
Expand Down
41 changes: 28 additions & 13 deletions extension/src/activation/registerCliCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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));
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions extension/src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
2 changes: 2 additions & 0 deletions extension/src/data/AppHostDataRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion extension/src/data/appHostCliRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions extension/src/data/appHostPsPoller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = '';
Expand Down
14 changes: 14 additions & 0 deletions extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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)}`);
});
Comment thread
ellahathaway marked this conversation as resolved.
}));
const appHostDiscoveryService = new AppHostDiscoveryService(terminalProvider, configInfoProvider);
context.subscriptions.push(appHostDiscoveryService);

Expand Down
2 changes: 2 additions & 0 deletions extension/src/loc/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
5 changes: 4 additions & 1 deletion extension/src/services/AppHostStopper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -11,10 +12,12 @@ export async function stopExternalAppHost(
cancellationToken: vscode.CancellationToken,
): Promise<void> {

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<void>((resolve, reject) => {
let settled = false;
Expand Down
8 changes: 7 additions & 1 deletion extension/src/test/appHostCliRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -150,14 +151,19 @@ 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',
});

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();
}
});
Expand Down
26 changes: 26 additions & 0 deletions extension/src/test/appHostDataRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand Down
8 changes: 8 additions & 0 deletions extension/src/test/appHostStopper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand All @@ -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();
}
Expand Down
Loading
Loading