Skip to content

Commit 3f53299

Browse files
committed
Auto-pick base port for worktrees and drop CLI base port argument
1 parent 0411cd5 commit 3f53299

5 files changed

Lines changed: 54 additions & 54 deletions

File tree

.claude/skills/aspire-restart/SKILL.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: Start or restart the .NET Aspire AppHost via the developer CLI. Alw
66
# Restart Aspire
77

88
```bash
9-
dotnet run --project developer-cli -- restart [<basePort>]
9+
dotnet run --project developer-cli -- restart
1010
```
1111

1212
Use `developer-cli` exactly as written - do not expand to an absolute worktree path.
@@ -15,7 +15,6 @@ Stops any running Aspire AppHost and starts a fresh instance. Detached by defaul
1515

1616
Always use `restart`, even when nothing is running yet. It is a no-op when Aspire is not up, and the safe default in every other case. Never use the developer CLI's `run` command, `aspire run`, or `aspire restart`.
1717

18-
- `<basePort>` - optional positional argument; written to `.workspace/port.txt` before Aspire starts
1918
- `--public-url <url>` - set `PUBLIC_URL` (e.g. an ngrok URL)
2019

2120
## When to use
@@ -25,10 +24,9 @@ Always use `restart`, even when nothing is running yet. It is a no-op when Aspir
2524
- When hot reload breaks or stops picking up changes.
2625
- Before running e2e tests on a fresh stack.
2726

28-
## Picking the base port
27+
## Port allocation
2928

30-
- In the git root: omit `<basePort>` (uses the default).
31-
- In a worktree: on the first start, pick the first free port from `10000`, `11000`, `12000`, `13000` (`lsof -i :<port>` on macOS/Linux, `netstat -ano | findstr :<port>` on Windows). Keep using that port for the lifetime of the worktree - never change it after.
29+
Fully automatic. On a fresh checkout the developer CLI bootstraps `.workspace/port.txt`: the root gets the default base port; worktrees scan a fixed list of candidates and pick the first one whose ports are all free. Once written, the file is the authoritative allocation for the lifetime of the checkout.
3230

3331
## Output is fire-and-forget
3432

application/shared-kernel/SharedKernel/Configuration/PortAllocation.cs

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
using System.Net;
2+
using System.Net.Sockets;
3+
14
namespace SharedKernel.Configuration;
25

36
// Single source of truth for the local development port allocation. Reads .workspace/port.txt
4-
// (single integer, whitespace-tolerant). If the file is missing, self-bootstraps with the default
5-
// base port so the first run on a fresh checkout just works. Throws on a present-but-invalid file.
7+
// (single integer, whitespace-tolerant). If the file is missing, self-bootstraps: the root
8+
// checkout gets the default base port; a worktree scans a fixed list of candidate base ports
9+
// and picks the first one whose ports are all free locally. Once written, port.txt is the
10+
// authoritative allocation for the lifetime of the checkout. Throws on a present-but-invalid file.
611
public sealed record PortAllocation(int BasePort)
712
{
813
private const int DefaultBasePort = 9000;
@@ -11,6 +16,9 @@ public sealed record PortAllocation(int BasePort)
1116

1217
private const string PortFileName = "port.txt";
1318

19+
// Worktrees scan these in order and pick the first base port whose full allocation is free.
20+
private static readonly int[] WorktreeCandidateBasePorts = [9100, 9200, 9300, 9400, 9500, 9600, 9700, 9800, 9900];
21+
1422
public int AppGateway => BasePort;
1523

1624
public int Aspire => BasePort + 1;
@@ -74,9 +82,12 @@ public static PortAllocation LoadFrom(string repositoryRoot)
7482

7583
if (!File.Exists(portFilePath))
7684
{
85+
var bootstrapPort = IsWorktree(repositoryRoot)
86+
? FindFreeBasePortForWorktree()
87+
: DefaultBasePort;
7788
Directory.CreateDirectory(workspaceDirectory);
78-
File.WriteAllText(portFilePath, $"{DefaultBasePort}{Environment.NewLine}");
79-
return new PortAllocation(DefaultBasePort);
89+
File.WriteAllText(portFilePath, $"{bootstrapPort}{Environment.NewLine}");
90+
return new PortAllocation(bootstrapPort);
8091
}
8192

8293
var content = File.ReadAllText(portFilePath).Trim();
@@ -90,28 +101,45 @@ public static PortAllocation LoadFrom(string repositoryRoot)
90101
return new PortAllocation(basePort);
91102
}
92103

93-
// True if .workspace/port.txt already exists -- distinguishes a fresh worktree from a configured one.
104+
// True if .workspace/port.txt already exists -- distinguishes a fresh checkout from a configured one.
94105
public static bool PortFileExists(string repositoryRoot)
95106
{
96107
var portFilePath = Path.Combine(repositoryRoot, WorkspaceDirectoryName, PortFileName);
97108
return File.Exists(portFilePath);
98109
}
99110

100-
// Atomically writes the base port to .workspace/port.txt under the given repository root.
101-
// Callers (e.g., the developer CLI's positional base-port argument on run/restart) use this to
102-
// update the file before any code path lazily loads PortAllocation -- otherwise the lazy load
103-
// would race with the write and could bootstrap with the default port.
104-
public static void WriteBasePort(string repositoryRoot, int basePort)
111+
// .git is a directory in the root checkout and a file in git worktrees.
112+
private static bool IsWorktree(string repositoryRoot)
105113
{
106-
if (basePort <= 0) throw new ArgumentOutOfRangeException(nameof(basePort), basePort, "Base port must be a positive integer.");
114+
return File.Exists(Path.Combine(repositoryRoot, ".git"));
115+
}
107116

108-
var workspaceDirectory = Path.Combine(repositoryRoot, WorkspaceDirectoryName);
109-
var portFilePath = Path.Combine(workspaceDirectory, PortFileName);
110-
var temporaryFilePath = portFilePath + ".tmp";
117+
private static int FindFreeBasePortForWorktree()
118+
{
119+
foreach (var candidate in WorktreeCandidateBasePorts)
120+
{
121+
var allocation = new PortAllocation(candidate);
122+
if (allocation.AllPorts.All(IsTcpPortFree)) return candidate;
123+
}
111124

112-
Directory.CreateDirectory(workspaceDirectory);
113-
File.WriteAllText(temporaryFilePath, $"{basePort}{Environment.NewLine}");
114-
File.Move(temporaryFilePath, portFilePath, true);
125+
throw new InvalidOperationException(
126+
$"No free base port available for this worktree. Tried: {string.Join(", ", WorktreeCandidateBasePorts)}."
127+
);
128+
}
129+
130+
private static bool IsTcpPortFree(int port)
131+
{
132+
try
133+
{
134+
var listener = new TcpListener(IPAddress.Loopback, port);
135+
listener.Start();
136+
listener.Stop();
137+
return true;
138+
}
139+
catch (SocketException)
140+
{
141+
return false;
142+
}
115143
}
116144

117145
private static string FindRepositoryRoot(string startDirectory)

developer-cli/Commands/ClaudeCommand/SendInterruptSignalCommand.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ namespace DeveloperCli.Commands.ClaudeCommand;
44

55
internal sealed class SendInterruptSignalCommand : Command
66
{
7-
private readonly Option<string> _teamOption = new("--team", "-t") { Description = "Team name", Required = true };
8-
97
private readonly Option<string> _agentOption = new("--agent", "-a") { Description = "Target agent name", Required = true };
8+
private readonly Option<string> _teamOption = new("--team", "-t") { Description = "Team name", Required = true };
109

1110
public SendInterruptSignalCommand() : base("send-interrupt-signal", "Send an interrupt signal to a team agent")
1211
{

developer-cli/Commands/RestartCommand.cs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,38 +11,32 @@ public class RestartCommand : Command
1111
{
1212
public RestartCommand() : base("restart", "Stops any running Aspire AppHost and starts a fresh instance")
1313
{
14-
var basePortArgument = new Argument<int?>("basePort") { Description = "Optional base port. If provided, written to .workspace/port.txt before Aspire starts.", DefaultValueFactory = _ => null };
1514
var watchOption = new Option<bool>("--watch", "-w") { Description = "Enable watch mode for hot reload" };
1615
var attachOption = new Option<bool>("--attach", "-a") { Description = "Keep the CLI process attached to the Aspire process (detached is the default)" };
1716
var publicUrlOption = new Option<string?>("--public-url") { Description = "Set the PUBLIC_URL environment variable for the app (e.g., https://example.ngrok-free.app)" };
1817

19-
Arguments.Add(basePortArgument);
2018
Options.Add(watchOption);
2119
Options.Add(attachOption);
2220
Options.Add(publicUrlOption);
2321

2422
SetAction(parseResult => Execute(
25-
parseResult.GetValue(basePortArgument),
2623
parseResult.GetValue(watchOption),
2724
parseResult.GetValue(attachOption),
2825
parseResult.GetValue(publicUrlOption)
2926
)
3027
);
3128
}
3229

33-
private static void Execute(int? basePort, bool watch, bool attach, string? publicUrl)
30+
private static void Execute(bool watch, bool attach, string? publicUrl)
3431
{
3532
Prerequisite.Ensure(Prerequisite.Dotnet, Prerequisite.Node, Prerequisite.Docker);
3633

37-
// Skip stop in a fresh worktree (no port.txt) -- nothing to stop, and the check would otherwise false-positive on another worktree's stack.
34+
// Skip stop in a fresh checkout (no port.txt) -- nothing to stop, and the check would otherwise false-positive on another stack.
3835
if (PortAllocation.PortFileExists(Configuration.SourceCodeFolder) && RunCommand.IsAspireRunning())
3936
{
40-
// Stop on the OLD port before writing the new one; otherwise stop would look at the new port and miss the running old stack.
4137
RunCommand.StopAspire();
4238
}
4339

44-
RunCommand.WriteBasePortIfProvided(basePort);
45-
4640
RunCommand.CheckForPortConflicts();
4741

4842
RunCommand.StartAspireAppHost(watch, attach, publicUrl);

developer-cli/Commands/RunCommand.cs

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,15 @@ public class RunCommand : Command
1313
{
1414
public RunCommand() : base("run", "Runs Aspire AppHost (use --watch for hot reload)")
1515
{
16-
var basePortArgument = new Argument<int?>("basePort") { Description = "Optional base port. If provided, written to .workspace/port.txt before Aspire starts.", DefaultValueFactory = _ => null };
1716
var watchOption = new Option<bool>("--watch", "-w") { Description = "Enable watch mode for hot reload" };
1817
var attachOption = new Option<bool>("--attach", "-a") { Description = "Keep the CLI process attached to the Aspire process (detached is the default)" };
1918
var publicUrlOption = new Option<string?>("--public-url") { Description = "Set the PUBLIC_URL environment variable for the app (e.g., https://example.ngrok-free.app)" };
2019

21-
Arguments.Add(basePortArgument);
2220
Options.Add(watchOption);
2321
Options.Add(attachOption);
2422
Options.Add(publicUrlOption);
2523

2624
SetAction(parseResult => Execute(
27-
parseResult.GetValue(basePortArgument),
2825
parseResult.GetValue(watchOption),
2926
parseResult.GetValue(attachOption),
3027
parseResult.GetValue(publicUrlOption)
@@ -34,9 +31,6 @@ public class RunCommand : Command
3431

3532
// The CLI binary is published outside the repo, so PortAllocation.Load (which walks up from
3633
// AppContext.BaseDirectory) cannot find the repo. Use the CLI's known SourceCodeFolder instead.
37-
// Re-read on every access so a run/restart invocation with a positional base port picks up the
38-
// freshly written .workspace/port.txt without any cached PortAllocation lingering from earlier
39-
// in the call.
4034
internal static PortAllocation Ports => PortAllocation.LoadFrom(Configuration.SourceCodeFolder);
4135

4236
internal static int AspirePort => Ports.Aspire;
@@ -45,37 +39,24 @@ public class RunCommand : Command
4539

4640
internal static int ResourceServicePort => Ports.ResourceService;
4741

48-
private static void Execute(int? basePort, bool watch, bool attach, string? publicUrl)
42+
private static void Execute(bool watch, bool attach, string? publicUrl)
4943
{
5044
Prerequisite.Ensure(Prerequisite.Dotnet, Prerequisite.Node, Prerequisite.Docker);
5145

52-
// Refuse if Aspire is already on the currently configured port -- updating port.txt now would orphan the running stack.
46+
// Refuse if Aspire is already on the currently configured port.
5347
// Skipped in a fresh worktree (no port.txt) where the check would false-positive on another worktree's stack.
5448
if (PortAllocation.PortFileExists(Configuration.SourceCodeFolder) && IsAspireRunning())
5549
{
5650
var alias = Configuration.AliasName;
57-
var message = basePort is null
58-
? $"Aspire AppHost is already running on port {AspirePort}. Run '{alias} stop' to stop it or '{alias} restart' to start a fresh instance."
59-
: $"Aspire AppHost is already running on port {AspirePort}. Run '{alias} stop' first or use '{alias} restart {basePort}' to switch.";
60-
AnsiConsole.MarkupLine($"[yellow]{message}[/]");
51+
AnsiConsole.MarkupLine($"[yellow]Aspire AppHost is already running on port {AspirePort}. Run '{alias} stop' to stop it or '{alias} restart' to start a fresh instance.[/]");
6152
Environment.Exit(1);
6253
}
6354

64-
WriteBasePortIfProvided(basePort);
65-
6655
CheckForPortConflicts();
6756

6857
StartAspireAppHost(watch, attach, publicUrl);
6958
}
7059

71-
internal static void WriteBasePortIfProvided(int? basePort)
72-
{
73-
if (basePort is null) return;
74-
75-
PortAllocation.WriteBasePort(Configuration.SourceCodeFolder, basePort.Value);
76-
AnsiConsole.MarkupLine($"[blue]Set base port to {basePort.Value}.[/]");
77-
}
78-
7960
internal static bool IsAspireRunning()
8061
{
8162
// Check the main Aspire port

0 commit comments

Comments
 (0)