port clide runtime to Windows (ConPTY, AF_UNIX, PATHEXT)

Bring the runtime up on Windows without disturbing the POSIX paths.

PTY: introduce a platform-neutral PtySession contract with a factory
that picks NativePty (posix_openpt/posix_spawn) or the new WindowsPty
(ConPTY via CreatePseudoConsole). The pane registry programs against
the interface; NativePty now implements it.

IPC: the per-workspace AF_UNIX socket lives under %LOCALAPPDATA% and
is hashed from a canonical workspace key (backslash + ASCII-folded
case) so the Dart server and the C client agree despite NTFS case-
insensitivity. The C client grows a Win32 shim (winsock afunix);
chmod is a no-op on Windows where the per-user ACL is the gate.

Toolchain: PATH probing splits on ';' and tries PATHEXT extensions;
the shell defaults to PowerShell (pwsh, then powershell); tmux is
treated as optional since it has no Windows build; dugite falls back
to PATH git for now.

Build: add `make build-windows`, a clide-cli MSVC build wrapped by
ci/build_cli_windows.sh, and a ConPTY smoke-test suite that self-
skips off-platform.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-14 17:42:55 +02:00
co-authored by Claude
parent 3a59f4f2e6
commit 03cc0603b0
19 changed files with 1257 additions and 103 deletions
+35 -12
View File
@@ -52,7 +52,7 @@ class _StaticToolchain implements ToolchainView {
@override
String get tmux => _paths.tmux ?? 'tmux';
@override
String get shell => _paths.shell ?? '/bin/bash';
String get shell => _paths.shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash');
@override
Map<String, String>? get gitEnv => _paths.gitEnv;
@override
@@ -60,7 +60,13 @@ class _StaticToolchain implements ToolchainView {
@override
bool get allOk => missing.isEmpty;
@override
List<String> get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql', if (_paths.tmux == null) 'tmux'];
List<String> get missing => [
if (_paths.git == null) 'git',
if (_paths.pql == null) 'pql',
// tmux has no Windows build; its absence there is the documented
// no-tmux mode, not a missing tool.
if (_paths.tmux == null && !Platform.isWindows) 'tmux',
];
}
/// Top-level function for compute/isolate use. Returns a plain-data
@@ -83,13 +89,17 @@ ResolvedPaths resolveToolchainPaths() {
git = _findOnPath('git');
}
return ResolvedPaths(
git: git,
pql: _findOnPath('pql'),
tmux: _findOnPath('tmux'),
shell: _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash'),
gitEnv: gitEnv,
);
return ResolvedPaths(git: git, pql: _findOnPath('pql'), tmux: _findOnPath('tmux'), shell: _resolveShell(), gitEnv: gitEnv);
}
/// The user's interactive shell. POSIX honours `$SHELL`; Windows has
/// no such convention — prefer PowerShell 7 (`pwsh`), fall back to
/// Windows PowerShell (present on every supported Windows).
String? _resolveShell() {
if (Platform.isWindows) {
return _findOnPath('pwsh') ?? _findOnPath('powershell');
}
return _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
}
/// Locate the dugite-bundled git binary in trusted install locations
@@ -104,6 +114,10 @@ ResolvedPaths resolveToolchainPaths() {
///
/// Returns null if no dugite is found; caller falls back to PATH git.
String? _resolveDugiteGit() {
// dugite-native's Windows layout differs (cmd\git.exe, mingw64
// libexec) and isn't wired up yet — PATH git serves Windows until
// the bundle work lands.
if (Platform.isWindows) return null;
final candidates = <String>[];
final envDir = Platform.environment['CLIDE_DUGITE_DIR'];
if (envDir != null && envDir.isNotEmpty) {
@@ -116,10 +130,19 @@ String? _resolveDugiteGit() {
}
String? _findOnPath(String name) {
for (final dir in _expandedPath().split(':')) {
final sep = Platform.isWindows ? ';' : ':';
for (final dir in _expandedPath().split(sep)) {
if (dir.isEmpty) continue;
final f = File('$dir/$name');
if (f.existsSync()) return f.path;
if (Platform.isWindows) {
// PATHEXT-style probe — a bare `pql` on PATH is really pql.exe.
for (final ext in const ['.exe', '.bat', '.cmd', '.com', '']) {
final f = File('$dir\\$name$ext');
if (f.existsSync()) return f.path;
}
} else {
final f = File('$dir/$name');
if (f.existsSync()) return f.path;
}
}
return null;
}