journal/Journal.DevTool/Config/ConfigBootstrapper.cs

627 lines
22 KiB
C#

using System.Text.Json;
namespace Sdt.Config;
public sealed record BootstrapScanResult(
string ProjectRoot,
string ProjectName,
IReadOnlyList<string> ToolFamilies,
string? NodeWorkingDir,
string? PythonRequirementsFile,
bool HasDockerCompose,
IReadOnlyList<string> RootHints);
public static class ConfigBootstrapper
{
private const int MaxScanDepth = 4;
private static readonly HashSet<string> 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<string>(StringComparer.OrdinalIgnoreCase);
var rootHints = new HashSet<string>(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<WorkflowDefinition> BuildWorkflows(BootstrapScanResult scan)
{
var has = new Func<string, bool>(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<WorkflowStep>();
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<WorkflowStep>();
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<WorkflowStep>();
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<DebugProfileDefinition> BuildDebugProfiles(BootstrapScanResult scan)
{
var has = new Func<string, bool>(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<string>? actionArgs = null)
{
return new WorkflowStep
{
Id = id,
Label = label,
Action = action,
ActionArgs = actionArgs?.ToList() ?? [],
WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? "." : workingDir
};
}
private static HashSet<string> DetectScriptHelpers(string projectRoot)
{
var scriptsDir = Path.Combine(projectRoot, "scripts");
if (!Directory.Exists(scriptsDir))
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
return Directory.EnumerateFiles(scriptsDir, "*.py", SearchOption.TopDirectoryOnly)
.Select(Path.GetFileName)
.Where(f => !string.IsNullOrWhiteSpace(f))
.Cast<string>()
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<WorkflowDefinition> BuildScriptDrivenWorkflows(HashSet<string> 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<string> 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<string> 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<string> 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));
}
}
}
}