SDT/src/DevTool.Host.Bridge/BridgeStdioServer.cs
stan44 d5a74be368 Add guided CLI workflows and config commands
- introduce `sdt` subcommands for run, debug, setup, env, favorite, and explain
- add project/workspace discovery plus config bootstrap and migration helpers
- expand tests for CLI parsing, project role detection, and headless flows
2026-03-29 22:22:48 -05:00

706 lines
29 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using Sdt.Config;
using Sdt.Core;
namespace Sdt.Bridge;
public sealed class BridgeStdioServer
{
private readonly JsonSerializerOptions _json;
private readonly string? _startupProjectRoot;
private readonly RunEventLogReader _eventReader = new();
private readonly ConfigDoctorService _doctor = new(new ToolProbeService(), new RequirementResolver());
private readonly SetupWizardConfigService _setupConfigService = new(new RequirementResolver());
private readonly ConfigDoctorAutoFixService _doctorFixes = new();
public BridgeStdioServer(string? startupProjectRoot = null)
{
_startupProjectRoot = startupProjectRoot;
_json = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
_json.Converters.Add(new JsonStringEnumConverter());
}
public async Task<int> RunAsync(CancellationToken cancellationToken = default)
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await Console.In.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
break;
if (string.IsNullOrWhiteSpace(line))
continue;
BridgeResponseEnvelope response;
try
{
var request = JsonSerializer.Deserialize<BridgeRequestEnvelope>(line, _json);
if (request is null || string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.Method))
{
response = Error(null, "bad_request", "Invalid bridge request envelope.");
}
else
{
response = await HandleAsync(request, cancellationToken).ConfigureAwait(false);
}
}
catch (JsonException jex)
{
response = Error(null, "bad_json", $"Invalid JSON: {jex.Message}");
}
catch (Exception ex)
{
response = Error(null, "internal_error", ex.Message);
}
Console.Out.WriteLine(JsonSerializer.Serialize(response, _json));
await Console.Out.FlushAsync(cancellationToken).ConfigureAwait(false);
}
return 0;
}
private async Task<BridgeResponseEnvelope> HandleAsync(BridgeRequestEnvelope request, CancellationToken cancellationToken)
{
try
{
return request.Method switch
{
"workspace.get" => Ok(request.Id, HandleWorkspaceGet(request.Params)),
"workspace.add" => Ok(request.Id, HandleWorkspaceAdd(request.Params)),
"favorites.list" => Ok(request.Id, HandleFavoritesList(request.Params)),
"favorites.toggle" => Ok(request.Id, HandleFavoritesToggle(request.Params)),
"favorites.removeMany" => Ok(request.Id, HandleFavoritesRemoveMany(request.Params)),
"history.list" => Ok(request.Id, HandleHistoryList(request.Params)),
"events.listFiles" => Ok(request.Id, HandleEventsListFiles(request.Params)),
"events.readFile" => Ok(request.Id, HandleEventsReadFile(request.Params)),
"envProfiles.list" => Ok(request.Id, HandleEnvProfilesList(request.Params)),
"envProfiles.resolve" => Ok(request.Id, HandleEnvProfilesResolve(request.Params)),
"envProfiles.setActive" => Ok(request.Id, HandleEnvProfilesSetActive(request.Params)),
"envProfiles.saveProfile" => Ok(request.Id, HandleEnvProfilesSaveProfile(request.Params)),
"envVars.list" => Ok(request.Id, HandleEnvVarsList(request.Params)),
"envVars.save" => Ok(request.Id, HandleEnvVarsSave(request.Params)),
"doctor.run" => Ok(request.Id, await HandleDoctorRunAsync(request.Params, cancellationToken).ConfigureAwait(false)),
"setup.plan" => Ok(request.Id, HandleSetupPlan(request.Params)),
"setup.autofixDirs" => Ok(request.Id, HandleSetupAutofixDirs(request.Params)),
"setup.migrateLegacy" => Ok(request.Id, HandleSetupMigrateLegacy(request.Params)),
"setup.applyRecommendedConfig" => Ok(request.Id, HandleSetupApplyRecommendedConfig(request.Params)),
_ => Error(request.Id, "method_not_found", $"Unsupported bridge method: {request.Method}")
};
}
catch (BridgeValidationException vex)
{
return Error(request.Id, "validation_failed", vex.Message);
}
catch (Exception ex)
{
return Error(request.Id, "method_failed", ex.Message);
}
}
private object HandleWorkspaceGet(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
var (workspace, workspaceRoot) = loaded;
var currentRoot = ConfigLoader.FindAndLoad(startDir)?.ProjectRoot ?? Path.GetFullPath(startDir);
var inventory = WorkspaceLoader.ScanInventory(workspaceRoot, currentRoot, workspace);
return new
{
workspaceRoot,
currentProjectRoot = currentRoot,
configuredProjects = workspace.Projects.Select(project => new
{
name = project.Name,
description = project.Description,
path = project.Path,
tags = project.Tags,
toolFamilies = project.ToolFamilies,
disabled = project.Disabled,
detectedBy = project.DetectedBy,
lastValidatedUtc = project.LastValidatedUtc,
resolvedRoot = WorkspaceLoader.ResolveProjectRoot(workspaceRoot, project)
}),
favorites = workspace.Favorites.Select(f => new
{
id = GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId),
projectPath = f.ProjectPath,
workflowId = f.WorkflowId,
label = f.Label,
resolvedProjectRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f)
}),
knownProjects = inventory.KnownProjects,
candidates = inventory.Candidates,
scanStats = inventory.Snapshot.ScanStats
};
}
private object HandleWorkspaceAdd(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
var candidatePath = GetRequiredString(@params, "candidatePath");
var initConfig = GetBool(@params, "initializeConfig") ?? false;
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
var (workspace, workspaceRoot) = loaded;
var absoluteCandidate = Path.GetFullPath(candidatePath);
if (!Directory.Exists(absoluteCandidate))
throw new BridgeValidationException($"Candidate path does not exist: {absoluteCandidate}");
if (initConfig && ConfigLoader.FindConfigPath(absoluteCandidate) is null)
{
var scan = ConfigBootstrapper.Scan(absoluteCandidate);
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
}
var existing = workspace.Projects.Any(p =>
string.Equals(
WorkspaceLoader.ResolveProjectRoot(workspaceRoot, p),
absoluteCandidate,
StringComparison.OrdinalIgnoreCase));
if (!existing)
{
var relPath = Path.GetRelativePath(workspaceRoot, absoluteCandidate);
workspace.Projects.Add(new WorkspaceProject
{
Name = Path.GetFileName(absoluteCandidate),
Description = initConfig ? "Added via GUI bridge (initialized)." : "Added via GUI bridge.",
Path = relPath,
Tags = [],
ToolFamilies = [],
Disabled = false,
DetectedBy = "inventory",
LastValidatedUtc = DateTimeOffset.UtcNow,
});
WorkspaceLoader.Save(workspaceRoot, workspace);
}
return HandleWorkspaceGet(@params);
}
private object HandleFavoritesList(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
var (workspace, workspaceRoot) = loaded;
return workspace.Favorites.Select(f => new
{
id = GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId),
projectPath = f.ProjectPath,
workflowId = f.WorkflowId,
label = f.Label,
resolvedProjectRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f)
}).ToList();
}
private object HandleFavoritesToggle(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
var projectPath = GetRequiredString(@params, "favoriteProjectPath");
var workflowId = GetRequiredString(@params, "workflowId");
var label = GetString(@params, "label");
var id = GetString(@params, "id");
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
var (workspace, workspaceRoot) = loaded;
var absoluteProject = Path.GetFullPath(projectPath);
var relativeProject = Path.IsPathRooted(projectPath) ? Path.GetRelativePath(workspaceRoot, absoluteProject) : projectPath;
var existing = workspace.Favorites.FirstOrDefault(f =>
string.Equals(WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f), absoluteProject, StringComparison.OrdinalIgnoreCase) &&
string.Equals(f.WorkflowId, workflowId, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
workspace.Favorites.Remove(existing);
}
else
{
workspace.Favorites.Add(new WorkspaceFavorite
{
Id = string.IsNullOrWhiteSpace(id) ? GuidedTaskCatalog.Slugify(label ?? workflowId) : id,
ProjectPath = relativeProject,
WorkflowId = workflowId,
Label = string.IsNullOrWhiteSpace(label) ? null : label
});
}
WorkspaceLoader.Save(workspaceRoot, workspace);
return HandleFavoritesList(@params);
}
private object HandleFavoritesRemoveMany(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
var (workspace, workspaceRoot) = loaded;
if (@params.ValueKind != JsonValueKind.Object ||
!@params.TryGetProperty("items", out var itemsProp) ||
itemsProp.ValueKind != JsonValueKind.Array)
{
throw new BridgeValidationException("Missing required parameter 'items'.");
}
var items = new List<(string ProjectPath, string WorkflowId)>();
foreach (var item in itemsProp.EnumerateArray())
{
var path = item.TryGetProperty("projectPath", out var pp) && pp.ValueKind == JsonValueKind.String
? pp.GetString()
: null;
var workflowId = item.TryGetProperty("workflowId", out var wf) && wf.ValueKind == JsonValueKind.String
? wf.GetString()
: null;
if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(workflowId))
continue;
items.Add((Path.GetFullPath(path), workflowId!));
}
if (items.Count == 0)
return HandleFavoritesList(@params);
workspace.Favorites.RemoveAll(f =>
{
var resolved = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f);
return items.Any(i =>
string.Equals(i.ProjectPath, resolved, StringComparison.OrdinalIgnoreCase) &&
string.Equals(i.WorkflowId, f.WorkflowId, StringComparison.OrdinalIgnoreCase));
});
WorkspaceLoader.Save(workspaceRoot, workspace);
return HandleFavoritesList(@params);
}
private object HandleHistoryList(JsonElement @params)
{
var projectRoot = ResolveProjectRootForProjectScopedMethod(@params);
var limit = GetInt(@params, "limit") ?? 50;
return _eventReader.ListRunHistory(projectRoot, Math.Clamp(limit, 1, 500));
}
private object HandleEventsListFiles(JsonElement @params)
{
var projectRoot = ResolveProjectRootForProjectScopedMethod(@params);
return _eventReader.ListEventFiles(projectRoot);
}
private object HandleEventsReadFile(JsonElement @params)
{
var projectRoot = ResolveProjectRootForProjectScopedMethod(@params);
var filePath = GetRequiredString(@params, "filePath");
var absolute = Path.GetFullPath(filePath);
var eventsRoot = Path.GetFullPath(Path.Combine(projectRoot, ".sdt", "events"));
if (!absolute.StartsWith(eventsRoot, StringComparison.OrdinalIgnoreCase))
throw new BridgeValidationException("Event file path must be under .sdt/events.");
return _eventReader.ReadEvents(absolute);
}
private object HandleEnvProfilesList(JsonElement @params)
{
var loaded = LoadProject(@params);
var envProfiles = loaded.Config.EnvProfiles;
return new
{
active = envProfiles?.Active,
profiles = envProfiles?.Profiles ?? []
};
}
private object HandleEnvProfilesResolve(JsonElement @params)
{
var loaded = LoadProject(@params);
var profileId = GetString(@params, "envProfile");
var effective = EnvProfileService.ResolveEffectiveEnv(loaded.Config, profileId);
return new
{
selected = string.IsNullOrWhiteSpace(profileId) ? loaded.Config.EnvProfiles?.Active : profileId,
values = effective
};
}
private object HandleEnvProfilesSetActive(JsonElement @params)
{
var loaded = LoadProject(@params);
var profileId = GetRequiredString(@params, "profileId");
var envProfiles = loaded.Config.EnvProfiles ?? new EnvProfilesConfig
{
Active = profileId,
Profiles = []
};
var profileExists = envProfiles.Profiles.Any(p =>
string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase));
if (!profileExists)
throw new BridgeValidationException($"Profile '{profileId}' does not exist.");
var updated = new DevToolConfig
{
Name = loaded.Config.Name,
Version = loaded.Config.Version,
Targets = loaded.Config.Targets,
Workflows = loaded.Config.Workflows,
Env = loaded.Config.Env,
EnvProfiles = new EnvProfilesConfig
{
Active = profileId,
Profiles = envProfiles.Profiles
},
Toolchains = loaded.Config.Toolchains,
Tooling = loaded.Config.Tooling,
Project = loaded.Config.Project,
Debug = loaded.Config.Debug
};
var save = SaveConfigWithBackup(loaded.ProjectRoot, updated);
return new
{
projectRoot = loaded.ProjectRoot,
profileId,
save,
envProfiles = new
{
active = profileId,
profiles = envProfiles.Profiles
}
};
}
private object HandleEnvProfilesSaveProfile(JsonElement @params)
{
var loaded = LoadProject(@params);
if (@params.ValueKind != JsonValueKind.Object ||
!@params.TryGetProperty("profile", out var profileProp) ||
profileProp.ValueKind != JsonValueKind.Object)
{
throw new BridgeValidationException("Missing required parameter 'profile'.");
}
var id = profileProp.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String
? idProp.GetString()
: null;
if (string.IsNullOrWhiteSpace(id))
throw new BridgeValidationException("Profile id is required.");
var description = profileProp.TryGetProperty("description", out var dProp) && dProp.ValueKind == JsonValueKind.String
? dProp.GetString() ?? ""
: "";
var inherits = new List<string>();
if (profileProp.TryGetProperty("inherits", out var inhProp) && inhProp.ValueKind == JsonValueKind.Array)
{
foreach (var entry in inhProp.EnumerateArray())
{
if (entry.ValueKind == JsonValueKind.String)
{
var value = entry.GetString();
if (!string.IsNullOrWhiteSpace(value))
inherits.Add(value!);
}
}
}
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (profileProp.TryGetProperty("values", out var valuesProp) && valuesProp.ValueKind == JsonValueKind.Object)
{
foreach (var kvp in valuesProp.EnumerateObject())
{
if (kvp.Value.ValueKind == JsonValueKind.String)
values[kvp.Name] = kvp.Value.GetString() ?? "";
}
}
var envProfiles = loaded.Config.EnvProfiles ?? new EnvProfilesConfig
{
Active = id!,
Profiles = []
};
var profiles = envProfiles.Profiles.ToList();
var existingIndex = profiles.FindIndex(p => string.Equals(p.Id, id, StringComparison.OrdinalIgnoreCase));
var newProfile = new EnvProfileDefinition
{
Id = id!,
Description = description,
Inherits = inherits,
Values = values,
};
if (existingIndex >= 0)
profiles[existingIndex] = newProfile;
else
profiles.Add(newProfile);
var active = string.IsNullOrWhiteSpace(envProfiles.Active) ? id! : envProfiles.Active;
var updated = new DevToolConfig
{
Name = loaded.Config.Name,
Version = loaded.Config.Version,
Targets = loaded.Config.Targets,
Workflows = loaded.Config.Workflows,
Env = loaded.Config.Env,
EnvProfiles = new EnvProfilesConfig
{
Active = active,
Profiles = profiles
},
Toolchains = loaded.Config.Toolchains,
Tooling = loaded.Config.Tooling,
Project = loaded.Config.Project,
Debug = loaded.Config.Debug
};
var save = SaveConfigWithBackup(loaded.ProjectRoot, updated);
return new
{
projectRoot = loaded.ProjectRoot,
profile = newProfile,
save,
envProfiles = new
{
active,
profiles
}
};
}
private async Task<object> HandleDoctorRunAsync(JsonElement @params, CancellationToken cancellationToken)
{
var loaded = LoadProject(@params);
var report = await _doctor.RunAsync(loaded.Config, loaded.ProjectRoot, cancellationToken).ConfigureAwait(false);
return report;
}
private object HandleSetupPlan(JsonElement @params)
{
var loaded = LoadProject(@params);
var report = _doctor.RunAsync(loaded.Config, loaded.ProjectRoot).GetAwaiter().GetResult();
var update = _setupConfigService.ApplyRecommendedDefaults(loaded.Config);
var missingDirs = _doctorFixes.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot);
return new
{
projectRoot = loaded.ProjectRoot,
doctor = new
{
failCount = report.Checks.Count(c => c.Status == DoctorStatus.Fail),
warnCount = report.Checks.Count(c => c.Status == DoctorStatus.Warn),
checks = report.Checks
},
plan = new[]
{
new { id = "doctor", label = "Run config doctor", mode = "read-only", count = report.Checks.Count },
new { id = "autofix-dirs", label = "Create missing working directories", mode = "preview", count = missingDirs.Count },
new { id = "legacy-migration", label = "Migrate legacy targets -> workflows", mode = "preview", count = loaded.Config.Targets.Count > 0 ? 1 : 0 },
new { id = "recommended-config", label = "Apply recommended config enhancements", mode = "preview", count = update.Changes.Count },
},
recommendedChanges = update.Changes
};
}
private object HandleEnvVarsList(JsonElement @params)
{
var loaded = LoadProject(@params);
return new
{
projectRoot = loaded.ProjectRoot,
env = loaded.Config.Env
};
}
private object HandleEnvVarsSave(JsonElement @params)
{
var loaded = LoadProject(@params);
if (@params.ValueKind != JsonValueKind.Object ||
!@params.TryGetProperty("env", out var envProp) ||
envProp.ValueKind != JsonValueKind.Array)
{
throw new BridgeValidationException("Missing required parameter 'env'.");
}
var env = new List<EnvVarDef>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in envProp.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
continue;
var key = item.TryGetProperty("key", out var keyProp) && keyProp.ValueKind == JsonValueKind.String
? keyProp.GetString()
: null;
if (string.IsNullOrWhiteSpace(key))
throw new BridgeValidationException("Each env var requires a non-empty 'key'.");
if (!seen.Add(key!))
throw new BridgeValidationException($"Duplicate env key detected: '{key}'.");
var description = item.TryGetProperty("description", out var descProp) && descProp.ValueKind == JsonValueKind.String
? descProp.GetString() ?? ""
: "";
var defaultValue = item.TryGetProperty("default", out var defProp) && defProp.ValueKind == JsonValueKind.String
? defProp.GetString() ?? ""
: "";
var options = new List<string>();
if (item.TryGetProperty("options", out var optionsProp) && optionsProp.ValueKind == JsonValueKind.Array)
{
foreach (var opt in optionsProp.EnumerateArray())
{
if (opt.ValueKind == JsonValueKind.String)
options.Add(opt.GetString() ?? "");
}
}
env.Add(new EnvVarDef
{
Key = key!,
Description = description,
DefaultValue = defaultValue,
Options = options
});
}
var updated = new DevToolConfig
{
Name = loaded.Config.Name,
Version = loaded.Config.Version,
Targets = loaded.Config.Targets,
Workflows = loaded.Config.Workflows,
Env = env,
EnvProfiles = loaded.Config.EnvProfiles,
Toolchains = loaded.Config.Toolchains,
Tooling = loaded.Config.Tooling,
Project = loaded.Config.Project,
Debug = loaded.Config.Debug
};
var save = SaveConfigWithBackup(loaded.ProjectRoot, updated);
return new
{
projectRoot = loaded.ProjectRoot,
env,
save
};
}
private object HandleSetupAutofixDirs(JsonElement @params)
{
var loaded = LoadProject(@params);
var missingDirs = _doctorFixes.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot);
var result = _doctorFixes.CreateMissingWorkingDirectories(missingDirs);
return new
{
projectRoot = loaded.ProjectRoot,
missingDirectories = missingDirs,
result
};
}
private object HandleSetupMigrateLegacy(JsonElement @params)
{
var loaded = LoadProject(@params);
var result = _doctorFixes.ApplyLegacyMigration(loaded.ProjectRoot);
return new
{
projectRoot = loaded.ProjectRoot,
result
};
}
private object HandleSetupApplyRecommendedConfig(JsonElement @params)
{
var loaded = LoadProject(@params);
var update = _setupConfigService.ApplyRecommendedDefaults(loaded.Config);
if (update.Changes.Count == 0)
{
return new
{
projectRoot = loaded.ProjectRoot,
changes = update.Changes,
applied = false,
message = "No recommended config changes were needed."
};
}
var save = SaveConfigWithBackup(loaded.ProjectRoot, update.Config);
return new
{
projectRoot = loaded.ProjectRoot,
changes = update.Changes,
applied = save.Success,
save
};
}
private LoadedProjectConfig LoadProject(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
return ConfigLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No project config found for project.");
}
private string ResolveProjectRootForProjectScopedMethod(JsonElement @params)
{
var explicitRoot = GetString(@params, "projectRoot");
if (!string.IsNullOrWhiteSpace(explicitRoot))
return Path.GetFullPath(explicitRoot);
return LoadProject(@params).ProjectRoot;
}
private static string? GetString(JsonElement @params, string name)
{
if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop))
return null;
return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null;
}
private static string GetRequiredString(JsonElement @params, string name)
{
var value = GetString(@params, name);
if (string.IsNullOrWhiteSpace(value))
throw new BridgeValidationException($"Missing required parameter '{name}'.");
return value;
}
private static bool? GetBool(JsonElement @params, string name)
{
if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop))
return null;
return prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False ? prop.GetBoolean() : null;
}
private static int? GetInt(JsonElement @params, string name)
{
if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop))
return null;
return prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var i) ? i : null;
}
private static BridgeResponseEnvelope Ok(string? id, object result) =>
new(id, true, result, null);
private static BridgeResponseEnvelope Error(string? id, string code, string message, object? details = null) =>
new(id, false, null, new BridgeErrorEnvelope(code, message, details));
private static LegacyMigrationApplyResult SaveConfigWithBackup(string projectRoot, DevToolConfig config)
{
try
{
var path = ConfigLoader.FindConfigPath(projectRoot);
if (string.IsNullOrWhiteSpace(path))
return new LegacyMigrationApplyResult(false, "Could not find project config for saving.");
var backup = path + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}";
File.Copy(path, backup, overwrite: false);
File.WriteAllText(path, ConfigBootstrapper.ToJson(config));
return new LegacyMigrationApplyResult(true, "Saved updated project config.", BackupPath: backup, ConfigPath: path);
}
catch (Exception ex)
{
return new LegacyMigrationApplyResult(false, ex.Message);
}
}
}
public sealed class BridgeValidationException(string message) : Exception(message);