- 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
706 lines
29 KiB
C#
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);
|