using System.Text.Json; namespace Sdt.Config; public sealed record BootstrapScanResult( string ProjectRoot, string ProjectName, IReadOnlyList ToolFamilies, string? NodeWorkingDir, string? PythonRequirementsFile, bool HasDockerCompose, IReadOnlyList RootHints); public static class ConfigBootstrapper { private const int MaxScanDepth = 4; private static readonly HashSet ExcludedDirectories = new(StringComparer.OrdinalIgnoreCase) { ".git", "node_modules", ".venv", "venv", "bin", "obj", ".idea", ".vscode", "dist", "build", ".sdt", }; private static readonly string[] RequirementCandidates = [ "requirements.txt", "requirements-dev.txt", "requirements_cpu_only.txt", "requirements_gpu.txt", ]; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; public static BootstrapScanResult Scan(string startDir) { var root = FindProjectRoot(startDir); var toolFamilies = new HashSet(StringComparer.OrdinalIgnoreCase); var rootHints = new HashSet(StringComparer.OrdinalIgnoreCase); if (Directory.Exists(Path.Combine(root, ".git"))) { toolFamilies.Add("git"); rootHints.Add(".git"); } var hasTopLevelSln = Directory.EnumerateFiles(root, "*.sln", SearchOption.TopDirectoryOnly).Any(); var hasCsproj = hasTopLevelSln || EnumerateFilesBounded(root, "*.csproj", MaxScanDepth).Any(); if (hasCsproj) { toolFamilies.Add("dotnet"); rootHints.Add("*.sln"); } var topLevelPackageJson = Path.Combine(root, "package.json"); var packageJson = File.Exists(topLevelPackageJson) ? topLevelPackageJson : EnumerateFilesBounded(root, "package.json", MaxScanDepth) .OrderBy(p => p.Length) .FirstOrDefault(); string? nodeWorkingDir = null; if (packageJson is not null) { toolFamilies.Add("node"); toolFamilies.Add("npm"); nodeWorkingDir = Path.GetRelativePath(root, Path.GetDirectoryName(packageJson)!); rootHints.Add("package.json"); } string? requirements = RequirementCandidates .Select(name => Path.Combine(root, name)) .FirstOrDefault(File.Exists); if (requirements is null) { var pyproject = Path.Combine(root, "pyproject.toml"); if (File.Exists(pyproject)) { toolFamilies.Add("python"); rootHints.Add("pyproject.toml"); } } else { toolFamilies.Add("python"); rootHints.Add(Path.GetFileName(requirements)!); } var hasCargo = File.Exists(Path.Combine(root, "Cargo.toml")) || EnumerateFilesBounded(root, "Cargo.toml", MaxScanDepth).Any(); if (hasCargo) { toolFamilies.Add("cargo"); rootHints.Add("Cargo.toml"); } var hasTauri = File.Exists(Path.Combine(root, "tauri.conf.json")) || EnumerateFilesBounded(root, "tauri.conf.json", MaxScanDepth).Any(); if (hasTauri) { toolFamilies.Add("tauri"); toolFamilies.Add("cargo"); toolFamilies.Add("node"); toolFamilies.Add("npm"); rootHints.Add("tauri.conf.json"); } var hasDockerCompose = File.Exists(Path.Combine(root, "docker-compose.yml")) || File.Exists(Path.Combine(root, "docker-compose.yaml")); if (hasDockerCompose || File.Exists(Path.Combine(root, "Dockerfile"))) { toolFamilies.Add("docker"); rootHints.Add(hasDockerCompose ? "docker-compose.yml" : "Dockerfile"); } var scriptsDir = Path.Combine(root, "scripts"); if (Directory.Exists(scriptsDir) && Directory.EnumerateFiles(scriptsDir, "*.py", SearchOption.TopDirectoryOnly).Any()) { toolFamilies.Add("python"); rootHints.Add("scripts"); } if (rootHints.Count == 0) rootHints.Add("devtool.json"); return new BootstrapScanResult( ProjectRoot: root, ProjectName: new DirectoryInfo(root).Name, ToolFamilies: toolFamilies.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(), NodeWorkingDir: nodeWorkingDir, PythonRequirementsFile: requirements is null ? null : Path.GetRelativePath(root, requirements), HasDockerCompose: hasDockerCompose, RootHints: rootHints.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList()); } public static DevToolConfig BuildDefaultConfig(BootstrapScanResult scan) { var workflows = BuildWorkflows(scan).ToList(); var toolingTools = scan.ToolFamilies .Select(t => new ToolInstallDefinition { Tool = t, PreferredInstallCommands = [] }) .ToList(); var toolchains = new ToolchainConfig { Python = scan.ToolFamilies.Contains("python", StringComparer.OrdinalIgnoreCase) ? new PythonToolchain { Executable = "python", WindowsExecutable = "py", VenvDir = ".venv", Profiles = [] } : null, Node = scan.ToolFamilies.Contains("node", StringComparer.OrdinalIgnoreCase) ? new NodeToolchain { PackageManager = "npm", WorkingDir = string.IsNullOrWhiteSpace(scan.NodeWorkingDir) ? "." : scan.NodeWorkingDir } : null }; var debugProfiles = BuildDebugProfiles(scan).ToList(); return new DevToolConfig { Name = scan.ProjectName, Version = "0.1.0", Project = new ProjectMetadata { Type = "generic", RootHints = scan.RootHints.ToList(), Artifacts = ["bin", "obj", ".sdt/debug"] }, Toolchains = toolchains, Tooling = new ToolingConfig { Tools = toolingTools }, Workflows = workflows, Debug = new DebugConfig { Profiles = debugProfiles, Diagnostics = new DebugDiagnosticsOptions { Enabled = true, OutputDir = ".sdt/debug", IncludeAllEnv = false, CaptureEnvKeys = [ "SDT_LOG_LEVEL", "DOTNET_CLI_HOME", "NUGET_PACKAGES", "PIP_CACHE_DIR", "NVM_HOME", "NVM_SYMLINK", ], BundleOnFailure = true } }, Env = [ new EnvVarDef { Key = "SDT_LOG_LEVEL", Description = "CLI log verbosity", DefaultValue = "information", Options = ["trace", "debug", "information", "warning", "error", "critical"] } ] }; } public static string ToJson(DevToolConfig config) => JsonSerializer.Serialize(config, JsonOptions) + Environment.NewLine; public static string WriteDefaultConfig(string projectRoot, DevToolConfig config, bool overwrite = false) { var path = Path.Combine(projectRoot, "devtool.json"); if (File.Exists(path) && !overwrite) throw new InvalidOperationException($"devtool.json already exists at {path}"); File.WriteAllText(path, ToJson(config)); return path; } private static IEnumerable BuildWorkflows(BootstrapScanResult scan) { var has = new Func(tool => scan.ToolFamilies.Contains(tool, StringComparer.OrdinalIgnoreCase)); var scripts = DetectScriptHelpers(scan.ProjectRoot); if (scripts.Contains("publish-sidecar.py") || scripts.Contains("publish-app.py") || scripts.Contains("publish-webgateway.py") || scripts.Contains("publish-output.py") || scripts.Contains("sync-output.py") || scripts.Contains("run-webgateway.py")) { foreach (var workflow in BuildScriptDrivenWorkflows(scripts)) yield return workflow; } var buildSteps = new List(); if (has("dotnet")) buildSteps.Add(StepAction("dotnet-build", "dotnet build", "dotnet-build")); if (has("npm")) buildSteps.Add(StepAction("npm-build", "npm run build", "npm-build", scan.NodeWorkingDir)); if (has("cargo")) buildSteps.Add(StepAction("cargo-build", "cargo build", "cargo-build")); if (has("tauri")) buildSteps.Add(StepAction("tauri-build", "tauri build", "tauri-build", scan.NodeWorkingDir ?? ".")); if (buildSteps.Count > 0) { yield return new WorkflowDefinition { Id = "build", Label = "Build", Description = "Build detected project stacks", Group = "Build", Steps = buildSteps }; } var depsSteps = new List(); if (has("dotnet")) depsSteps.Add(StepAction("dotnet-restore", "dotnet restore", "dotnet-restore")); if (has("npm")) depsSteps.Add(StepAction("npm-ci", "npm ci", "npm-ci", scan.NodeWorkingDir)); if (has("python") && !string.IsNullOrWhiteSpace(scan.PythonRequirementsFile)) { depsSteps.Add(StepAction("python-pip-sync", "python pip sync", "python-pip-sync", ".", ["--requirements", scan.PythonRequirementsFile!])); } if (depsSteps.Count > 0) { yield return new WorkflowDefinition { Id = "deps-refresh", Label = "Refresh Dependencies", Description = "Restore/install dependency stacks", Group = "Deps", Steps = depsSteps }; } var testSteps = new List(); if (has("dotnet")) testSteps.Add(StepAction("dotnet-test", "dotnet test", "dotnet-test")); if (has("npm")) testSteps.Add(StepAction("npm-test", "npm test", "npm-test", scan.NodeWorkingDir)); if (has("python")) testSteps.Add(StepAction("python-pytest", "python -m pytest", "python-pytest")); if (has("cargo")) testSteps.Add(StepAction("cargo-test", "cargo test", "cargo-test")); if (testSteps.Count > 0) { yield return new WorkflowDefinition { Id = "test", Label = "Run Tests", Description = "Run detected test stacks", Group = "Test", Steps = testSteps }; } if (has("git")) { yield return new WorkflowDefinition { Id = "repo-health", Label = "Repo Health", Description = "Check repo status and fetch remotes", Group = "Repo", Steps = [ StepAction("git-status", "git status", "git-status"), StepAction("git-fetch", "git fetch", "git-fetch") ] }; } if (has("docker")) { yield return new WorkflowDefinition { Id = "containers", Label = "Containers", Description = scan.HasDockerCompose ? "Manage docker compose stack" : "Build docker image", Group = "Containers", Steps = scan.HasDockerCompose ? [StepAction("docker-compose-up", "docker compose up -d", "docker-compose-up")] : [StepAction("docker-build", "docker build .", "docker-build")] }; } } private static IEnumerable BuildDebugProfiles(BootstrapScanResult scan) { var has = new Func(tool => scan.ToolFamilies.Contains(tool, StringComparer.OrdinalIgnoreCase)); if (has("dotnet")) { yield return new DebugProfileDefinition { Id = "dotnet-run", Label = "Run .NET app", Type = "dotnet", Command = "dotnet", Args = ["run"], WorkingDir = ".", Requires = [new ToolRequirement { Tool = "dotnet", InstallPolicy = InstallPolicy.Prompt }], Attach = new DebugAttachConfig { Kind = "manual", Note = "Attach your IDE debugger to the running dotnet process." } }; } if (has("npm")) { yield return new DebugProfileDefinition { Id = "npm-dev", Label = "Run npm dev server", Type = "node", Command = "npm", Args = ["run", "dev"], WorkingDir = string.IsNullOrWhiteSpace(scan.NodeWorkingDir) ? "." : scan.NodeWorkingDir!, Requires = [ new ToolRequirement { Tool = "node", InstallPolicy = InstallPolicy.Prompt }, new ToolRequirement { Tool = "npm", InstallPolicy = InstallPolicy.Prompt } ] }; } } private static WorkflowStep StepAction( string id, string label, string action, string? workingDir = null, IReadOnlyList? actionArgs = null) { return new WorkflowStep { Id = id, Label = label, Action = action, ActionArgs = actionArgs?.ToList() ?? [], WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? "." : workingDir }; } private static HashSet DetectScriptHelpers(string projectRoot) { var scriptsDir = Path.Combine(projectRoot, "scripts"); if (!Directory.Exists(scriptsDir)) return new HashSet(StringComparer.OrdinalIgnoreCase); return Directory.EnumerateFiles(scriptsDir, "*.py", SearchOption.TopDirectoryOnly) .Select(Path.GetFileName) .Where(f => !string.IsNullOrWhiteSpace(f)) .Cast() .ToHashSet(StringComparer.OrdinalIgnoreCase); } private static IEnumerable BuildScriptDrivenWorkflows(HashSet scripts) { static WorkflowStep ScriptStep(string id, string label, params string[] scriptArgs) => new() { Id = id, Label = label, Command = "python", Args = scriptArgs.ToList(), WorkingDir = ".", Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }] }; if (scripts.Contains("publish-sidecar.py")) { yield return new WorkflowDefinition { Id = "sidecar", Label = "Publish Sidecar", Description = "Publish sidecar service", Group = "Build", Steps = [ScriptStep("sidecar:run", "python scripts/publish-sidecar.py", "scripts/publish-sidecar.py")] }; } if (scripts.Contains("publish-app.py")) { yield return new WorkflowDefinition { Id = "web", Label = "Build Web UI", Description = "Build frontend assets", Group = "Build", Steps = [ ScriptStep("web:run", "python scripts/publish-app.py --target web", "scripts/publish-app.py", "--target", "web") ] }; yield return new WorkflowDefinition { Id = "tauri", Label = "Build Tauri Desktop App", Description = "Build desktop binary", Group = "Build", DependsOn = scripts.Contains("publish-sidecar.py") ? ["sidecar"] : [], Steps = [ ScriptStep("tauri:run", "python scripts/publish-app.py --target tauri --tauri-bundles none", "scripts/publish-app.py", "--target", "tauri", "--tauri-bundles", "none") ] }; } if (scripts.Contains("publish-webgateway.py")) { yield return new WorkflowDefinition { Id = "webgateway", Label = "Publish WebGateway", Description = "Publish ASP.NET gateway", Group = "Build", DependsOn = scripts.Contains("publish-app.py") ? ["web"] : [], Steps = [ScriptStep("webgateway:run", "python scripts/publish-webgateway.py", "scripts/publish-webgateway.py")] }; } if (scripts.Contains("sync-output.py")) { yield return new WorkflowDefinition { Id = "sync-output", Label = "Sync Output", Description = "Sync newest artifacts to output", Group = "Build", Steps = [ScriptStep("sync-output:run", "python scripts/sync-output.py", "scripts/sync-output.py")] }; } if (scripts.Contains("publish-output.py")) { yield return new WorkflowDefinition { Id = "stage-output", Label = "Stage Output Bundle", Description = "Publish and stage distributable output", Group = "Build", Steps = [ScriptStep("stage-output:run", "python scripts/publish-output.py", "scripts/publish-output.py")] }; } if (scripts.Contains("run-webgateway.py")) { yield return new WorkflowDefinition { Id = "run-gateway-dev", Label = "Run WebGateway Server (Dev)", Description = "Run gateway in development mode", Group = "Dev", Steps = [ ScriptStep("run-gateway-dev:run", "python scripts/run-webgateway.py --mode Dev", "scripts/run-webgateway.py", "--mode", "Dev") ] }; } } private static string FindProjectRoot(string startDir) { var start = Path.GetFullPath(startDir); var gitRoot = TryGetGitRoot(start); if (!string.IsNullOrWhiteSpace(gitRoot)) return gitRoot!; var best = start; var bestScore = ScoreRoot(start); var cursor = new DirectoryInfo(start); while (cursor.Parent is not null) { cursor = cursor.Parent; var score = ScoreRoot(cursor.FullName); if (score > bestScore) { best = cursor.FullName; bestScore = score; } } return best; } private static int ScoreRoot(string path) { var score = 0; if (File.Exists(Path.Combine(path, "package.json"))) score += 2; if (File.Exists(Path.Combine(path, "pyproject.toml"))) score += 2; if (File.Exists(Path.Combine(path, "Cargo.toml"))) score += 2; if (File.Exists(Path.Combine(path, "docker-compose.yml")) || File.Exists(Path.Combine(path, "docker-compose.yaml")) || File.Exists(Path.Combine(path, "Dockerfile"))) score += 1; if (Directory.EnumerateFiles(path, "*.sln", SearchOption.TopDirectoryOnly).Any()) score += 3; if (Directory.Exists(Path.Combine(path, ".git"))) score += 1; return score; } private static string? TryGetGitRoot(string start) { try { var psi = new System.Diagnostics.ProcessStartInfo { FileName = "git", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, WorkingDirectory = start }; psi.ArgumentList.Add("rev-parse"); psi.ArgumentList.Add("--show-toplevel"); using var process = System.Diagnostics.Process.Start(psi); if (process is null) return null; var stdout = process.StandardOutput.ReadToEnd(); process.WaitForExit(2000); if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(stdout)) return stdout.Trim(); return null; } catch { return null; } } private static IEnumerable EnumerateFilesBounded(string root, string pattern, int maxDepth) { var queue = new Queue<(string Dir, int Depth)>(); queue.Enqueue((root, 0)); while (queue.Count > 0) { var (dir, depth) = queue.Dequeue(); IEnumerable files = []; try { files = Directory.EnumerateFiles(dir, pattern, SearchOption.TopDirectoryOnly); } catch { // Ignore unreadable directories. } foreach (var file in files) yield return file; if (depth >= maxDepth) continue; IEnumerable subdirs = []; try { subdirs = Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly); } catch { // Ignore unreadable directories. } foreach (var subdir in subdirs) { var name = Path.GetFileName(subdir); if (ExcludedDirectories.Contains(name)) continue; queue.Enqueue((subdir, depth + 1)); } } } }