Skip to content

Commit a22af72

Browse files
authored
Create project output subfolder in VS Code extension mode (#15976)
* Create project output subfolder in VS Code extension mode When creating a new project via the VS Code extension, append the project name as a subdirectory to the user-selected output path. This matches the git clone experience where selecting a parent folder results in a clean subfolder for the project. Fixes #15737 * Address review: trailing separator, path traversal guard, test coverage - Use Path.TrimEndingDirectorySeparator before comparing the last path segment to the project name, preventing double-append when the folder picker returns a path ending with a separator (e.g. C:\source\). - Guard against path traversal by skipping the append when the project name is '.' or '..'. - Add test for trailing directory separator scenarios (both parent folder and already-matching folder).
1 parent 827b8e5 commit a22af72

2 files changed

Lines changed: 301 additions & 1 deletion

File tree

src/Aspire.Cli/Templating/DotNetTemplateFactory.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,28 @@ private async Task<string> GetOutputPathAsync(TemplateInputs inputs, Func<string
616616
outputPath = await prompter.PromptForOutputPath(pathDeriver(projectName), cancellationToken);
617617
}
618618

619-
return Path.GetFullPath(outputPath);
619+
outputPath = Path.GetFullPath(outputPath);
620+
621+
// When running in extension mode (VS Code), the folder picker returns the parent
622+
// directory the user selected. Append the project name as a subdirectory so the
623+
// project gets its own clean folder, matching the git-clone convention.
624+
if (ExtensionHelper.IsExtensionHost(interactionService, out _, out _)
625+
&& !projectName.Equals(".", StringComparison.Ordinal)
626+
&& !projectName.Equals("..", StringComparison.Ordinal))
627+
{
628+
var normalizedOutputPath = Path.TrimEndingDirectorySeparator(outputPath);
629+
630+
if (!string.Equals(Path.GetFileName(normalizedOutputPath), projectName, StringComparison.OrdinalIgnoreCase))
631+
{
632+
outputPath = Path.Combine(normalizedOutputPath, projectName);
633+
}
634+
else
635+
{
636+
outputPath = normalizedOutputPath;
637+
}
638+
}
639+
640+
return outputPath;
620641
}
621642

622643
private async Task<(NuGetPackage Package, PackageChannel Channel)> GetProjectTemplatesVersionAsync(TemplateInputs inputs, CancellationToken cancellationToken)

tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,6 +1664,285 @@ public async Task NewCommand_WhenTypeScriptTemplateApplyFails_ReturnsNonZeroExit
16641664
Assert.NotEqual(0, exitCode);
16651665
Assert.NotNull(testInteractionService);
16661666
}
1667+
1668+
[Fact]
1669+
public async Task NewCommandInExtensionModeAppendsProjectNameToOutputPath()
1670+
{
1671+
using var workspace = TemporaryWorkspace.Create(outputHelper);
1672+
string? capturedOutputPath = null;
1673+
1674+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
1675+
{
1676+
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
1677+
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
1678+
{
1679+
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
1680+
};
1681+
1682+
options.NewCommandPrompterFactory = (sp) =>
1683+
{
1684+
var interactionService = sp.GetRequiredService<IInteractionService>();
1685+
var prompter = new TestNewCommandPrompter(interactionService);
1686+
1687+
prompter.PromptForProjectNameCallback = (_) => "MyFirstApp";
1688+
1689+
// Simulate the user picking a parent folder (not named after the project)
1690+
prompter.PromptForOutputPathCallback = (_) =>
1691+
Path.Combine(workspace.WorkspaceRoot.FullName, "source");
1692+
1693+
return prompter;
1694+
};
1695+
1696+
options.DotNetCliRunnerFactory = (sp) =>
1697+
{
1698+
var runner = new TestDotNetCliRunner();
1699+
runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
1700+
{
1701+
var package = new NuGetPackage()
1702+
{
1703+
Id = "Aspire.ProjectTemplates",
1704+
Source = "nuget",
1705+
Version = "9.2.0"
1706+
};
1707+
return (0, new NuGetPackage[] { package });
1708+
};
1709+
runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) =>
1710+
{
1711+
return (0, version);
1712+
};
1713+
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
1714+
{
1715+
capturedOutputPath = outputPath;
1716+
return 0;
1717+
};
1718+
return runner;
1719+
};
1720+
});
1721+
var provider = services.BuildServiceProvider();
1722+
1723+
var command = provider.GetRequiredService<RootCommand>();
1724+
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
1725+
1726+
var exitCode = await result.InvokeAsync().DefaultTimeout();
1727+
1728+
Assert.Equal(0, exitCode);
1729+
Assert.NotNull(capturedOutputPath);
1730+
1731+
// Output path should have the project name appended as a subdirectory
1732+
var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source", "MyFirstApp");
1733+
Assert.Equal(expectedPath, capturedOutputPath);
1734+
}
1735+
1736+
[Fact]
1737+
public async Task NewCommandInExtensionModeDoesNotDoubleAppendProjectName()
1738+
{
1739+
using var workspace = TemporaryWorkspace.Create(outputHelper);
1740+
string? capturedOutputPath = null;
1741+
1742+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
1743+
{
1744+
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
1745+
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
1746+
{
1747+
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
1748+
};
1749+
1750+
options.NewCommandPrompterFactory = (sp) =>
1751+
{
1752+
var interactionService = sp.GetRequiredService<IInteractionService>();
1753+
var prompter = new TestNewCommandPrompter(interactionService);
1754+
1755+
prompter.PromptForProjectNameCallback = (_) => "MyFirstApp";
1756+
1757+
// Simulate the user picking a folder already named after the project
1758+
prompter.PromptForOutputPathCallback = (_) =>
1759+
Path.Combine(workspace.WorkspaceRoot.FullName, "source", "MyFirstApp");
1760+
1761+
return prompter;
1762+
};
1763+
1764+
options.DotNetCliRunnerFactory = (sp) =>
1765+
{
1766+
var runner = new TestDotNetCliRunner();
1767+
runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
1768+
{
1769+
var package = new NuGetPackage()
1770+
{
1771+
Id = "Aspire.ProjectTemplates",
1772+
Source = "nuget",
1773+
Version = "9.2.0"
1774+
};
1775+
return (0, new NuGetPackage[] { package });
1776+
};
1777+
runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) =>
1778+
{
1779+
return (0, version);
1780+
};
1781+
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
1782+
{
1783+
capturedOutputPath = outputPath;
1784+
return 0;
1785+
};
1786+
return runner;
1787+
};
1788+
});
1789+
var provider = services.BuildServiceProvider();
1790+
1791+
var command = provider.GetRequiredService<RootCommand>();
1792+
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
1793+
1794+
var exitCode = await result.InvokeAsync().DefaultTimeout();
1795+
1796+
Assert.Equal(0, exitCode);
1797+
Assert.NotNull(capturedOutputPath);
1798+
1799+
// Output path should NOT have the project name double-appended
1800+
var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source", "MyFirstApp");
1801+
Assert.Equal(expectedPath, capturedOutputPath);
1802+
}
1803+
1804+
[Fact]
1805+
public async Task NewCommandInConsoleModeDoesNotAppendProjectName()
1806+
{
1807+
using var workspace = TemporaryWorkspace.Create(outputHelper);
1808+
string? capturedOutputPath = null;
1809+
1810+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
1811+
{
1812+
// Default InteractionServiceFactory creates ConsoleInteractionService (not extension mode)
1813+
1814+
options.NewCommandPrompterFactory = (sp) =>
1815+
{
1816+
var interactionService = sp.GetRequiredService<IInteractionService>();
1817+
var prompter = new TestNewCommandPrompter(interactionService);
1818+
1819+
prompter.PromptForProjectNameCallback = (_) => "MyFirstApp";
1820+
1821+
// Simulate user accepting default path or selecting parent folder
1822+
prompter.PromptForOutputPathCallback = (_) =>
1823+
Path.Combine(workspace.WorkspaceRoot.FullName, "source");
1824+
1825+
return prompter;
1826+
};
1827+
1828+
options.DotNetCliRunnerFactory = (sp) =>
1829+
{
1830+
var runner = new TestDotNetCliRunner();
1831+
runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
1832+
{
1833+
var package = new NuGetPackage()
1834+
{
1835+
Id = "Aspire.ProjectTemplates",
1836+
Source = "nuget",
1837+
Version = "9.2.0"
1838+
};
1839+
return (0, new NuGetPackage[] { package });
1840+
};
1841+
runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) =>
1842+
{
1843+
return (0, version);
1844+
};
1845+
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
1846+
{
1847+
capturedOutputPath = outputPath;
1848+
return 0;
1849+
};
1850+
return runner;
1851+
};
1852+
});
1853+
var provider = services.BuildServiceProvider();
1854+
1855+
var command = provider.GetRequiredService<RootCommand>();
1856+
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
1857+
1858+
var exitCode = await result.InvokeAsync().DefaultTimeout();
1859+
1860+
Assert.Equal(0, exitCode);
1861+
Assert.NotNull(capturedOutputPath);
1862+
1863+
// In console mode, the output path should NOT have project name appended
1864+
var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source");
1865+
Assert.Equal(expectedPath, capturedOutputPath);
1866+
}
1867+
1868+
[Fact]
1869+
public async Task NewCommandInExtensionModeHandlesTrailingDirectorySeparator()
1870+
{
1871+
const string projectName = "MyFirstApp";
1872+
1873+
async Task AssertOutputPathAsync(Func<string, string> selectedPathFactory, Func<string, string> expectedPathFactory)
1874+
{
1875+
using var workspace = TemporaryWorkspace.Create(outputHelper);
1876+
string? capturedOutputPath = null;
1877+
1878+
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
1879+
{
1880+
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
1881+
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
1882+
{
1883+
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
1884+
};
1885+
1886+
options.NewCommandPrompterFactory = (sp) =>
1887+
{
1888+
var interactionService = sp.GetRequiredService<IInteractionService>();
1889+
var prompter = new TestNewCommandPrompter(interactionService);
1890+
1891+
prompter.PromptForProjectNameCallback = (_) => projectName;
1892+
1893+
prompter.PromptForOutputPathCallback = (_) =>
1894+
selectedPathFactory(workspace.WorkspaceRoot.FullName);
1895+
1896+
return prompter;
1897+
};
1898+
1899+
options.DotNetCliRunnerFactory = (sp) =>
1900+
{
1901+
var runner = new TestDotNetCliRunner();
1902+
runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
1903+
{
1904+
var package = new NuGetPackage()
1905+
{
1906+
Id = "Aspire.ProjectTemplates",
1907+
Source = "nuget",
1908+
Version = "9.2.0"
1909+
};
1910+
return (0, new NuGetPackage[] { package });
1911+
};
1912+
runner.InstallTemplateAsyncCallback = (packageName, version, nugetSource, force, invocationOptions, ct) =>
1913+
{
1914+
return (0, version);
1915+
};
1916+
runner.NewProjectAsyncCallback = (templateName, pName, outputPath, invocationOptions, ct) =>
1917+
{
1918+
capturedOutputPath = outputPath;
1919+
return 0;
1920+
};
1921+
return runner;
1922+
};
1923+
});
1924+
var provider = services.BuildServiceProvider();
1925+
1926+
var command = provider.GetRequiredService<RootCommand>();
1927+
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
1928+
1929+
var exitCode = await result.InvokeAsync().DefaultTimeout();
1930+
1931+
Assert.Equal(0, exitCode);
1932+
Assert.NotNull(capturedOutputPath);
1933+
Assert.Equal(expectedPathFactory(workspace.WorkspaceRoot.FullName), capturedOutputPath);
1934+
}
1935+
1936+
// Trailing separator on a parent folder should still append the project name once.
1937+
await AssertOutputPathAsync(
1938+
workspaceRoot => Path.Combine(workspaceRoot, "source") + Path.DirectorySeparatorChar,
1939+
workspaceRoot => Path.Combine(workspaceRoot, "source", projectName));
1940+
1941+
// Trailing separator on a folder already named after the project should not double-append.
1942+
await AssertOutputPathAsync(
1943+
workspaceRoot => Path.Combine(workspaceRoot, projectName) + Path.DirectorySeparatorChar,
1944+
workspaceRoot => Path.Combine(workspaceRoot, projectName));
1945+
}
16671946
}
16681947

16691948
internal sealed class TestNewCommandPrompter(IInteractionService interactionService) : NewCommandPrompter(interactionService)

0 commit comments

Comments
 (0)