This commit is contained in:
Jacob Schmidt 2026-03-01 16:15:16 -06:00
commit 53204ec59e
122 changed files with 11345 additions and 1602 deletions

14
.gitignore vendored
View File

@ -50,4 +50,16 @@ logs/
journalapp.exe
Journal.App/node_modules.old/@rollup/.rollup-win32-x64-msvc-IjiZshxL/rollup.win32-x64-msvc.node
journalapp(1).exe
.cache/
.cache/
Journal.DevTool/node_modules/
scripts/__pycache__/
.sdt/
devtool.backup.json
sdt.deps.json
sdt.dll
sdt.exe
sdt.pdb
sdt.runtimeconfig.json
Spectre.Console.dll
Journal.DevTool/devtool.generated.workflows.json
Journal.DevTool/sdt-workspace.json

View File

@ -11,7 +11,8 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2"
"@tauri-apps/plugin-opener": "^2",
"tauri-plugin-mic-recorder-api": "^2.0.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
@ -908,6 +909,7 @@
"integrity": "sha512-NXsZLvalgI3HrHG6ogoEVzjyV7bSFQNqQeekfU7nNufQFrRyV3EBDfQKEwxx50peu7spZR42JuC1PFhwxuvBrg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@ -950,6 +952,7 @@
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
@ -1256,6 +1259,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1542,6 +1546,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -1584,6 +1589,7 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -1715,6 +1721,7 @@
"integrity": "sha512-pRUBr6j6uQDgBi208gHnGRMykw0Rf2Yr1HmLyRucsvcaYgIUxswJkT93WZJflsmezu5s8Lq+q78EoyLv2yaFCg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@ -1761,6 +1768,15 @@
"typescript": ">=5.0.0"
}
},
"node_modules/tauri-plugin-mic-recorder-api": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tauri-plugin-mic-recorder-api/-/tauri-plugin-mic-recorder-api-2.0.0.tgz",
"integrity": "sha512-04wqYCX4WIlYd6KUY7aS3+W4B5RtnSoVczaQCBSXKpQkEx9XdaaBN05XCee2unxGva0btSXBItFqQSdosnS4jQ==",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -1794,6 +1810,7 @@
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -1808,6 +1825,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

15
Journal.DevTool/.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
bin/
obj/
__pycache__/
.cache/
.vscode/
.idea/
.vs/
.git/
.pip
.tmp
.venv
.dotnet_home
.nuget
publish-test/

View File

@ -0,0 +1,626 @@
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));
}
}
}
}

View File

@ -1,9 +1,24 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Sdt.Core;
namespace Sdt.Config;
public sealed record LoadedProjectConfig(
DevToolConfig Config,
string ProjectRoot,
IReadOnlyList<string> Warnings);
public sealed record LegacyMigrationApplyResult(
bool Success,
string Message,
string? BackupPath = null,
string? ConfigPath = null);
public static class ConfigLoader
{
public const string WorkspaceDefaultsFileName = "sdt-defaults.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@ -16,29 +31,193 @@ public static class ConfigLoader
/// Walks up from <paramref name="startDir"/> (or CWD) until it finds devtool.json.
/// Returns null if not found.
/// </summary>
public static (DevToolConfig Config, string ProjectRoot)? FindAndLoad(string? startDir = null)
public static string? FindConfigPath(string? startDir = null)
{
var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory());
while (dir is not null)
{
var candidate = Path.Combine(dir.FullName, "devtool.json");
if (File.Exists(candidate))
{
try
{
var json = File.ReadAllText(candidate);
var config = JsonSerializer.Deserialize<DevToolConfig>(json, JsonOptions)
?? throw new InvalidOperationException("devtool.json deserialized to null.");
return (config, dir.FullName);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Failed to parse devtool.json at {candidate}: {ex.Message}", ex);
}
}
return candidate;
dir = dir.Parent!;
}
return null;
}
public static LoadedProjectConfig? FindAndLoad(string? startDir = null)
{
var configPath = FindConfigPath(startDir);
if (configPath is null)
return null;
var projectRoot = Path.GetDirectoryName(configPath)
?? throw new InvalidOperationException($"Could not resolve project root from {configPath}");
try
{
var effectiveConfig = LoadEffectiveConfig(projectRoot, configPath, out var defaultsPath);
var warnings = new List<string>();
if (!string.IsNullOrWhiteSpace(defaultsPath))
warnings.Add($"Applied workspace defaults from {defaultsPath}.");
var legacyMode = ResolveLegacyMode();
if (legacyMode == LegacyMode.Strict && effectiveConfig.Workflows.Count == 0 && effectiveConfig.Targets.Count > 0)
{
var previewPath = Path.Combine(projectRoot, "devtool.generated.workflows.json");
try
{
var previewConfig = WorkflowModelBuilder.BuildMigrationPreviewConfig(effectiveConfig, new RequirementResolver());
File.WriteAllText(previewPath, ConfigBootstrapper.ToJson(previewConfig));
}
catch
{
// Keep strict failure even if preview generation fails.
}
throw new InvalidOperationException(
$"Legacy targets-only config detected at {configPath}. Strict mode requires workflows. " +
"Use migration preview file 'devtool.generated.workflows.json' and migrate devtool.json. " +
"Temporary rollback: set SDT_LEGACY_MODE=compat.");
}
var normalized = WorkflowModelBuilder.Normalize(effectiveConfig, legacyMode, new RequirementResolver());
warnings.AddRange(normalized.Warnings);
return new LoadedProjectConfig(effectiveConfig, projectRoot, warnings);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Failed to parse devtool.json at {configPath}: {ex.Message}", ex);
}
}
public static LegacyMigrationApplyResult ApplyLegacyTargetMigration(
string configPath,
bool createBackup = true)
{
try
{
if (!File.Exists(configPath))
return new LegacyMigrationApplyResult(false, $"Config file not found: {configPath}");
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<DevToolConfig>(json, JsonOptions)
?? throw new InvalidOperationException("devtool.json deserialized to null.");
if (config.Targets.Count == 0)
return new LegacyMigrationApplyResult(false, "No legacy targets found to migrate.", ConfigPath: configPath);
var migrated = WorkflowModelBuilder.BuildMigrationPreviewConfig(config, new RequirementResolver());
var backupPath = (string?)null;
if (createBackup)
{
backupPath = configPath + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}";
File.Copy(configPath, backupPath, overwrite: false);
}
File.WriteAllText(configPath, ConfigBootstrapper.ToJson(migrated));
return new LegacyMigrationApplyResult(
true,
"Legacy targets migrated to workflows.",
BackupPath: backupPath,
ConfigPath: configPath);
}
catch (Exception ex)
{
return new LegacyMigrationApplyResult(false, ex.Message, ConfigPath: configPath);
}
}
private static LegacyMode ResolveLegacyMode()
{
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
return string.Equals(raw, "compat", StringComparison.OrdinalIgnoreCase)
? LegacyMode.Compat
: LegacyMode.Strict;
}
private static DevToolConfig LoadEffectiveConfig(
string projectRoot,
string projectConfigPath,
out string? defaultsPath)
{
defaultsPath = FindWorkspaceDefaultsPath(projectRoot);
var projectObj = LoadJsonObject(projectConfigPath, "project config");
if (string.IsNullOrWhiteSpace(defaultsPath))
return DeserializeConfig(projectObj, projectConfigPath);
var defaultsObj = LoadJsonObject(defaultsPath!, "workspace defaults");
var merged = MergeObjects(defaultsObj, projectObj);
return DeserializeConfig(merged, projectConfigPath);
}
private static string? FindWorkspaceDefaultsPath(string startDir)
{
var workspaceBoundary = FindWorkspaceBoundary(startDir);
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
var candidate = Path.Combine(dir.FullName, WorkspaceDefaultsFileName);
if (File.Exists(candidate))
return candidate;
if (workspaceBoundary is not null &&
string.Equals(dir.FullName, workspaceBoundary, StringComparison.OrdinalIgnoreCase))
{
break;
}
if (workspaceBoundary is null)
break;
dir = dir.Parent;
}
return null;
}
private static string? FindWorkspaceBoundary(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
var workspacePath = Path.Combine(dir.FullName, WorkspaceLoader.FileName);
if (File.Exists(workspacePath))
return dir.FullName;
dir = dir.Parent;
}
return null;
}
private static JsonObject LoadJsonObject(string path, string label)
{
var json = File.ReadAllText(path);
var node = JsonNode.Parse(json)
?? throw new InvalidOperationException($"{label} at {path} deserialized to null.");
if (node is not JsonObject obj)
throw new InvalidOperationException($"{label} at {path} must be a JSON object.");
return obj;
}
private static DevToolConfig DeserializeConfig(JsonObject obj, string sourcePath)
{
return obj.Deserialize<DevToolConfig>(JsonOptions)
?? throw new InvalidOperationException($"devtool.json at {sourcePath} deserialized to null.");
}
private static JsonObject MergeObjects(JsonObject baseObj, JsonObject overlayObj)
{
var result = (JsonObject)baseObj.DeepClone();
foreach (var kv in overlayObj)
{
if (kv.Value is JsonObject overlayChild &&
result[kv.Key] is JsonObject baseChild)
{
result[kv.Key] = MergeObjects(baseChild, overlayChild);
continue;
}
result[kv.Key] = kv.Value?.DeepClone();
}
return result;
}
}

View File

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace Sdt.Config;
public sealed class DevToolConfig
@ -5,8 +7,12 @@ public sealed class DevToolConfig
public string Name { get; init; } = "SDT Project";
public string Version { get; init; } = "0.1.0";
public List<BuildTarget> Targets { get; init; } = [];
public List<WorkflowDefinition> Workflows { get; init; } = [];
public List<EnvVarDef> Env { get; init; } = [];
public ToolchainConfig? Toolchains { get; init; }
public ToolingConfig? Tooling { get; init; }
public ProjectMetadata? Project { get; init; }
public DebugConfig? Debug { get; init; }
}
public sealed class BuildTarget
@ -39,6 +45,99 @@ public sealed class EnvVarDef
public List<string> Options { get; init; } = [];
}
public sealed class WorkflowDefinition
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public string Description { get; init; } = "";
public string Group { get; init; } = "General";
public List<string> DependsOn { get; init; } = [];
public List<WorkflowStep> Steps { get; init; } = [];
}
public sealed class WorkflowStep
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public string? Command { get; init; }
public List<string> Args { get; init; } = [];
public string WorkingDir { get; init; } = ".";
public string? Action { get; init; }
public List<string> ActionArgs { get; init; } = [];
public List<ToolRequirement> Requires { get; init; } = [];
}
public sealed class ToolRequirement
{
public string Tool { get; init; } = "";
[JsonConverter(typeof(JsonStringEnumConverter))]
public InstallPolicy InstallPolicy { get; init; } = InstallPolicy.Prompt;
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum InstallPolicy
{
Prompt,
Auto,
Never,
}
public sealed class ToolingConfig
{
public List<ToolInstallDefinition> Tools { get; init; } = [];
}
public sealed class ToolInstallDefinition
{
public string Tool { get; init; } = "";
public List<string> PreferredInstallCommands { get; init; } = [];
public List<string> Executables { get; init; } = [];
}
public sealed class ProjectMetadata
{
public string Type { get; init; } = "";
public List<string> RootHints { get; init; } = [];
public List<string> Artifacts { get; init; } = [];
}
public sealed class DebugConfig
{
public List<DebugProfileDefinition> Profiles { get; init; } = [];
public DebugDiagnosticsOptions Diagnostics { get; init; } = new();
}
public sealed class DebugProfileDefinition
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public string Type { get; init; } = "generic";
public string Command { get; init; } = "";
public List<string> Args { get; init; } = [];
public string WorkingDir { get; init; } = ".";
public Dictionary<string, string> Env { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public List<ToolRequirement> Requires { get; init; } = [];
public DebugAttachConfig? Attach { get; init; }
}
public sealed class DebugAttachConfig
{
public string Kind { get; init; } = "";
public int? Port { get; init; }
public string? ProcessName { get; init; }
public string? Note { get; init; }
}
public sealed class DebugDiagnosticsOptions
{
public bool Enabled { get; init; } = true;
public string OutputDir { get; init; } = ".sdt/debug";
public bool IncludeAllEnv { get; init; } = false;
public List<string> CaptureEnvKeys { get; init; } = [];
public bool BundleOnFailure { get; init; } = true;
}
// ── Toolchain config ──────────────────────────────────────────────────────────
public sealed class ToolchainConfig

View File

@ -0,0 +1,100 @@
using Sdt.Core;
namespace Sdt.Config;
public enum LegacyMode
{
Strict,
Compat,
}
public sealed record WorkflowNormalizationResult(
IReadOnlyList<WorkflowDefinition> Workflows,
IReadOnlyList<string> Warnings);
public static class WorkflowModelBuilder
{
public static WorkflowNormalizationResult Normalize(
DevToolConfig config,
LegacyMode legacyMode = LegacyMode.Strict,
IRequirementResolver? requirementResolver = null)
{
requirementResolver ??= new RequirementResolver();
var warnings = new List<string>();
if (config.Workflows.Count > 0)
{
if (config.Targets.Count > 0)
{
warnings.Add("Both 'workflows' and legacy 'targets' are present. SDT will use 'workflows'.");
}
return new WorkflowNormalizationResult(config.Workflows, warnings);
}
if (config.Targets.Count == 0)
{
warnings.Add("No 'workflows' or legacy 'targets' were found.");
return new WorkflowNormalizationResult([], warnings);
}
if (legacyMode == LegacyMode.Strict)
{
throw new InvalidOperationException(
"Legacy 'targets' are not allowed in strict mode. Migrate to 'workflows' or set SDT_LEGACY_MODE=compat temporarily.");
}
warnings.Add("Using legacy 'targets' schema. Migrate to 'workflows' for v1+ features.");
return new WorkflowNormalizationResult(ConvertLegacyTargets(config.Targets, requirementResolver), warnings);
}
public static DevToolConfig BuildMigrationPreviewConfig(DevToolConfig config, IRequirementResolver? requirementResolver = null)
{
requirementResolver ??= new RequirementResolver();
return new DevToolConfig
{
Name = config.Name,
Version = config.Version,
Targets = [],
Workflows = ConvertLegacyTargets(config.Targets, requirementResolver),
Env = config.Env,
Toolchains = config.Toolchains,
Tooling = config.Tooling,
Project = config.Project,
Debug = config.Debug,
};
}
private static List<WorkflowDefinition> ConvertLegacyTargets(
IReadOnlyList<BuildTarget> targets,
IRequirementResolver requirementResolver)
{
var workflows = new List<WorkflowDefinition>(targets.Count);
foreach (var target in targets)
{
var step = target.Command is null
? null
: new WorkflowStep
{
Id = $"{target.Id}:run",
Label = string.IsNullOrWhiteSpace(target.Label) ? target.Id : target.Label,
Command = target.Command,
Args = target.Args,
WorkingDir = target.WorkingDir,
Requires = requirementResolver.Resolve(target),
};
workflows.Add(new WorkflowDefinition
{
Id = target.Id,
Label = target.Label,
Description = target.Description,
Group = target.Group,
DependsOn = target.DependsOn,
Steps = step is null ? [] : [step],
});
}
return workflows;
}
}

View File

@ -12,8 +12,11 @@ public sealed class WorkspaceProject
public string Description { get; init; } = "";
/// <summary>
/// Relative path from the sdt-workspace.json directory to the project root
/// Relative or absolute path to the project root
/// (the directory containing devtool.json).
/// </summary>
public string Path { get; init; } = "";
public List<string> Tags { get; init; } = [];
public List<string> ToolFamilies { get; init; } = [];
public bool Disabled { get; init; } = false;
}

View File

@ -4,7 +4,7 @@ namespace Sdt.Config;
public static class WorkspaceLoader
{
private const string FileName = "sdt-workspace.json";
public const string FileName = "sdt-workspace.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
@ -41,12 +41,130 @@ public static class WorkspaceLoader
}
dir = dir.Parent!;
}
return null;
// No workspace file found; synthesize one by scanning nearby project roots.
return TryAutoDiscover(startDir ?? Directory.GetCurrentDirectory());
}
/// <summary>
/// Resolves the absolute project root for a workspace project entry.
/// </summary>
public static string ResolveProjectRoot(string workspaceRoot, WorkspaceProject project)
=> Path.GetFullPath(Path.Combine(workspaceRoot, project.Path));
=> Path.GetFullPath(Path.IsPathRooted(project.Path)
? project.Path
: Path.Combine(workspaceRoot, project.Path));
public static string GetWorkspaceFilePath(string workspaceRoot)
=> Path.Combine(workspaceRoot, FileName);
public static void Save(string workspaceRoot, WorkspaceConfig workspace)
{
var path = GetWorkspaceFilePath(workspaceRoot);
var saveOptions = new JsonSerializerOptions(JsonOptions)
{
WriteIndented = true
};
var json = JsonSerializer.Serialize(workspace, saveOptions);
File.WriteAllText(path, json + Environment.NewLine);
}
private static (WorkspaceConfig Config, string WorkspaceRoot)? TryAutoDiscover(string startDir)
{
LoadedProjectConfig? loaded;
try
{
loaded = ConfigLoader.FindAndLoad(startDir);
}
catch
{
return null;
}
if (loaded is null)
return null;
var currentRoot = loaded.ProjectRoot;
var parent = Directory.GetParent(currentRoot);
var workspaceRoot = parent?.FullName ?? currentRoot;
var roots = DiscoverProjectRoots(workspaceRoot, currentRoot);
if (roots.Count == 0)
return null;
var projects = new List<WorkspaceProject>();
foreach (var root in roots)
{
LoadedProjectConfig? cfg;
try
{
cfg = ConfigLoader.FindAndLoad(root);
}
catch
{
continue;
}
var name = cfg?.Config.Name;
projects.Add(new WorkspaceProject
{
Name = string.IsNullOrWhiteSpace(name) ? new DirectoryInfo(root).Name : name!,
Description = $"Auto-discovered at {root}",
Path = Path.GetRelativePath(workspaceRoot, root),
});
}
return (
new WorkspaceConfig
{
Name = "SDT Auto Workspace",
Projects = projects
},
workspaceRoot);
}
private static List<string> DiscoverProjectRoots(string workspaceRoot, string currentRoot)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var excluded = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"bin",
"obj",
".git",
".venv",
"node_modules",
};
void AddIfProject(string path)
{
var full = Path.GetFullPath(path);
if (File.Exists(Path.Combine(full, "devtool.json")))
set.Add(full);
}
AddIfProject(currentRoot);
AddIfProject(workspaceRoot);
try
{
foreach (var dir in Directory.EnumerateDirectories(workspaceRoot))
{
if (excluded.Contains(Path.GetFileName(dir)))
continue;
AddIfProject(dir);
foreach (var sub in Directory.EnumerateDirectories(dir))
{
if (excluded.Contains(Path.GetFileName(sub)))
continue;
AddIfProject(sub);
}
}
}
catch
{
// Ignore inaccessible directories during auto-discovery.
}
return set.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList();
}
}

View File

@ -0,0 +1,207 @@
using Sdt.Config;
using Sdt.Runner;
namespace Sdt.Core;
public sealed class ActionRunner : IActionRunner
{
public async Task<RunResult> RunStepAsync(
WorkflowStep step,
string projectRoot,
Action<string, bool> onOutput,
CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(step.Action))
{
var scriptPath = ScriptLocator.FindHelperScript(projectRoot, "build.py");
if (scriptPath is null)
throw new InvalidOperationException("build.py not found in bundled scripts or project scripts directory.");
var actionArgs = new List<string>
{
scriptPath,
step.Action,
"--project-root",
projectRoot,
};
actionArgs.AddRange(step.ActionArgs);
return await ProcessRunner.RunAsync(
PythonResolver.ResolveExecutable(),
actionArgs,
projectRoot,
onOutput,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(step.Command))
return new RunResult(0, TimeSpan.Zero);
var workingDir = Path.GetFullPath(Path.Combine(projectRoot, step.WorkingDir));
var pwshReroute = await TryRunLegacyPwshScriptViaPythonAsync(
step,
projectRoot,
workingDir,
onOutput,
cancellationToken).ConfigureAwait(false);
if (pwshReroute is not null)
return pwshReroute;
return await ProcessRunner.RunAsync(
step.Command,
step.Args,
workingDir,
onOutput,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task<RunResult?> TryRunLegacyPwshScriptViaPythonAsync(
WorkflowStep step,
string projectRoot,
string workingDir,
Action<string, bool> onOutput,
CancellationToken cancellationToken)
{
if (!IsPowerShellCommand(step.Command))
return null;
var args = step.Args;
var fileIndex = FindArgIndex(args, "-File");
if (fileIndex < 0 || fileIndex + 1 >= args.Count)
return null;
var psScriptArg = args[fileIndex + 1];
if (!psScriptArg.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
return null;
var pyScriptPath = ResolvePythonScriptPath(projectRoot, workingDir, psScriptArg);
if (pyScriptPath is null)
return null;
var translated = TranslatePowerShellArgsToPython(args.Skip(fileIndex + 2));
var pythonArgs = new List<string> { pyScriptPath };
pythonArgs.AddRange(translated);
onOutput($"Legacy PowerShell target detected. Trying Python script first: {Path.GetFileName(pyScriptPath)}", false);
var pyRun = await ProcessRunner.RunAsync(
PythonResolver.ResolveExecutable(),
pythonArgs,
workingDir,
onOutput,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (pyRun.Success)
return pyRun;
var psScriptPath = ResolveScriptPath(workingDir, psScriptArg);
if (psScriptPath is null || !File.Exists(psScriptPath))
return pyRun;
onOutput(
$"Python script failed (exit {pyRun.ExitCode}). Falling back to legacy PowerShell script: {psScriptArg}",
true);
return null;
}
private static string? ResolvePythonScriptPath(string projectRoot, string workingDir, string psScriptArg)
{
var pyArg = Path.ChangeExtension(psScriptArg, ".py");
var candidate = ResolveScriptPath(workingDir, pyArg);
if (candidate is not null && File.Exists(candidate))
return candidate;
var fileName = Path.GetFileName(pyArg);
return ScriptLocator.FindHelperScript(projectRoot, fileName);
}
private static string? ResolveScriptPath(string workingDir, string scriptArg)
{
if (Path.IsPathRooted(scriptArg))
return scriptArg;
return Path.GetFullPath(Path.Combine(workingDir, scriptArg));
}
private static bool IsPowerShellCommand(string? command)
{
if (string.IsNullOrWhiteSpace(command))
return false;
var normalized = Path.GetFileNameWithoutExtension(command).ToLowerInvariant();
return normalized is "pwsh" or "powershell";
}
private static int FindArgIndex(IReadOnlyList<string> args, string name)
{
for (var i = 0; i < args.Count; i++)
{
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
return i;
}
return -1;
}
private static List<string> TranslatePowerShellArgsToPython(IEnumerable<string> inputArgs)
{
var result = new List<string>();
var list = inputArgs.ToList();
for (var i = 0; i < list.Count; i++)
{
var token = list[i];
if (!token.StartsWith("-", StringComparison.Ordinal) || token == "-")
{
result.Add(token);
continue;
}
var key = token.TrimStart('-');
if (key.Length == 0)
continue;
var mapped = MapPowerShellParameter(key);
var nextIsValue = (i + 1) < list.Count && !list[i + 1].StartsWith("-", StringComparison.Ordinal);
result.Add(mapped);
if (nextIsValue)
{
result.Add(list[i + 1]);
i++;
}
}
return result;
}
private static string MapPowerShellParameter(string key)
{
return key.ToLowerInvariant() switch
{
"tauribundles" => "--tauri-bundles",
"projectroot" => "--project-root",
"reporoot" => "--repo-root",
"outputzip" => "--output-zip",
"inputzip" => "--input-zip",
"workingdir" => "--working-dir",
"outputdir" => "--output-dir",
_ => "--" + ToKebabCase(key)
};
}
private static string ToKebabCase(string value)
{
if (string.IsNullOrWhiteSpace(value))
return value.ToLowerInvariant();
var chars = new List<char>(value.Length + 4);
for (var i = 0; i < value.Length; i++)
{
var c = value[i];
if (char.IsUpper(c) && i > 0 && value[i - 1] != '-')
chars.Add('-');
chars.Add(char.ToLowerInvariant(c));
}
return new string(chars.ToArray());
}
}

View File

@ -0,0 +1,173 @@
namespace Sdt.Core;
public enum CommandResolutionSource
{
Exact,
Path,
Shim,
NodeAdjacentShim,
ConfiguredOverride,
Fallback,
}
public sealed record CommandResolutionResult(
string Requested,
string Resolved,
CommandResolutionSource Source);
public static class CommandResolver
{
public static CommandResolutionResult ResolveWithTrace(string command, Config.DevToolConfig? config = null, string? tool = null)
{
if (string.IsNullOrWhiteSpace(command))
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
if (!OperatingSystem.IsWindows())
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
if (command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar))
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
var normalized = command.ToLowerInvariant();
if (Path.HasExtension(command))
{
var extensionResolved = ResolveFromPath(command);
return extensionResolved is null
? new CommandResolutionResult(command, command, CommandResolutionSource.Fallback)
: new CommandResolutionResult(command, extensionResolved, CommandResolutionSource.Path);
}
var overrideTool = string.IsNullOrWhiteSpace(tool) ? normalized : tool.ToLowerInvariant();
var configuredCandidates = config?.Tooling?.Tools
.FirstOrDefault(t => string.Equals(t.Tool, overrideTool, StringComparison.OrdinalIgnoreCase))
?.Executables
?.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
if (configuredCandidates is not null)
{
foreach (var configured in configuredCandidates)
{
var resolvedConfigured = ResolveFromPath(configured!) ?? configured!;
if (IsUsableExecutable(resolvedConfigured))
return new CommandResolutionResult(command, resolvedConfigured, CommandResolutionSource.ConfiguredOverride);
}
}
foreach (var candidate in BuildWindowsCandidates(command, normalized))
{
var resolved = ResolveFromPath(candidate);
if (!string.IsNullOrWhiteSpace(resolved))
{
var source = candidate.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) ||
candidate.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ||
candidate.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)
? CommandResolutionSource.Shim
: CommandResolutionSource.Path;
return new CommandResolutionResult(command, resolved!, source);
}
}
if (normalized is "npm" or "npx" or "pnpm" or "yarn")
{
var nodePath = ResolveFromPath("node.exe") ?? ResolveFromPath("node");
if (!string.IsNullOrWhiteSpace(nodePath))
{
var nodeDir = Path.GetDirectoryName(nodePath);
if (!string.IsNullOrWhiteSpace(nodeDir))
{
var shim = Path.Combine(nodeDir, normalized + ".cmd");
if (File.Exists(shim))
return new CommandResolutionResult(command, shim, CommandResolutionSource.NodeAdjacentShim);
}
}
}
var fallback = BuildWindowsCandidates(command, normalized).LastOrDefault() ?? command;
return new CommandResolutionResult(command, fallback, CommandResolutionSource.Fallback);
}
public static string Resolve(string command)
{
return ResolveWithTrace(command).Resolved;
}
private static string? ResolveFromPath(string executable)
{
var pathValue = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(pathValue))
return null;
foreach (var segment in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
try
{
var expandedSegment = ExpandWindowsPathTokens(segment.Trim());
var candidate = Path.Combine(expandedSegment, executable);
if (File.Exists(candidate))
{
// If PATH lookup hit extensionless npm but npm.cmd exists beside it, prefer npm.cmd.
var fileName = Path.GetFileName(candidate).ToLowerInvariant();
if (fileName is "npm" or "npx" or "pnpm" or "yarn" or "tauri")
{
var shim = candidate + ".cmd";
if (File.Exists(shim))
return shim;
}
return candidate;
}
}
catch
{
// Ignore malformed PATH segments.
}
}
return null;
}
private static string ExpandWindowsPathTokens(string segment)
{
if (string.IsNullOrWhiteSpace(segment) || !OperatingSystem.IsWindows())
return segment;
var expanded = segment;
for (var i = 0; i < 4; i++)
{
var next = Environment.ExpandEnvironmentVariables(expanded);
if (string.Equals(next, expanded, StringComparison.Ordinal))
break;
expanded = next;
}
return expanded;
}
private static List<string> BuildWindowsCandidates(string command, string normalized)
{
var candidates = new List<string>();
if (normalized is "npm" or "npx" or "pnpm" or "yarn" or "tauri")
{
candidates.Add(command + ".cmd");
candidates.Add(command + ".exe");
candidates.Add(command + ".bat");
candidates.Add(command);
}
else
{
candidates.Add(command);
}
return candidates;
}
private static bool IsUsableExecutable(string resolved)
{
if (string.IsNullOrWhiteSpace(resolved))
return false;
if (Path.IsPathRooted(resolved))
return File.Exists(resolved);
return ResolveFromPath(resolved) is not null;
}
}

View File

@ -0,0 +1,67 @@
using Sdt.Config;
namespace Sdt.Core;
public sealed record DoctorAutoFixResult(
bool Success,
string Message,
int CreatedDirectories = 0,
string? BackupPath = null);
public sealed class ConfigDoctorAutoFixService
{
public IReadOnlyList<string> FindMissingWorkingDirectories(DevToolConfig config, string projectRoot)
{
var missing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var workflow in config.Workflows)
{
foreach (var step in workflow.Steps)
{
if (string.IsNullOrWhiteSpace(step.WorkingDir))
continue;
var path = Path.GetFullPath(Path.Combine(projectRoot, step.WorkingDir));
if (!Directory.Exists(path))
missing.Add(path);
}
}
return missing.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
}
public DoctorAutoFixResult CreateMissingWorkingDirectories(IReadOnlyList<string> directories)
{
var created = 0;
try
{
foreach (var dir in directories)
{
if (Directory.Exists(dir))
continue;
Directory.CreateDirectory(dir);
created++;
}
return new DoctorAutoFixResult(
Success: true,
Message: created == 0 ? "No directories needed creation." : $"Created {created} missing working director{(created == 1 ? "y" : "ies")}.",
CreatedDirectories: created);
}
catch (Exception ex)
{
return new DoctorAutoFixResult(false, ex.Message, CreatedDirectories: created);
}
}
public DoctorAutoFixResult ApplyLegacyMigration(string projectRoot)
{
var configPath = ConfigLoader.FindConfigPath(projectRoot);
if (string.IsNullOrWhiteSpace(configPath))
return new DoctorAutoFixResult(false, "Could not find devtool.json for migration.");
var migration = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
return new DoctorAutoFixResult(
Success: migration.Success,
Message: migration.Message,
BackupPath: migration.BackupPath);
}
}

View File

@ -0,0 +1,270 @@
using Sdt.Config;
namespace Sdt.Core;
public enum DoctorStatus
{
Pass,
Warn,
Fail,
}
public sealed record DoctorCheck(
string Name,
DoctorStatus Status,
string Detail,
string? Fix = null);
public sealed record DoctorReport(
IReadOnlyList<DoctorCheck> Checks)
{
public bool HasFailures => Checks.Any(c => c.Status == DoctorStatus.Fail);
public bool HasWarnings => Checks.Any(c => c.Status == DoctorStatus.Warn);
}
public sealed class ConfigDoctorService(
IToolProbe? toolProbe = null,
IRequirementResolver? requirementResolver = null)
{
private readonly IToolProbe _toolProbe = toolProbe ?? new ToolProbeService();
private readonly IRequirementResolver _requirementResolver = requirementResolver ?? new RequirementResolver();
public async Task<DoctorReport> RunAsync(
DevToolConfig config,
string projectRoot,
CancellationToken cancellationToken = default)
{
var checks = new List<DoctorCheck>();
var workflowMap = config.Workflows.ToDictionary(w => w.Id, StringComparer.OrdinalIgnoreCase);
AddSchemaChecks(config, checks);
AddWorkflowChecks(config.Workflows, workflowMap, projectRoot, checks);
AddPathChecks(config, projectRoot, checks);
await AddToolProbeChecksAsync(config, projectRoot, checks, cancellationToken).ConfigureAwait(false);
return new DoctorReport(checks);
}
private static void AddSchemaChecks(DevToolConfig config, List<DoctorCheck> checks)
{
if (config.Workflows.Count == 0 && config.Targets.Count == 0)
{
checks.Add(new DoctorCheck(
"Config schema",
DoctorStatus.Fail,
"No workflows or legacy targets found.",
"Add workflows or run SDT init/bootstrap."));
return;
}
if (config.Workflows.Count == 0 && config.Targets.Count > 0)
{
checks.Add(new DoctorCheck(
"Legacy schema",
DoctorStatus.Fail,
"Targets-only config detected (strict mode will block execution).",
"Use SYSTEM -> Migrate legacy targets -> workflows."));
return;
}
if (config.Targets.Count > 0)
{
checks.Add(new DoctorCheck(
"Legacy schema",
DoctorStatus.Warn,
"Both workflows and legacy targets are present.",
"Prefer workflows-only config and remove legacy targets once migrated."));
}
else
{
checks.Add(new DoctorCheck("Config schema", DoctorStatus.Pass, "Workflow-first config detected."));
}
}
private static void AddWorkflowChecks(
IReadOnlyList<WorkflowDefinition> workflows,
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap,
string projectRoot,
List<DoctorCheck> checks)
{
var duplicateIds = workflows
.GroupBy(w => w.Id, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
if (duplicateIds.Count > 0)
{
checks.Add(new DoctorCheck(
"Workflow IDs",
DoctorStatus.Fail,
$"Duplicate workflow IDs: {string.Join(", ", duplicateIds)}",
"Ensure each workflow has a unique id."));
}
else
{
checks.Add(new DoctorCheck("Workflow IDs", DoctorStatus.Pass, "No duplicate workflow IDs."));
}
var brokenDeps = new List<string>();
foreach (var workflow in workflows)
{
foreach (var dep in workflow.DependsOn)
{
if (!workflowMap.ContainsKey(dep))
brokenDeps.Add($"{workflow.Id} -> {dep}");
}
}
if (brokenDeps.Count > 0)
{
checks.Add(new DoctorCheck(
"Workflow dependencies",
DoctorStatus.Fail,
$"Missing dependencies: {string.Join("; ", brokenDeps)}",
"Fix dependsOn IDs to reference existing workflows."));
}
else
{
checks.Add(new DoctorCheck("Workflow dependencies", DoctorStatus.Pass, "All workflow dependencies are valid."));
}
var invalidSteps = new List<string>();
var missingWorkingDirs = new List<string>();
foreach (var workflow in workflows)
{
foreach (var step in workflow.Steps)
{
if (string.IsNullOrWhiteSpace(step.Command) && string.IsNullOrWhiteSpace(step.Action))
invalidSteps.Add($"{workflow.Id}/{step.Id}");
var stepDir = Path.GetFullPath(Path.Combine(projectRoot, step.WorkingDir));
if (!Directory.Exists(stepDir))
missingWorkingDirs.Add($"{workflow.Id}/{step.Id} -> {step.WorkingDir}");
}
}
if (invalidSteps.Count > 0)
{
checks.Add(new DoctorCheck(
"Step definitions",
DoctorStatus.Fail,
$"Steps missing command/action: {string.Join(", ", invalidSteps)}",
"Each step must define either action or command."));
}
else
{
checks.Add(new DoctorCheck("Step definitions", DoctorStatus.Pass, "All steps define an action or command."));
}
if (missingWorkingDirs.Count > 0)
{
checks.Add(new DoctorCheck(
"Working directories",
DoctorStatus.Warn,
$"Missing directories: {string.Join("; ", missingWorkingDirs)}",
"Create missing directories or fix step workingDir values."));
}
else
{
checks.Add(new DoctorCheck("Working directories", DoctorStatus.Pass, "All referenced working directories exist."));
}
}
private static void AddPathChecks(DevToolConfig config, string projectRoot, List<DoctorCheck> checks)
{
var configPath = Path.Combine(projectRoot, "devtool.json");
checks.Add(File.Exists(configPath)
? new DoctorCheck("Project root", DoctorStatus.Pass, $"Config found at {configPath}")
: new DoctorCheck("Project root", DoctorStatus.Fail, $"devtool.json not found at {configPath}", "Run SDT init/bootstrap."));
if (OperatingSystem.IsWindows())
{
var pathValue = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
var unresolvedSegments = pathValue
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
.Where(s => s.Contains('%') && Environment.ExpandEnvironmentVariables(s) == s)
.Take(4)
.ToList();
if (unresolvedSegments.Count > 0)
{
checks.Add(new DoctorCheck(
"PATH expansion",
DoctorStatus.Warn,
$"Unresolved PATH tokens: {string.Join(" | ", unresolvedSegments)}",
"Set referenced env vars or remove invalid PATH segments."));
}
else
{
checks.Add(new DoctorCheck("PATH expansion", DoctorStatus.Pass, "No unresolved PATH token segments detected."));
}
}
if (config.Project?.RootHints.Count > 0)
{
checks.Add(new DoctorCheck("Root hints", DoctorStatus.Pass, $"Configured root hints: {string.Join(", ", config.Project.RootHints)}"));
}
else
{
checks.Add(new DoctorCheck(
"Root hints",
DoctorStatus.Warn,
"No project.rootHints configured.",
"Add rootHints markers (for example .git, *.sln, package.json)."));
}
}
private async Task AddToolProbeChecksAsync(
DevToolConfig config,
string projectRoot,
List<DoctorCheck> checks,
CancellationToken cancellationToken)
{
var requiredTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var workflow in config.Workflows)
{
foreach (var step in workflow.Steps)
{
foreach (var req in _requirementResolver.Resolve(step))
requiredTools.Add(req.Tool);
}
}
foreach (var profile in config.Debug?.Profiles ?? [])
{
foreach (var req in profile.Requires)
requiredTools.Add(req.Tool);
}
foreach (var toolDef in config.Tooling?.Tools ?? [])
requiredTools.Add(toolDef.Tool);
if (requiredTools.Count == 0)
{
checks.Add(new DoctorCheck("Tool probes", DoctorStatus.Warn, "No tools discovered from workflows/debug/tooling."));
return;
}
foreach (var tool in requiredTools.OrderBy(t => t, StringComparer.OrdinalIgnoreCase))
{
var probe = await _toolProbe.ProbeAsync(tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
if (probe.IsAvailable)
{
checks.Add(new DoctorCheck(
$"Tool: {tool}",
DoctorStatus.Pass,
string.IsNullOrWhiteSpace(probe.Version) ? "available" : probe.Version!,
probe.Details));
}
else
{
checks.Add(new DoctorCheck(
$"Tool: {tool}",
DoctorStatus.Fail,
string.IsNullOrWhiteSpace(probe.Details) ? "not available" : probe.Details!,
$"Install/configure {tool} or set tooling.tools[].executables for non-standard paths."));
}
}
}
}

View File

@ -0,0 +1,87 @@
using Sdt.Config;
using Sdt.Runner;
namespace Sdt.Core;
public sealed record ProbeResult(
string Tool,
bool IsAvailable,
string? Version = null,
string? Details = null);
public sealed record InstallCommand(
string Command,
IReadOnlyList<string> Args);
public sealed record InstallPlan(
string Tool,
bool Supported,
string Summary,
IReadOnlyList<InstallCommand> Commands);
public sealed record WorkflowStepResult(
string WorkflowId,
string StepId,
string StepLabel,
RunResult Result);
public enum ExecutionStopReason
{
MissingPrereq,
InstallFailed,
CommandFailed,
ValidationFailed,
UserDeclined,
}
public sealed record WorkflowExecutionResult(
bool Success,
ExecutionStopReason? StopReason,
string Message,
IReadOnlyList<WorkflowStepResult> Steps);
public interface IToolProbe
{
Task<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default);
}
public interface IPrereqInstaller
{
Task<InstallPlan> GetInstallPlanAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default);
Task<RunResult> RunInstallAsync(
InstallCommand command,
string projectRoot,
Action<string, bool> onOutput,
CancellationToken cancellationToken = default);
}
public interface IActionRunner
{
Task<RunResult> RunStepAsync(
WorkflowStep step,
string projectRoot,
Action<string, bool> onOutput,
CancellationToken cancellationToken = default);
}
public interface IWorkflowPlanner
{
List<WorkflowDefinition> ResolvePlan(
WorkflowDefinition workflow,
IReadOnlyDictionary<string, WorkflowDefinition> allWorkflows);
}
public interface IRequirementResolver
{
List<ToolRequirement> Resolve(WorkflowStep step);
List<ToolRequirement> Resolve(BuildTarget target);
}

View File

@ -0,0 +1,52 @@
using Sdt.Config;
using Sdt.Runner;
namespace Sdt.Core.Debug;
public sealed record DebugRunResult(
bool Success,
ExecutionStopReason? StopReason,
string Message,
DebugProfileDefinition Profile,
RunResult? RunResult,
IReadOnlyList<string> OutputLines,
IReadOnlyList<ProbeResult> Probes);
public sealed record DiagnosticsBundleResult(
bool Success,
string BundleDirectory,
string? ZipPath,
string Message);
public sealed record DiagnosticsBundleRequest(
string Category,
string ProjectRoot,
string SummaryMessage,
IReadOnlyList<string> OutputLines,
IReadOnlyList<WorkflowStepResult> WorkflowSteps,
IReadOnlyList<ProbeResult> Probes,
DebugDiagnosticsOptions DiagnosticsOptions,
DevToolConfig Config,
ExecutionStopReason? StopReason = null,
RunResult? DebugRun = null,
DebugProfileDefinition? DebugProfile = null);
public interface IDebugProfileRunner
{
Task<DebugRunResult> RunAsync(
DebugProfileDefinition profile,
DevToolConfig config,
string projectRoot,
bool verbose,
Func<string, InstallPlan, Task<bool>> confirmInstallAsync,
Action<string, bool> onOutput,
Action<RunEvent>? onEvent = null,
CancellationToken cancellationToken = default);
}
public interface IDiagnosticsBundleService
{
Task<DiagnosticsBundleResult> WriteBundleAsync(
DiagnosticsBundleRequest request,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,185 @@
using Sdt.Config;
using Sdt.Runner;
namespace Sdt.Core.Debug;
public sealed class DebugProfileRunner(
IToolProbe toolProbe,
IPrereqInstaller installer) : IDebugProfileRunner
{
private readonly IToolProbe _toolProbe = toolProbe;
private readonly IPrereqInstaller _installer = installer;
public async Task<DebugRunResult> RunAsync(
DebugProfileDefinition profile,
DevToolConfig config,
string projectRoot,
bool verbose,
Func<string, InstallPlan, Task<bool>> confirmInstallAsync,
Action<string, bool> onOutput,
Action<RunEvent>? onEvent = null,
CancellationToken cancellationToken = default)
{
var probes = new List<ProbeResult>();
var output = new List<string>();
onEvent?.Invoke(new RunEvent(
Category: "debug",
Type: RunEventType.DebugStarted,
Message: $"Debug profile '{profile.Id}' started."));
var requires = profile.Requires.Count > 0
? profile.Requires
: InferRequirements(profile);
foreach (var req in requires)
{
var probe = await _toolProbe.ProbeAsync(req.Tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
probes.Add(probe);
if (probe.IsAvailable)
continue;
if (!string.IsNullOrWhiteSpace(probe.Details))
{
var line = $"Probe detail [{req.Tool}]: {probe.Details}";
output.Add("OUT: " + line);
if (verbose)
onOutput(line, false);
}
if (req.InstallPolicy == InstallPolicy.Never)
{
onEvent?.Invoke(new RunEvent(
Category: "debug",
Type: RunEventType.DebugCompleted,
Message: $"Missing prerequisite '{req.Tool}'.",
Tool: req.Tool,
Success: false));
return new DebugRunResult(
Success: false,
StopReason: ExecutionStopReason.MissingPrereq,
Message: $"Missing prerequisite '{req.Tool}' for debug profile '{profile.Label}'.",
Profile: profile,
RunResult: null,
OutputLines: output,
Probes: probes);
}
var installPlan = await _installer.GetInstallPlanAsync(req.Tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
if (!installPlan.Supported || installPlan.Commands.Count == 0)
{
onEvent?.Invoke(new RunEvent(
Category: "debug",
Type: RunEventType.DebugCompleted,
Message: $"No installer plan available for '{req.Tool}'.",
Tool: req.Tool,
Success: false));
return new DebugRunResult(
Success: false,
StopReason: ExecutionStopReason.MissingPrereq,
Message: $"Missing prerequisite '{req.Tool}' and no installer plan is available.",
Profile: profile,
RunResult: null,
OutputLines: output,
Probes: probes);
}
var approved = req.InstallPolicy == InstallPolicy.Auto
? true
: await confirmInstallAsync(req.Tool, installPlan).ConfigureAwait(false);
if (!approved)
{
onEvent?.Invoke(new RunEvent(
Category: "debug",
Type: RunEventType.InstallDeclined,
Message: $"Install declined for '{req.Tool}'.",
Tool: req.Tool,
Success: false));
return new DebugRunResult(
Success: false,
StopReason: ExecutionStopReason.UserDeclined,
Message: $"Install declined for missing prerequisite '{req.Tool}'.",
Profile: profile,
RunResult: null,
OutputLines: output,
Probes: probes);
}
foreach (var cmd in installPlan.Commands)
{
var installResult = await _installer.RunInstallAsync(cmd, projectRoot, onOutput, cancellationToken).ConfigureAwait(false);
if (!installResult.Success)
{
onEvent?.Invoke(new RunEvent(
Category: "debug",
Type: RunEventType.DebugCompleted,
Message: $"Install failed for '{req.Tool}'.",
Tool: req.Tool,
Success: false,
ExitCode: installResult.ExitCode));
return new DebugRunResult(
Success: false,
StopReason: ExecutionStopReason.InstallFailed,
Message: $"Failed to install prerequisite '{req.Tool}'.",
Profile: profile,
RunResult: installResult,
OutputLines: output,
Probes: probes);
}
}
}
var cwd = Path.GetFullPath(Path.Combine(projectRoot, profile.WorkingDir));
var mergedEnv = profile.Env.Count > 0 ? profile.Env : null;
onEvent?.Invoke(new RunEvent(
Category: "debug",
Type: RunEventType.DebugCommandStarted,
Message: $"{profile.Command} {string.Join(" ", profile.Args)}"));
var run = await ProcessRunner.RunAsync(
profile.Command,
profile.Args,
cwd,
(line, isErr) =>
{
output.Add((isErr ? "ERR: " : "OUT: ") + line);
if (verbose)
onOutput(line, isErr);
},
mergedEnv,
cancellationToken).ConfigureAwait(false);
onEvent?.Invoke(new RunEvent(
Category: "debug",
Type: RunEventType.DebugCommandCompleted,
Message: $"Debug command exited {run.ExitCode}.",
Success: run.Success,
ExitCode: run.ExitCode));
onEvent?.Invoke(new RunEvent(
Category: "debug",
Type: RunEventType.DebugCompleted,
Message: run.Success ? "Debug run completed." : "Debug run failed.",
Success: run.Success,
ExitCode: run.ExitCode));
return new DebugRunResult(
Success: run.Success,
StopReason: run.Success ? null : ExecutionStopReason.CommandFailed,
Message: run.Success
? $"Debug profile '{profile.Label}' completed."
: $"Debug profile '{profile.Label}' exited with code {run.ExitCode}.",
Profile: profile,
RunResult: run,
OutputLines: output,
Probes: probes);
}
private static List<ToolRequirement> InferRequirements(DebugProfileDefinition profile)
{
return profile.Type.ToLowerInvariant() switch
{
"dotnet" => [new ToolRequirement { Tool = "dotnet" }],
"node" => [new ToolRequirement { Tool = "node" }, new ToolRequirement { Tool = "npm" }],
"python" => [new ToolRequirement { Tool = "python" }],
_ => string.IsNullOrWhiteSpace(profile.Command)
? []
: [new ToolRequirement { Tool = profile.Command }],
};
}
}

View File

@ -0,0 +1,99 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Sdt.Config;
namespace Sdt.Core.Debug;
public sealed class DiagnosticsBundleService : IDiagnosticsBundleService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public async Task<DiagnosticsBundleResult> WriteBundleAsync(
DiagnosticsBundleRequest request,
CancellationToken cancellationToken = default)
{
try
{
var timestamp = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
var root = Path.GetFullPath(Path.Combine(request.ProjectRoot, request.DiagnosticsOptions.OutputDir));
var bundleDir = Path.Combine(root, $"{request.Category}-{timestamp}");
Directory.CreateDirectory(bundleDir);
var stepsPath = Path.Combine(bundleDir, "steps.json");
var toolsPath = Path.Combine(bundleDir, "tools.json");
var envPath = Path.Combine(bundleDir, "env.json");
var outputPath = Path.Combine(bundleDir, "output.log");
var summaryPath = Path.Combine(bundleDir, "summary.json");
await File.WriteAllTextAsync(stepsPath, JsonSerializer.Serialize(request.WorkflowSteps, JsonOptions), cancellationToken);
await File.WriteAllTextAsync(toolsPath, JsonSerializer.Serialize(request.Probes, JsonOptions), cancellationToken);
await File.WriteAllTextAsync(envPath, JsonSerializer.Serialize(CaptureEnvironment(request.DiagnosticsOptions), JsonOptions), cancellationToken);
await File.WriteAllLinesAsync(outputPath, request.OutputLines, cancellationToken);
var summary = new
{
category = request.Category,
stopReason = request.StopReason?.ToString(),
message = request.SummaryMessage,
createdAt = DateTimeOffset.Now,
configHash = HashConfig(request.Config),
debugProfile = request.DebugProfile?.Id,
debugExitCode = request.DebugRun?.ExitCode,
envCapture = BuildEnvCaptureSummary(request.DiagnosticsOptions),
};
await File.WriteAllTextAsync(summaryPath, JsonSerializer.Serialize(summary, JsonOptions), cancellationToken);
return new DiagnosticsBundleResult(
Success: true,
BundleDirectory: bundleDir,
ZipPath: null,
Message: "Diagnostics bundle generated.");
}
catch (Exception ex)
{
return new DiagnosticsBundleResult(
Success: false,
BundleDirectory: string.Empty,
ZipPath: null,
Message: ex.Message);
}
}
private static Dictionary<string, string> CaptureEnvironment(DebugDiagnosticsOptions options)
{
if (options.IncludeAllEnv)
{
return Environment.GetEnvironmentVariables()
.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(e => e.Key?.ToString() ?? "", e => e.Value?.ToString() ?? "", StringComparer.OrdinalIgnoreCase);
}
if (options.CaptureEnvKeys.Count == 0)
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var captured = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in options.CaptureEnvKeys)
captured[key] = Environment.GetEnvironmentVariable(key) ?? "";
return captured;
}
private static string BuildEnvCaptureSummary(DebugDiagnosticsOptions options)
{
if (options.IncludeAllEnv)
return "full";
if (options.CaptureEnvKeys.Count == 0)
return "allowlist-empty (intentional minimal capture)";
return $"allowlist ({options.CaptureEnvKeys.Count} keys)";
}
private static string HashConfig(object config)
{
var json = JsonSerializer.Serialize(config);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(bytes);
}
}

View File

@ -0,0 +1,59 @@
using Sdt.Config;
namespace Sdt.Core;
internal static class LegacyScriptRequirementResolver
{
public static List<ToolRequirement> InferForPowerShellArgs(IReadOnlyList<string> args)
{
var script = FindScriptArg(args);
if (string.IsNullOrWhiteSpace(script))
return [];
static ToolRequirement Req(string tool) => new() { Tool = tool, InstallPolicy = InstallPolicy.Prompt };
var file = Path.GetFileName(script).ToLowerInvariant();
var lowerArgs = args.Select(a => a.ToLowerInvariant()).ToList();
return file switch
{
"publish-app.ps1" => IsTauriTarget(lowerArgs)
? [Req("python"), Req("node"), Req("npm"), Req("cargo")]
: [Req("python"), Req("node"), Req("npm")],
"publish-sidecar.ps1" => [Req("python"), Req("dotnet")],
"publish-webgateway.ps1" => [Req("python"), Req("dotnet"), Req("node"), Req("npm")],
"run-webgateway.ps1" => [Req("python"), Req("dotnet")],
"migration-gate.ps1" => [Req("python"), Req("dotnet")],
"nuget-export-cache.ps1" => [Req("python"), Req("dotnet")],
"nuget-import-cache.ps1" => [Req("python"), Req("dotnet")],
"npm-clean.ps1" => [Req("python"), Req("node"), Req("npm")],
"publish-output.ps1" => [Req("python"), Req("dotnet"), Req("node"), Req("npm"), Req("cargo")],
"sync-output.ps1" => [Req("python")],
"dotnet-min.ps1" => [Req("python"), Req("dotnet")],
"pip-min.ps1" => [Req("python")],
_ => [Req("python")]
};
}
private static string? FindScriptArg(IReadOnlyList<string> args)
{
for (var i = 0; i < args.Count - 1; i++)
{
if (string.Equals(args[i], "-File", StringComparison.OrdinalIgnoreCase))
return args[i + 1];
}
return args.FirstOrDefault(a => a.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase));
}
private static bool IsTauriTarget(IReadOnlyList<string> lowerArgs)
{
for (var i = 0; i < lowerArgs.Count - 1; i++)
{
if (lowerArgs[i] is "-target" or "--target" && lowerArgs[i + 1] == "tauri")
return true;
}
return false;
}
}

View File

@ -0,0 +1,303 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using Sdt.Config;
using Sdt.Runner;
namespace Sdt.Core;
public sealed class PrereqInstallerService : IPrereqInstaller
{
public async Task<InstallPlan> GetInstallPlanAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default)
{
var fromConfig = TryGetPlanFromConfig(tool, config);
if (fromConfig is not null)
return fromConfig;
var scriptPath = ScriptLocator.FindHelperScript(projectRoot, "diag.py");
if (scriptPath is null)
return FallbackPlan(tool);
try
{
var psi = new ProcessStartInfo
{
FileName = PythonResolver.ResolveExecutable(),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = projectRoot,
};
psi.ArgumentList.Add(scriptPath);
psi.ArgumentList.Add("install-plan");
psi.ArgumentList.Add("--tool");
psi.ArgumentList.Add(tool);
psi.ArgumentList.Add("--json");
using var process = new Process { StartInfo = psi };
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
var fallback = FallbackPlan(tool);
return fallback with
{
Summary = $"diag.py install-plan failed for {tool}; using fallback templates. " +
$"{(string.IsNullOrWhiteSpace(stderr) ? "No stderr output." : stderr.Trim())}"
};
}
var parsed = JsonSerializer.Deserialize<InstallPlanJson>(stdout);
if (parsed is null)
{
var fallback = FallbackPlan(tool);
return fallback with { Summary = $"diag.py returned invalid JSON for {tool}; using fallback templates." };
}
var commands = parsed.Commands
.Select(c => new InstallCommand(c.Command ?? "", c.Args ?? []))
.Where(c => !string.IsNullOrWhiteSpace(c.Command))
.ToList();
if (!parsed.Supported || commands.Count == 0)
{
var fallback = FallbackPlan(tool);
return fallback with { Summary = $"diag.py returned no usable commands for {tool}; using fallback templates." };
}
return new InstallPlan(
parsed.Tool ?? tool,
parsed.Supported,
parsed.Summary ?? $"Install {tool}",
commands);
}
catch (Exception ex)
{
var fallback = FallbackPlan(tool);
return fallback with { Summary = $"diag.py install-plan exception for {tool}; using fallback templates. {ex.Message}" };
}
}
public Task<RunResult> RunInstallAsync(
InstallCommand command,
string projectRoot,
Action<string, bool> onOutput,
CancellationToken cancellationToken = default)
=> ProcessRunner.RunAsync(command.Command, command.Args, projectRoot, onOutput, cancellationToken: cancellationToken);
private static InstallPlan FallbackPlan(string tool)
{
var isWindows = OperatingSystem.IsWindows();
var normalized = tool.ToLowerInvariant();
if (normalized == "tauri")
return BuildTauriFallbackPlan();
var installCommand = normalized switch
{
"dotnet" => isWindows
? new InstallCommand("winget", ["install", "Microsoft.DotNet.SDK.10"])
: new InstallCommand("sh", ["-c", "echo Install dotnet SDK from your distro package manager"]),
"node" => isWindows
? new InstallCommand("winget", ["install", "OpenJS.NodeJS.LTS"])
: new InstallCommand("sh", ["-c", "echo Install nodejs via package manager"]),
"npm" => isWindows
? new InstallCommand("winget", ["install", "OpenJS.NodeJS.LTS"])
: new InstallCommand("sh", ["-c", "echo Install npm via package manager"]),
"cargo" => isWindows
? new InstallCommand("winget", ["install", "Rustlang.Rustup"])
: new InstallCommand("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh"]),
"git" => isWindows
? new InstallCommand("winget", ["install", "Git.Git"])
: new InstallCommand("sh", ["-c", "echo Install git via package manager"]),
"docker" => isWindows
? new InstallCommand("winget", ["install", "Docker.DockerDesktop"])
: new InstallCommand("sh", ["-c", "echo Install docker engine via package manager"]),
"python" => isWindows
? new InstallCommand("winget", ["install", "Python.Python.3.12"])
: new InstallCommand("sh", ["-c", "echo Install python3 via package manager"]),
_ => new InstallCommand("sh", ["-c", $"echo No installer template for '{tool}'"]),
};
return new InstallPlan(
tool,
Supported: true,
Summary: $"Fallback install plan for {tool}",
Commands: [installCommand]);
}
private static InstallPlan BuildTauriFallbackPlan()
{
if (OperatingSystem.IsWindows())
{
return new InstallPlan(
"tauri",
Supported: true,
Summary: "Fallback tauri plan (Windows): install Node.js, Rust toolchain, and Tauri CLI. Visual Studio C++ build tools/WebView2 may also be required.",
Commands:
[
new InstallCommand("winget", ["install", "OpenJS.NodeJS.LTS"]),
new InstallCommand("winget", ["install", "Rustlang.Rustup"]),
new InstallCommand("npm", ["install", "-g", "@tauri-apps/cli"]),
]);
}
if (OperatingSystem.IsMacOS())
{
return new InstallPlan(
"tauri",
Supported: true,
Summary: "Fallback tauri plan (macOS): install Xcode command line tools, Rust toolchain, and Tauri CLI.",
Commands:
[
new InstallCommand("sh", ["-c", "xcode-select --install || true"]),
new InstallCommand("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh -s -- -y"]),
new InstallCommand("npm", ["install", "-g", "@tauri-apps/cli"]),
]);
}
var linuxPlan = BuildLinuxTauriPrereqCommand();
return new InstallPlan(
"tauri",
Supported: true,
Summary: $"Fallback tauri plan (Linux): detected package manager `{linuxPlan.PackageManager}` for system deps, then install Rust toolchain and Tauri CLI.",
Commands:
[
new InstallCommand("sh", ["-c", linuxPlan.Command]),
new InstallCommand("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh -s -- -y"]),
new InstallCommand("npm", ["install", "-g", "@tauri-apps/cli"]),
]);
}
private static (string PackageManager, string Command) BuildLinuxTauriPrereqCommand()
{
if (CommandExists("apt-get"))
{
return ("apt-get",
"sudo apt-get update && sudo apt-get install -y build-essential libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev");
}
if (CommandExists("dnf"))
{
return ("dnf",
"sudo dnf install -y gcc gcc-c++ make webkit2gtk4.1-devel gtk3-devel libappindicator-gtk3 librsvg2-devel");
}
if (CommandExists("pacman"))
{
return ("pacman",
"sudo pacman -S --needed base-devel webkit2gtk gtk3 libappindicator-gtk3 librsvg");
}
if (CommandExists("zypper"))
{
return ("zypper",
"sudo zypper install -y gcc gcc-c++ make webkit2gtk3-devel gtk3-devel libappindicator3-devel librsvg-devel");
}
if (CommandExists("apk"))
{
return ("apk",
"sudo apk add build-base webkit2gtk-dev gtk+3.0-dev libayatana-appindicator-dev librsvg-dev");
}
return ("unknown",
"echo Install tauri system dependencies using your distro package manager, then rerun SDT.");
}
private static bool CommandExists(string command)
{
try
{
var psi = new ProcessStartInfo
{
FileName = OperatingSystem.IsWindows() ? "where" : "which",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add(command);
using var process = Process.Start(psi);
if (process is null)
return false;
process.WaitForExit(1500);
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private static InstallPlan? TryGetPlanFromConfig(string tool, DevToolConfig? config)
{
var preferred = config?.Tooling?.Tools
.FirstOrDefault(t => string.Equals(t.Tool, tool, StringComparison.OrdinalIgnoreCase))
?.PreferredInstallCommands;
if (preferred is null || preferred.Count == 0)
return null;
var commands = new List<InstallCommand>();
foreach (var line in preferred)
{
var parts = SplitShellLike(line);
if (parts.Count == 0)
continue;
commands.Add(new InstallCommand(parts[0], parts.Skip(1).ToList()));
}
if (commands.Count == 0)
return null;
return new InstallPlan(
tool,
Supported: true,
Summary: $"Configured install commands for {tool}",
Commands: commands);
}
private static List<string> SplitShellLike(string input)
{
if (string.IsNullOrWhiteSpace(input))
return [];
var tokens = new List<string>();
var matches = Regex.Matches(input, "\"([^\"]*)\"|'([^']*)'|\\S+");
foreach (Match match in matches)
{
var value = match.Value.Trim();
if (value.Length >= 2 && (
(value.StartsWith("\"", StringComparison.Ordinal) && value.EndsWith("\"", StringComparison.Ordinal)) ||
(value.StartsWith("'", StringComparison.Ordinal) && value.EndsWith("'", StringComparison.Ordinal))))
{
value = value[1..^1];
}
if (!string.IsNullOrWhiteSpace(value))
tokens.Add(value);
}
return tokens;
}
private sealed class InstallPlanJson
{
public string? Tool { get; init; }
public bool Supported { get; init; }
public string? Summary { get; init; }
public List<InstallCommandJson> Commands { get; init; } = [];
}
private sealed class InstallCommandJson
{
public string? Command { get; init; }
public List<string>? Args { get; init; }
}
}

View File

@ -0,0 +1,46 @@
using System.Diagnostics;
namespace Sdt.Core;
internal static class PythonResolver
{
public static string ResolveExecutable()
{
var candidates = OperatingSystem.IsWindows()
? new[] { "python", "py" }
: new[] { "python3", "python" };
foreach (var candidate in candidates)
{
if (CanRun(candidate))
return candidate;
}
return "python";
}
private static bool CanRun(string exe)
{
try
{
var psi = new ProcessStartInfo
{
FileName = exe,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("--version");
using var p = new Process { StartInfo = psi };
p.Start();
p.WaitForExit(2000);
return p.ExitCode == 0;
}
catch
{
return false;
}
}
}

View File

@ -0,0 +1,67 @@
using Sdt.Config;
namespace Sdt.Core;
public sealed class RequirementResolver : IRequirementResolver
{
public List<ToolRequirement> Resolve(WorkflowStep step)
{
if (step.Requires.Count > 0)
return step.Requires.ToList();
if (!string.IsNullOrWhiteSpace(step.Action))
return InferActionRequirements(step.Action);
if (string.IsNullOrWhiteSpace(step.Command))
return [];
return InferCommandRequirements(step.Command, step.Args);
}
public List<ToolRequirement> Resolve(BuildTarget target)
{
if (string.IsNullOrWhiteSpace(target.Command))
return [];
return InferCommandRequirements(target.Command, target.Args);
}
private static List<ToolRequirement> InferActionRequirements(string action)
{
return action.ToLowerInvariant() switch
{
"dotnet-restore" or "dotnet-build" or "dotnet-test" or "dotnet-publish" => [Req("dotnet")],
"npm-install" or "npm-ci" or "npm-build" or "npm-test" or "npm-audit" => [Req("node"), Req("npm")],
"python-venv-create" or "python-pip-install" or "python-pip-sync" or "python-pytest" => [Req("python")],
"cargo-build" or "cargo-test" => [Req("cargo")],
"tauri-build" => [Req("cargo"), Req("node"), Req("npm")],
"git-status" or "git-fetch" or "git-pull" or "git-clean" => [Req("git")],
"docker-build" or "docker-compose-up" or "docker-compose-down" => [Req("docker")],
_ => [],
};
}
private static List<ToolRequirement> InferCommandRequirements(string command, IReadOnlyList<string> args)
{
return command.ToLowerInvariant() switch
{
"dotnet" => [Req("dotnet")],
"npm" => [Req("node"), Req("npm")],
"pnpm" => [Req("node"), Req("pnpm")],
"yarn" => [Req("node"), Req("yarn")],
"python" or "py" => [Req("python")],
"cargo" => [Req("cargo")],
"tauri" => [Req("cargo"), Req("node"), Req("npm")],
"git" => [Req("git")],
"docker" => [Req("docker")],
"pwsh" or "powershell" => LegacyScriptRequirementResolver.InferForPowerShellArgs(args),
_ => [],
};
}
private static ToolRequirement Req(string tool) => new()
{
Tool = tool,
InstallPolicy = InstallPolicy.Prompt,
};
}

View File

@ -0,0 +1,65 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Sdt.Core;
public sealed class RunEventJsonlRecorder : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
static RunEventJsonlRecorder()
{
JsonOptions.Converters.Add(new JsonStringEnumConverter());
}
private readonly StreamWriter _writer;
private readonly object _gate = new();
private bool _disposed;
public string FilePath { get; }
private RunEventJsonlRecorder(string filePath, StreamWriter writer)
{
FilePath = filePath;
_writer = writer;
}
public static RunEventJsonlRecorder Create(string projectRoot, string category)
{
var root = Path.Combine(projectRoot, ".sdt", "events");
Directory.CreateDirectory(root);
var fileName = $"{category}-{DateTimeOffset.Now:yyyyMMdd-HHmmss}.jsonl";
var path = Path.Combine(root, fileName);
var writer = new StreamWriter(new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
{
AutoFlush = true
};
return new RunEventJsonlRecorder(path, writer);
}
public void Write(RunEvent evt)
{
lock (_gate)
{
if (_disposed)
return;
var line = JsonSerializer.Serialize(evt, JsonOptions);
_writer.WriteLine(line);
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
_writer.Dispose();
}
}
}

View File

@ -0,0 +1,99 @@
using System.Text.Json;
namespace Sdt.Core;
public sealed record RunEventLogFile(
string Path,
string Name,
DateTimeOffset LastWriteTime,
long SizeBytes);
public sealed class RunEventLogReader
{
public IReadOnlyList<RunEventLogFile> ListEventFiles(string projectRoot)
{
var eventsRoot = Path.Combine(projectRoot, ".sdt", "events");
if (!Directory.Exists(eventsRoot))
return [];
return Directory.EnumerateFiles(eventsRoot, "*.jsonl", SearchOption.TopDirectoryOnly)
.Select(path =>
{
var info = new FileInfo(path);
return new RunEventLogFile(
Path: path,
Name: info.Name,
LastWriteTime: info.LastWriteTime,
SizeBytes: info.Length);
})
.OrderByDescending(f => f.LastWriteTime)
.ToList();
}
public IReadOnlyList<RunEvent> ReadEvents(string filePath)
{
var results = new List<RunEvent>();
if (!File.Exists(filePath))
return results;
foreach (var line in File.ReadLines(filePath))
{
if (string.IsNullOrWhiteSpace(line))
continue;
if (TryParseLine(line, out var evt))
results.Add(evt!);
}
return results;
}
internal static bool TryParseLine(string jsonLine, out RunEvent? evt)
{
evt = null;
try
{
using var doc = JsonDocument.Parse(jsonLine);
var root = doc.RootElement;
var category = root.TryGetProperty("category", out var c) ? c.GetString() : null;
var typeRaw = root.TryGetProperty("type", out var t) ? t.GetString() : null;
var message = root.TryGetProperty("message", out var m) ? m.GetString() : null;
var workflowId = root.TryGetProperty("workflowId", out var wf) ? wf.GetString() : null;
var stepId = root.TryGetProperty("stepId", out var st) ? st.GetString() : null;
var tool = root.TryGetProperty("tool", out var tl) ? tl.GetString() : null;
var success = root.TryGetProperty("success", out var s) && s.ValueKind != JsonValueKind.Null ? s.GetBoolean() : (bool?)null;
var exitCode = root.TryGetProperty("exitCode", out var ec) && ec.ValueKind != JsonValueKind.Null ? ec.GetInt32() : (int?)null;
DateTimeOffset? occurred = null;
if (root.TryGetProperty("occurredAt", out var ts) && ts.ValueKind == JsonValueKind.String &&
DateTimeOffset.TryParse(ts.GetString(), out var parsed))
{
occurred = parsed;
}
if (string.IsNullOrWhiteSpace(category) ||
string.IsNullOrWhiteSpace(typeRaw) ||
string.IsNullOrWhiteSpace(message) ||
!Enum.TryParse<RunEventType>(typeRaw, ignoreCase: true, out var type))
{
return false;
}
evt = new RunEvent(
Category: category!,
Type: type,
Message: message!,
WorkflowId: workflowId,
StepId: stepId,
Tool: tool,
Success: success,
ExitCode: exitCode,
Timestamp: occurred);
return true;
}
catch
{
return false;
}
}
}

View File

@ -0,0 +1,34 @@
namespace Sdt.Core;
public enum RunEventType
{
WorkflowStarted,
WorkflowPlanned,
WorkflowStepStarted,
WorkflowStepCompleted,
ProbeChecked,
ProbeFailed,
InstallPlanPrepared,
InstallDeclined,
InstallCommandStarted,
InstallCommandCompleted,
WorkflowCompleted,
DebugStarted,
DebugCommandStarted,
DebugCommandCompleted,
DebugCompleted,
}
public sealed record RunEvent(
string Category,
RunEventType Type,
string Message,
string? WorkflowId = null,
string? StepId = null,
string? Tool = null,
bool? Success = null,
int? ExitCode = null,
DateTimeOffset? Timestamp = null)
{
public DateTimeOffset OccurredAt { get; init; } = Timestamp ?? DateTimeOffset.Now;
}

View File

@ -0,0 +1,19 @@
namespace Sdt.Core;
internal static class ScriptLocator
{
public static string? FindHelperScript(string projectRoot, string scriptFileName)
{
// Packaged location: alongside executable in ./scripts
var bundled = Path.Combine(AppContext.BaseDirectory, "scripts", scriptFileName);
if (File.Exists(bundled))
return bundled;
// Source/project location fallback
var project = Path.Combine(projectRoot, "scripts", scriptFileName);
if (File.Exists(project))
return project;
return null;
}
}

View File

@ -0,0 +1,122 @@
using System.Diagnostics;
using System.Text.Json;
using Sdt.Config;
namespace Sdt.Core;
public sealed class ToolProbeService : IToolProbe
{
public async Task<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default)
{
var direct = await ProbeDirectAsync(tool, config, cancellationToken).ConfigureAwait(false);
if (direct.IsAvailable)
return direct;
var scriptPath = ScriptLocator.FindHelperScript(projectRoot, "diag.py");
if (scriptPath is null)
return direct;
if (!(await ProbeDirectAsync("python", config, cancellationToken).ConfigureAwait(false)).IsAvailable)
return direct;
try
{
var psi = new ProcessStartInfo
{
FileName = PythonResolver.ResolveExecutable(),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = projectRoot,
};
psi.ArgumentList.Add(scriptPath);
psi.ArgumentList.Add("probe");
psi.ArgumentList.Add("--tool");
psi.ArgumentList.Add(tool);
psi.ArgumentList.Add("--json");
using var process = new Process { StartInfo = psi };
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
return new ProbeResult(tool, false, Details: stderr.Trim());
var parsed = JsonSerializer.Deserialize<DiagProbeJson>(stdout);
if (parsed is null)
return new ProbeResult(tool, false, Details: "diag.py returned invalid JSON");
return new ProbeResult(parsed.Tool ?? tool, parsed.Available, parsed.Version, parsed.Details);
}
catch (Exception ex)
{
return new ProbeResult(tool, false, Details: ex.Message);
}
}
private static async Task<ProbeResult> ProbeDirectAsync(string tool, DevToolConfig? config, CancellationToken cancellationToken)
{
var command = tool.ToLowerInvariant() switch
{
"python" => PythonResolver.ResolveExecutable(),
"dotnet" => "dotnet",
"node" => "node",
"npm" => "npm",
"cargo" => "cargo",
"tauri" => "tauri",
"git" => "git",
"docker" => "docker",
_ => tool,
};
var resolution = CommandResolver.ResolveWithTrace(command, config, tool);
command = resolution.Resolved;
var versionArg = command is "python" ? "--version" : "--version";
try
{
var psi = new ProcessStartInfo
{
FileName = command,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add(versionArg);
using var process = new Process { StartInfo = psi };
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
var failDetails = string.IsNullOrWhiteSpace(stderr) ? stdout.Trim() : stderr.Trim();
var trace = $"{resolution.Source}: {resolution.Resolved}";
return new ProbeResult(tool, false, Details: string.IsNullOrWhiteSpace(failDetails) ? trace : $"{trace} | {failDetails}");
}
var version = string.IsNullOrWhiteSpace(stdout) ? stderr.Trim() : stdout.Trim();
return new ProbeResult(tool, true, Version: version, Details: $"{resolution.Source}: {resolution.Resolved}");
}
catch (Exception ex)
{
return new ProbeResult(tool, false, Details: $"{resolution.Source}: {resolution.Resolved} | {ex.Message}");
}
}
private sealed class DiagProbeJson
{
public string? Tool { get; init; }
public bool Available { get; init; }
public string? Version { get; init; }
public string? Details { get; init; }
}
}

View File

@ -0,0 +1,273 @@
using Sdt.Config;
namespace Sdt.Core;
public sealed class WorkflowExecutor(
IWorkflowPlanner planner,
IToolProbe toolProbe,
IPrereqInstaller installer,
IActionRunner actionRunner,
IRequirementResolver requirementResolver)
{
private readonly IWorkflowPlanner _planner = planner;
private readonly IToolProbe _toolProbe = toolProbe;
private readonly IPrereqInstaller _installer = installer;
private readonly IActionRunner _actionRunner = actionRunner;
private readonly IRequirementResolver _requirementResolver = requirementResolver;
public async Task<WorkflowExecutionResult> ExecuteAsync(
WorkflowDefinition rootWorkflow,
IReadOnlyDictionary<string, WorkflowDefinition> allWorkflows,
DevToolConfig config,
string projectRoot,
Func<string, InstallPlan, Task<bool>> confirmInstallAsync,
Action<string, bool> onOutput,
Action<RunEvent>? onEvent = null,
CancellationToken cancellationToken = default)
{
var results = new List<WorkflowStepResult>();
var plan = _planner.ResolvePlan(rootWorkflow, allWorkflows);
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowStarted,
Message: $"Workflow '{rootWorkflow.Id}' started.",
WorkflowId: rootWorkflow.Id));
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowPlanned,
Message: $"Execution plan contains {plan.Count} workflow(s).",
WorkflowId: rootWorkflow.Id));
if (plan.Count == 0)
{
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowCompleted,
Message: "No executable workflow steps were found.",
WorkflowId: rootWorkflow.Id,
Success: false));
return new WorkflowExecutionResult(
Success: false,
StopReason: ExecutionStopReason.ValidationFailed,
Message: "This workflow has no executable steps.",
Steps: results);
}
foreach (var workflow in plan)
{
foreach (var step in workflow.Steps)
{
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowStepStarted,
Message: $"Step '{step.Label}' started.",
WorkflowId: workflow.Id,
StepId: step.Id));
var requires = _requirementResolver.Resolve(step);
foreach (var req in requires)
{
var probe = await _toolProbe.ProbeAsync(req.Tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.ProbeChecked,
Message: probe.IsAvailable
? $"Tool '{req.Tool}' is available."
: $"Tool '{req.Tool}' is missing.",
WorkflowId: workflow.Id,
StepId: step.Id,
Tool: req.Tool,
Success: probe.IsAvailable));
if (probe.IsAvailable)
continue;
if (!string.IsNullOrWhiteSpace(probe.Details))
{
onOutput($"Probe detail [{req.Tool}]: {probe.Details}", false);
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.ProbeFailed,
Message: probe.Details,
WorkflowId: workflow.Id,
StepId: step.Id,
Tool: req.Tool,
Success: false));
}
if (req.InstallPolicy == InstallPolicy.Never)
{
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowCompleted,
Message: $"Missing prerequisite '{req.Tool}'.",
WorkflowId: rootWorkflow.Id,
Tool: req.Tool,
Success: false));
return new WorkflowExecutionResult(
Success: false,
StopReason: ExecutionStopReason.MissingPrereq,
Message: $"Missing prerequisite '{req.Tool}' for step '{step.Label}'.",
Steps: results);
}
var installPlan = await _installer.GetInstallPlanAsync(
req.Tool,
projectRoot,
config,
cancellationToken).ConfigureAwait(false);
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.InstallPlanPrepared,
Message: installPlan.Summary,
WorkflowId: workflow.Id,
StepId: step.Id,
Tool: req.Tool,
Success: installPlan.Supported));
if (!installPlan.Supported || installPlan.Commands.Count == 0)
{
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowCompleted,
Message: $"No installer plan available for '{req.Tool}'.",
WorkflowId: rootWorkflow.Id,
Tool: req.Tool,
Success: false));
return new WorkflowExecutionResult(
Success: false,
StopReason: ExecutionStopReason.MissingPrereq,
Message: $"Missing prerequisite '{req.Tool}' and no installer plan is available.",
Steps: results);
}
var approved = req.InstallPolicy == InstallPolicy.Auto
? true
: await confirmInstallAsync(req.Tool, installPlan).ConfigureAwait(false);
if (!approved)
{
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.InstallDeclined,
Message: $"Install declined for '{req.Tool}'.",
WorkflowId: workflow.Id,
StepId: step.Id,
Tool: req.Tool,
Success: false));
return new WorkflowExecutionResult(
Success: false,
StopReason: ExecutionStopReason.UserDeclined,
Message: $"Install declined for missing prerequisite '{req.Tool}'.",
Steps: results);
}
foreach (var installCommand in installPlan.Commands)
{
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.InstallCommandStarted,
Message: $"{installCommand.Command} {string.Join(" ", installCommand.Args)}",
WorkflowId: workflow.Id,
StepId: step.Id,
Tool: req.Tool));
var installResult = await _installer.RunInstallAsync(
installCommand,
projectRoot,
onOutput,
cancellationToken).ConfigureAwait(false);
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.InstallCommandCompleted,
Message: $"Install command exited {installResult.ExitCode}.",
WorkflowId: workflow.Id,
StepId: step.Id,
Tool: req.Tool,
Success: installResult.Success,
ExitCode: installResult.ExitCode));
if (!installResult.Success)
{
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowCompleted,
Message: $"Install failed for '{req.Tool}'.",
WorkflowId: rootWorkflow.Id,
Tool: req.Tool,
Success: false));
return new WorkflowExecutionResult(
Success: false,
StopReason: ExecutionStopReason.InstallFailed,
Message: $"Failed to install prerequisite '{req.Tool}'.",
Steps: results);
}
}
var verifyProbe = await _toolProbe.ProbeAsync(req.Tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
if (!verifyProbe.IsAvailable)
{
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowCompleted,
Message: $"Tool '{req.Tool}' still missing after install.",
WorkflowId: rootWorkflow.Id,
Tool: req.Tool,
Success: false));
return new WorkflowExecutionResult(
Success: false,
StopReason: ExecutionStopReason.InstallFailed,
Message: $"Prerequisite '{req.Tool}' is still missing after install attempt.",
Steps: results);
}
}
var runResult = await _actionRunner.RunStepAsync(
step,
projectRoot,
onOutput,
cancellationToken).ConfigureAwait(false);
results.Add(new WorkflowStepResult(
workflow.Id,
step.Id,
string.IsNullOrWhiteSpace(step.Label) ? step.Id : step.Label,
runResult));
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowStepCompleted,
Message: $"Step '{step.Label}' exited {runResult.ExitCode}.",
WorkflowId: workflow.Id,
StepId: step.Id,
Success: runResult.Success,
ExitCode: runResult.ExitCode));
if (!runResult.Success)
{
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowCompleted,
Message: $"Step '{step.Label}' failed.",
WorkflowId: rootWorkflow.Id,
Success: false,
ExitCode: runResult.ExitCode));
return new WorkflowExecutionResult(
Success: false,
StopReason: ExecutionStopReason.CommandFailed,
Message: $"Step '{step.Label}' failed with exit code {runResult.ExitCode}.",
Steps: results);
}
}
}
onEvent?.Invoke(new RunEvent(
Category: "workflow",
Type: RunEventType.WorkflowCompleted,
Message: "Workflow completed successfully.",
WorkflowId: rootWorkflow.Id,
Success: true));
return new WorkflowExecutionResult(
Success: true,
StopReason: null,
Message: "Workflow completed successfully.",
Steps: results);
}
}

View File

@ -0,0 +1,35 @@
using Sdt.Config;
namespace Sdt.Core;
public sealed class WorkflowPlanner : IWorkflowPlanner
{
public List<WorkflowDefinition> ResolvePlan(
WorkflowDefinition workflow,
IReadOnlyDictionary<string, WorkflowDefinition> allWorkflows)
{
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plan = new List<WorkflowDefinition>();
Visit(workflow, allWorkflows, visited, plan);
return plan;
}
private static void Visit(
WorkflowDefinition workflow,
IReadOnlyDictionary<string, WorkflowDefinition> allWorkflows,
HashSet<string> visited,
List<WorkflowDefinition> plan)
{
if (!visited.Add(workflow.Id))
return;
foreach (var depId in workflow.DependsOn)
{
if (allWorkflows.TryGetValue(depId, out var dep))
Visit(dep, allWorkflows, visited, plan);
}
if (workflow.Steps.Count > 0)
plan.Add(workflow);
}
}

View File

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>sdt</AssemblyName>
<RootNamespace>Sdt</RootNamespace>
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.49.1" />
</ItemGroup>
<ItemGroup>
<Compile Remove="tests\**\*.cs" />
<None Include="tests\**\*" />
</ItemGroup>
<ItemGroup>
<Content Include="scripts\*.py">
<Link>scripts\%(Filename)%(Extension)</Link>
<TargetPath>scripts\%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="scripts\dev-shell.ps1;scripts\dev-shell.sh;scripts\dev-shell.cmd;scripts\_pwsh-python-shim.ps1">
<Link>scripts\%(Filename)%(Extension)</Link>
<TargetPath>scripts\%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -2,30 +2,72 @@ using Sdt.Config;
using Sdt.Tui;
using Spectre.Console;
// ── Workspace + project discovery ────────────────────────────────────────────
var workspaceResult = WorkspaceLoader.FindAndLoad();
var projectResult = ConfigLoader.FindAndLoad();
if (projectResult is null)
{
AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No devtool.json found[/] in current directory or any parent.");
AnsiConsole.MarkupLine(Theme.Faint("Create a devtool.json in your project root to get started."));
return 1;
}
// ── Main run loop (handles workspace project switching) ───────────────────────
var (currentConfig, currentRoot) = projectResult.Value;
var (workspace, workspaceRoot) = workspaceResult.HasValue
? (workspaceResult.Value.Config, workspaceResult.Value.WorkspaceRoot)
: ((WorkspaceConfig?)null, (string?)null);
try
{
// ── Workspace + project discovery ────────────────────────────────────────
var workspaceResult = WorkspaceLoader.FindAndLoad();
var projectResult = ConfigLoader.FindAndLoad();
var cliArgs = Environment.GetCommandLineArgs().Skip(1).ToArray();
var forceInit = cliArgs.Any(a => string.Equals(a, "init", StringComparison.OrdinalIgnoreCase) ||
string.Equals(a, "--init", StringComparison.OrdinalIgnoreCase));
if (forceInit)
{
var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory());
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
AnsiConsole.MarkupLine(Theme.Ok($"Initialized config at {path}"));
projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot);
}
if (projectResult is null)
{
AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No devtool.json found[/] in current directory or any parent.");
var bootstrap = AnsiConsole.Confirm(
$"[{Theme.Amber}]Generate a default devtool.json for this project now?[/]",
defaultValue: true);
if (!bootstrap)
{
AnsiConsole.MarkupLine(Theme.Faint("Create a devtool.json in your project root to get started."));
return 1;
}
var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory());
AnsiConsole.MarkupLine(Theme.Faint($"Detected project root: {scan.ProjectRoot}"));
if (scan.ToolFamilies.Count > 0)
AnsiConsole.MarkupLine(Theme.Faint($"Detected tool families: {string.Join(", ", scan.ToolFamilies)}"));
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
var preview = ConfigBootstrapper.ToJson(generated);
AnsiConsole.Write(new Panel(Markup.Escape(preview)).Header("Generated devtool.json preview").BorderStyle(Theme.DimStyle));
var confirmWrite = AnsiConsole.Confirm(
$"[{Theme.Amber}]Write generated devtool.json to {scan.ProjectRoot}?[/]",
defaultValue: true);
if (!confirmWrite)
return 1;
var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
AnsiConsole.MarkupLine(Theme.Ok($"Created {path}"));
projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot);
if (projectResult is null)
{
AnsiConsole.MarkupLine(Theme.Fail("Generated config could not be reloaded."));
return 1;
}
}
// ── Main run loop (handles workspace project switching) ────────────────
var currentLoaded = projectResult;
var (workspace, workspaceRoot) = workspaceResult.HasValue
? (workspaceResult.Value.Config, workspaceResult.Value.WorkspaceRoot)
: ((WorkspaceConfig?)null, (string?)null);
while (true)
{
var app = new App(currentConfig, currentRoot, workspace, workspaceRoot);
var app = new App(currentLoaded.Config, currentLoaded.ProjectRoot, currentLoaded.Warnings, workspace, workspaceRoot);
var result = await app.RunAsync();
if (result.Reason == AppExitReason.Quit)
@ -34,7 +76,18 @@ try
// User switched projects — reload config from new root
if (result.Reason == AppExitReason.SwitchProject && result.NewProjectRoot is not null)
{
var loaded = ConfigLoader.FindAndLoad(result.NewProjectRoot);
LoadedProjectConfig? loaded;
try
{
loaded = ConfigLoader.FindAndLoad(result.NewProjectRoot);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine(Theme.Fail(ex.Message));
AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
Console.ReadKey(intercept: true);
continue;
}
if (loaded is null)
{
AnsiConsole.MarkupLine(Theme.Fail($"No devtool.json found at: {result.NewProjectRoot}"));
@ -43,7 +96,7 @@ try
continue; // go back to current app
}
(currentConfig, currentRoot) = loaded.Value;
currentLoaded = loaded;
}
}
@ -51,7 +104,40 @@ try
}
catch (Exception ex)
{
AnsiConsole.MarkupLine(Theme.Fail($"Fatal: {ex.Message}"));
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
var message = ex.Message;
var isExpectedMigrationError =
ex is InvalidOperationException &&
message.Contains("Legacy targets-only config detected", StringComparison.OrdinalIgnoreCase);
AnsiConsole.MarkupLine(Theme.Fail($"Fatal: {message}"));
if (isExpectedMigrationError)
{
var configPath = ConfigLoader.FindConfigPath();
if (!string.IsNullOrWhiteSpace(configPath))
{
var migrate = AnsiConsole.Confirm(
$"[{Theme.Amber}]Apply automatic migration now (creates backup + converts targets -> workflows)?[/]",
defaultValue: true);
if (migrate)
{
var result = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
if (result.Success)
{
AnsiConsole.MarkupLine(Theme.Ok("Migration applied successfully."));
if (!string.IsNullOrWhiteSpace(result.BackupPath))
AnsiConsole.MarkupLine(Theme.Faint($"Backup: {result.BackupPath}"));
AnsiConsole.MarkupLine(Theme.Faint("Run sdt.exe again in strict mode."));
}
else
{
AnsiConsole.MarkupLine(Theme.Fail($"Migration failed: {result.Message}"));
}
}
}
}
if (!isExpectedMigrationError)
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
return 1;
}

View File

@ -1,234 +1,200 @@
# SDT — Stan's Dev Tools
# SDT (Stan's Dev Tools)
> **Status: v0.1 — active development**
> Phosphor-green TUI build orchestrator. Currently embedded in Project Journal; long-term goal is a standalone universal dev tool.
Cross-platform terminal orchestrator for project workflows, toolchain checks, and prerequisite gating.
---
## Current State
## What It Is
- Standalone `.NET` TUI app (`net10.0`)
- Workflow-first config model in `devtool.json`
- Strict-by-default legacy migration (`targets`-only configs fail unless compat mode is enabled)
- Python-first diagnostics/build script layer under `scripts/`
- Fail-fast execution with install prompt gating for missing prerequisites
- Debug profiles with attach metadata and diagnostics bundle generation
- Workspace-first project switching with support for external project paths
- Workspace-level defaults layering via `sdt-defaults.json` (ancestor defaults merged, project config wins)
- Project status tracking is maintained in `ROADMAP.md`
- Core run-event stream (`RunEvent`) shared by workflow + debug execution (TUI consumes it; GUI-ready)
- Run events are persisted to JSONL at `.sdt/events/` for external tooling/GUI consumers
- TUI includes `SYSTEM -> View run events` to inspect persisted JSONL event logs
- `SYSTEM -> Run config doctor` can apply common autofixes (missing working dirs, legacy migration)
SDT is a terminal UI (TUI) application for managing builds, toolchains, and multi-project workspaces. It reads a `devtool.json` from your project root and presents a menu-driven interface so you never have to remember which script to run, in what order, with what flags.
```
____ ____ _____
/ ___|| _ \|_ _|
\___ \| | | | | |
___) | |_| | | |
|____/|____/ |_|
─────────── Project Journal v0.1.0 ─────────────
root: E:\stansshit\csharp\journal-master\journal
What would you like to do?
BUILD
> Publish Sidecar Build Journal.Sidecar as self-contained exe
Build Web UI Build SvelteKit bundle
Publish WebGateway Publish ASP.NET host with embedded web UI
Build Tauri Desktop App Build desktop exe (no installer)
Full Release Build ✦ Sidecar → Web → WebGateway → Tauri, in order
...
SYSTEM
⬡ Toolchain management python / node
⚙ Edit environment variables
✗ Quit
```
---
## Current Features (v0.1)
### Build Target Runner
- Menu driven by `devtool.json` — add/remove targets without touching SDT code
- **Dependency resolution**: targets declare `dependsOn`; SDT topologically sorts them and runs each step exactly once. Select `webgateway` and `web` runs first automatically. Select `all` and everything runs in the right order.
- **Live output streaming**: stdout in phosphor green, stderr in amber — you see the build as it happens
- Each step reports exit code and elapsed time
### Environment Variable Editor
- All configurable env vars declared in `devtool.json`
- Dropdown selection for known options (e.g. `none`/`python-sidecar` for AI provider)
- Free-text input for path overrides
- Changes apply to SDT's process environment for the current session
### Toolchain Management
Python:
- Detect system Python and venv status in one health-check table
- Create / recreate venv (`python -m venv`)
- Install requirements profile — select cpu / gpu / nlp, handles `--extra-index-url` automatically
- Upgrade pip
- Cross-platform venv path resolution (`Scripts/` on Windows, `bin/` on Linux/Mac)
Node / npm:
- Detect Node.js and npm versions
- Check `node_modules` status
- Run `npm install` (or `pnpm`/`yarn` — configurable per project)
### Multi-Project Workspace Switcher
- Place `sdt-workspace.json` in any parent directory listing your projects
- SDT detects it automatically on startup
- **Switch Project** appears in SYSTEM menu when workspace is loaded with more than one project
- Selecting a different project hot-reloads config in-process — no restart needed
- Project table shows current (`►`), path, and whether `devtool.json` exists
---
## Running SDT
From the project root (where `devtool.json` lives):
## Run
```powershell
dotnet run --project Journal.DevTool/Journal.DevTool.csproj
dotnet run --project DevTool.csproj
```
Or via `just`:
Run from any subdirectory inside a project; SDT walks up to find `devtool.json`.
If `devtool.json` is missing, SDT now offers to scan the repo and generate a default config.
Explicit bootstrap command:
```powershell
just sdt
dotnet run --project DevTool.csproj -- init
```
SDT walks up the directory tree from wherever you launch it to find `devtool.json`. You don't need to be in any specific directory.
Bootstrap detects common stacks (`dotnet`, `npm/node`, `python`, `cargo/tauri`, `git`, `docker`) and generates:
---
- default workflows
- toolchain/tooling defaults
- debug profiles + diagnostics defaults
## Config Files
## Config Model
### `devtool.json` (per project)
SDT supports both:
- `workflows` (preferred)
- `targets` (legacy; compat mode only)
### Legacy Migration Mode (v1.2)
- Default: strict mode
- Behavior: `targets`-only config fails early with migration instructions
- Preview file: SDT writes `devtool.generated.workflows.json` for migration help
- Temporary rollback: set `SDT_LEGACY_MODE=compat`
Permanent fix (recommended):
1. Open `devtool.generated.workflows.json`
2. Copy its `workflows` into `devtool.json`
3. Remove or empty legacy `targets`
4. Run `sdt.exe` again in strict mode
### Workflow shape (preferred)
```json
{
"name": "My Project",
"version": "1.0.0",
"toolchains": {
"python": {
"executable": "python3",
"venvDir": ".venv",
"profiles": [
{ "id": "default", "label": "Default", "requirementsFile": "requirements.txt" }
]
},
"node": { "packageManager": "npm", "workingDir": "frontend" }
},
"targets": [
"id": "build",
"label": "Build",
"description": "Build project",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "build",
"label": "Build",
"description": "Build everything",
"group": "Build",
"command": "dotnet",
"args": ["build"],
"id": "dotnet-build",
"label": "dotnet build",
"action": "dotnet-build",
"actionArgs": [],
"workingDir": ".",
"dependsOn": []
}
],
"env": [
{
"key": "MY_VAR",
"description": "Controls something",
"default": "value",
"options": ["value", "other"]
"requires": [
{ "tool": "dotnet", "installPolicy": "Prompt" }
]
}
]
}
```
**Target fields:**
| Field | Description |
|-------|-------------|
| `id` | Unique identifier, referenced by `dependsOn` |
| `label` | Display name in the menu |
| `description` | Short hint shown in the menu |
| `group` | Menu category (BUILD / DEV / TEST / etc.) |
| `command` | Executable to run (`dotnet`, `pwsh`, `npm`, etc.) — `null` for virtual aggregator |
| `args` | Argument array passed to the executable |
| `workingDir` | Working directory relative to project root |
| `dependsOn` | List of target IDs that must run first |
### Extra sections
### `sdt-workspace.json` (per workspace, any parent directory)
- `tooling.tools[].preferredInstallCommands`: preferred install commands per tool
- `tooling.tools[].executables`: explicit executable candidates for non-standard PATH setups
- `project.rootHints`: files/folders that identify project root
- `env`: session-level environment variable editor values
- `debug.profiles[]`: run/attach debug profiles
- `debug.diagnostics`: diagnostics bundle policy (`.sdt/debug` by default)
- secure default: allowlist-only environment capture
- set `includeAllEnv=true` to opt into full environment capture
```json
{
"name": "My Dev Workspace",
"projects": [
{ "name": "Project A", "description": "Does X", "path": "project-a" },
{ "name": "Project B", "description": "Does Y", "path": "../other-repo/project-b" }
]
}
### Workspace Defaults Layering
If SDT finds `sdt-defaults.json` in the project directory tree (current project root or an ancestor), it merges it into the effective config before runtime:
- base layer: `sdt-defaults.json`
- override layer: project `devtool.json` (project values win)
Merge behavior:
- objects merge recursively
- arrays/scalars are replaced when project provides the property
This is useful for shared defaults like toolchains, diagnostics policies, and baseline env definitions across multiple projects in one workspace.
## Execution Behavior
For each workflow step:
1. Resolve dependencies (topological order)
2. Probe required tools
3. If missing, show install commands and prompt (`Prompt` policy)
4. On decline/install failure/step failure, stop immediately
5. Render step summary table with exit code + elapsed time
6. On workflow/debug failure, generate diagnostics bundle when enabled
Installer command precedence:
1. `tooling.tools[].preferredInstallCommands`
2. `scripts/diag.py install-plan`
3. built-in C# fallback templates (used automatically if script planning fails)
When a tool probe fails, SDT now prints probe diagnostics (including command resolution source/path) in run output before prompting for installs.
## Scripts
See [scripts/README.md](/e:/stansshit/csharp/DevTool-master/scripts/README.md).
Primary Python entrypoints:
- `scripts/diag.py`
- `scripts/build.py`
- `scripts/dotnet-min.py`
- `scripts/pip-min.py`
- `scripts/publish-*.py`
## Workspace Support
- Uses `sdt-workspace.json` when present
- If missing, can auto-discover nearby projects containing `devtool.json`
- Workspace screen can add external project roots (absolute paths supported)
- `projects[].disabled`, `projects[].tags`, and `projects[].toolFamilies` are supported
## Dev Shell Bootstrap
Python-first cross-shell dev environment bootstrap:
```powershell
# PowerShell
. ./scripts/dev-shell.ps1
# cmd
scripts\dev-shell.cmd
```
Paths are relative to the `sdt-workspace.json` file. Absolute paths also work.
---
## Project Structure
```
Journal.DevTool/
├── Journal.DevTool.csproj net10.0 exe, outputs as 'sdt'
├── Program.cs Entry point — discovers workspace + project, run loop
├── Config/
│ ├── DevToolConfig.cs devtool.json models (BuildTarget, EnvVarDef, ToolchainConfig)
│ ├── ConfigLoader.cs Walks up dirs to find and parse devtool.json
│ ├── WorkspaceConfig.cs sdt-workspace.json model
│ └── WorkspaceLoader.cs Finds and parses sdt-workspace.json
├── Runner/
│ ├── ProcessRunner.cs Runs a process with live stdout/stderr streaming
│ └── TargetRunner.cs Topological dependency resolver
└── Tui/
├── Theme.cs Phosphor green palette (#00FF41 + amber + red)
├── App.cs Main TUI loop — banner, menu, target runner, env editor
├── ToolchainScreen.cs Python venv/pip + Node/npm management
└── WorkspaceScreen.cs Project switcher
```bash
# bash/zsh
source ./scripts/dev-shell.sh
```
---
Underlying implementation is `scripts/dev_shell.py`:
## Roadmap
- `python scripts/dev_shell.py export --shell pwsh --json`
- `python scripts/dev_shell.py doctor`
### v0.2 — Usability & Polish
- [ ] `--target <id>` flag for non-interactive single-target run (CI use)
- [ ] `--list` flag to print targets and exit
- [ ] Config validation with clear error messages on startup
- [ ] Build history — last run result + timestamp per target (persisted to `.sdt-state.json`)
- [ ] Parallel target execution for independent steps (opt-in per target)
## Legacy PowerShell Compatibility
### v0.3 — Package Manager Depth
- [ ] Full pip environment health: list installed packages, outdated check
- [ ] npm/pnpm/yarn: show scripts from `package.json`, run arbitrary script
- [ ] Python version manager integration (pyenv, py launcher on Windows)
- [ ] Virtual environment activation hint for the current shell
Legacy `.ps1` scripts remain for migration compatibility only. New functionality is implemented in Python scripts first. `script-common.ps1` is legacy-only.
### v0.4 — Multi-Project & Workspace
- [ ] Workspace-level targets that span multiple projects (e.g. build all repos in order)
- [ ] Project dependency graph across workspace (project A's publish feeds project B's build)
- [ ] Workspace health dashboard — all projects' last-build status in one table
Legacy runtime behavior in v1.2:
### v0.5 — Env & Secrets
- [ ] `.env` file load/save support (per project and per workspace)
- [ ] Secret redaction in log output (already in Journal's LogRedactor — port to SDT)
- [ ] Environment profiles: save/load named env var sets (e.g. "dev", "staging")
- strict mode rejects `targets`-only configs by default
- compat mode (`SDT_LEGACY_MODE=compat`) temporarily allows legacy execution
- TUI `SYSTEM` includes `Migrate legacy targets -> workflows` to apply migration in place (with backup)
### v1.0 — Standalone Universal Tool
- [ ] Extract from Journal repo into its own standalone repository
- [ ] Publish as a `dotnet tool` (`dotnet tool install sdt`)
- [ ] Plugin system: projects can register custom SDT commands via `IsdtPlugin`
- [ ] GUI mode (Avalonia or web UI) as an optional launch mode — default CLI, `--gui` for graphical
- [ ] Linux and macOS first-class support (already mostly there — mainly path/exe resolution)
- [ ] JSON schema for `devtool.json` with IDE autocompletion
Deprecation target:
### Long-term Vision
SDT's goal is to be **the single tool you open when you sit down at a project** — regardless of language, framework, or OS. Instead of remembering 15 different CLI commands across `dotnet`, `npm`, `pip`, `cargo`, `just`, and `pwsh`, you open SDT and it knows your project's shape from `devtool.json`.
- v1.x: compatibility only (no new behavior guarantees)
- v2.0: remove legacy `.ps1` scripts from default SDT workflows and docs
The workspace layer means you can manage a portfolio of projects — switching between them, running cross-project builds, and keeping a consistent interface across everything you work on.
## Testing
---
Run unit/integration tests:
## Dependencies
```powershell
dotnet test tests/DevTool.Tests/DevTool.Tests.csproj
```
- [`Spectre.Console`](https://spectreconsole.net/) `0.49.1` — TUI rendering, selection prompts, tables, progress
Run Python script smoke checks:
No dependency on `Journal.Core` — SDT is intentionally standalone so it can be extracted cleanly.
---
## Notes
- SDT does **not** set background colour — it renders on whatever your terminal's background is. A dark terminal is strongly recommended for the phosphor look.
- Environment variable changes made in SDT apply to SDT's own process environment for the session. They are **not** written to your system or shell permanently — by design.
- When a build step fails, SDT stops the plan and does not run subsequent steps.
```powershell
python -m py_compile scripts/*.py
```

View File

@ -0,0 +1,50 @@
# SDT Roadmap / Kanban
## Done (v1.2 Stabilization)
- [x] Python-first runtime for diagnostics/build wrappers
- [x] Config bootstrap generator for missing `devtool.json`
- [x] Workflow-first execution + dual schema support
- [x] Strict legacy mode default (`targets`-only blocked)
- [x] Compatibility escape hatch (`SDT_LEGACY_MODE=compat`)
- [x] Auto-generated migration preview (`devtool.generated.workflows.json`)
- [x] In-app migration action: `Migrate legacy targets -> workflows`
- [x] Centralized requirement inference (`RequirementResolver`)
- [x] Installer planning precedence + fallback resilience
- [x] Windows resolver hardening (`%VAR%` PATH expansion)
- [x] Resolver tracing surfaced in probe details
- [x] Secure diagnostics default (allowlist-only env capture)
- [x] Workspace external project add flow
- [x] Shared run event stream (`RunEvent`) across workflow + debug execution
- [x] TUI event rendering layer wired on top of core run events (GUI-readiness slice)
- [x] Persist run-event stream to JSONL for external GUI/client consumption (`.sdt/events/*.jsonl`)
- [x] TUI events viewer for persisted run-event logs (`SYSTEM -> View run events`)
- [x] Config doctor (`SYSTEM -> Run config doctor`)
- [x] Doctor autofix actions (create missing working dirs + invoke legacy migration)
- [x] Rich probe diagnostics panel in workflow failure summary
- [x] Enhanced Tauri fallback guidance (Windows/macOS/Linux package manager aware)
- [x] Workspace-level defaults file layering (`sdt-defaults.json`) above per-project `devtool.json`
## In Progress (next focus)
- [x] Add dedicated TUI "Events" viewer for last run
- [x] Add doctor autofix actions for common issues (missing dirs, legacy schema migration)
## Next (v1.3 candidates)
- [ ] Setup wizard for first run (bootstrap + tool fixes)
- [ ] Env profiles (`dev`, `ci`, `release`) with deterministic merge order
- [ ] Managed secrets redaction policy for diagnostics bundles
- [ ] Favorites/quick actions across projects
## Later
- [ ] JSON event stream for GUI integration
- [ ] Native GUI shell over headless core services
- [ ] Remove legacy PowerShell wrappers in v2
## Current Milestone Status
- Robustness Sprint v1.2: **complete**
- v1.1 expansion items shipped partially (debug profiles + diagnostics + workspace add external)
- Remaining gaps for broader AIO vision are mainly UX/workspace scale and advanced env orchestration

View File

@ -18,11 +18,12 @@ public static class ProcessRunner
IEnumerable<string> args,
string workingDir,
Action<string, bool> onOutput,
IReadOnlyDictionary<string, string>? envOverrides = null,
CancellationToken cancellationToken = default)
{
var psi = new ProcessStartInfo
{
FileName = command,
FileName = Core.CommandResolver.Resolve(command),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
@ -30,6 +31,12 @@ public static class ProcessRunner
WorkingDirectory = workingDir,
};
if (envOverrides is not null)
{
foreach (var kvp in envOverrides)
psi.Environment[kvp.Key] = kvp.Value;
}
foreach (var arg in args)
psi.ArgumentList.Add(arg);

View File

@ -1,28 +1,65 @@
using Sdt.Config;
using Sdt.Runner;
using Sdt.Core;
using Sdt.Core.Debug;
using Spectre.Console;
namespace Sdt.Tui;
/// <summary>Thin wrapper used in Spectre.Console selection prompts.</summary>
internal sealed record MenuItem(string Display, string Value);
public enum AppExitReason { Quit, SwitchProject }
public sealed record AppResult(AppExitReason Reason, string? NewProjectRoot = null);
public sealed class App(
DevToolConfig config,
string projectRoot,
WorkspaceConfig? workspace = null,
string? workspaceRoot = null)
public sealed class App
{
private DevToolConfig _config = config;
private string _projectRoot = projectRoot;
private readonly WorkspaceConfig? _workspace = workspace;
private readonly string? _workspaceRoot = workspaceRoot;
private DevToolConfig _config;
private string _projectRoot;
private readonly WorkspaceConfig? _workspace;
private readonly string? _workspaceRoot;
private List<WorkflowDefinition> _workflows;
private readonly List<string> _warnings;
private readonly IDebugProfileRunner _debugRunner;
private readonly IDiagnosticsBundleService _diagnostics;
private readonly IRequirementResolver _requirementResolver = new RequirementResolver();
private IReadOnlyDictionary<string, BuildTarget> TargetMap =>
_config.Targets.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
private readonly WorkflowExecutor _executor = new(
new WorkflowPlanner(),
new ToolProbeService(),
new PrereqInstallerService(),
new ActionRunner(),
new RequirementResolver());
private IReadOnlyDictionary<string, WorkflowDefinition> WorkflowMap =>
_workflows.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
public App(
DevToolConfig config,
string projectRoot,
IReadOnlyList<string>? warnings = null,
WorkspaceConfig? workspace = null,
string? workspaceRoot = null)
{
_config = config;
_projectRoot = projectRoot;
_workspace = workspace;
_workspaceRoot = workspaceRoot;
var normalized = WorkflowModelBuilder.Normalize(config, ResolveLegacyMode(), _requirementResolver);
_workflows = normalized.Workflows.ToList();
_warnings = [];
_debugRunner = new DebugProfileRunner(new ToolProbeService(), new PrereqInstallerService());
_diagnostics = new DiagnosticsBundleService();
if (warnings is not null)
_warnings.AddRange(warnings);
_warnings.AddRange(normalized.Warnings);
}
private static LegacyMode ResolveLegacyMode()
{
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
return string.Equals(raw, "compat", StringComparison.OrdinalIgnoreCase)
? LegacyMode.Compat
: LegacyMode.Strict;
}
public async Task<AppResult> RunAsync()
{
@ -31,8 +68,14 @@ public sealed class App(
AnsiConsole.Clear();
RenderBanner();
var choice = ShowMainMenu();
if (_warnings.Count > 0)
{
foreach (var warning in _warnings.Distinct(StringComparer.OrdinalIgnoreCase))
AnsiConsole.MarkupLine(Theme.Warn("Config warning: " + warning));
AnsiConsole.WriteLine();
}
var choice = ShowMainMenu();
switch (choice)
{
case "__env__":
@ -43,6 +86,14 @@ public sealed class App(
await new ToolchainScreen(_config, _projectRoot).RunAsync();
break;
case "__doctor__":
await RunConfigDoctorAsync();
break;
case "__events__":
new EventsScreen(_projectRoot, _config.Name, _config.Version).Run();
break;
case "__workspace__":
if (_workspace is not null && _workspaceRoot is not null)
{
@ -53,14 +104,33 @@ public sealed class App(
}
break;
case "__migrate_legacy__":
ApplyLegacyMigration();
break;
case "__quit__":
AnsiConsole.MarkupLine("\n" + Theme.Faint("Later.") + "\n");
return new AppResult(AppExitReason.Quit);
default:
var targetMap = TargetMap;
if (targetMap.TryGetValue(choice, out var target))
await RunTargetAsync(target, targetMap);
if (choice.StartsWith("__debugattach__:", StringComparison.Ordinal))
{
var profileId = choice["__debugattach__:".Length..];
ShowAttachInstructions(profileId);
break;
}
if (choice.StartsWith("__debug__:", StringComparison.Ordinal))
{
var parts = choice.Split(':', 3, StringSplitOptions.None);
if (parts.Length == 3)
await RunDebugProfileAsync(parts[1], string.Equals(parts[2], "verbose", StringComparison.OrdinalIgnoreCase));
break;
}
var workflowMap = WorkflowMap;
if (workflowMap.TryGetValue(choice, out var workflow))
await RunWorkflowAsync(workflow, workflowMap);
break;
}
@ -72,14 +142,9 @@ public sealed class App(
}
}
// ── Banner ────────────────────────────────────────────────────────────────
private void RenderBanner()
{
// Phosphor green figlet
AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor));
// Project + workspace info line
var wsInfo = _workspace is not null
? $" [{Theme.GreenDim}]∙ {Markup.Escape(_workspace.Name)}[/]"
: string.Empty;
@ -87,12 +152,9 @@ public sealed class App(
AnsiConsole.Write(
new Rule($"[bold {Theme.GreenBold}]{Markup.Escape(_config.Name)}[/] [{Theme.GreenDim}]v{Markup.Escape(_config.Version)}[/]{wsInfo}")
.RuleStyle(Theme.DimStyle));
AnsiConsole.MarkupLine(Theme.Faint($"root: {_projectRoot}") + "\n");
}
// ── Main menu ─────────────────────────────────────────────────────────────
private string ShowMainMenu()
{
var prompt = new SelectionPrompt<MenuItem>()
@ -101,8 +163,7 @@ public sealed class App(
.MoreChoicesText(Theme.Faint("(scroll to see more)"))
.UseConverter(m => m.Display);
// Targets, grouped
var groups = _config.Targets
var groups = _workflows
.Where(t => !string.IsNullOrWhiteSpace(t.Label))
.GroupBy(t => string.IsNullOrWhiteSpace(t.Group) ? "General" : t.Group);
@ -119,9 +180,35 @@ public sealed class App(
prompt.AddChoiceGroup(header, items);
}
// System actions
var debugProfiles = _config.Debug?.Profiles ?? [];
if (debugProfiles.Count > 0)
{
var debugItems = new List<MenuItem>();
foreach (var profile in debugProfiles)
{
debugItems.Add(new MenuItem(
$"[{Theme.Green}]Run {Markup.Escape(profile.Label)}[/] [{Theme.GreenDim}]debug profile[/]",
$"__debug__:{profile.Id}:normal"));
debugItems.Add(new MenuItem(
$"[{Theme.Green}]Run {Markup.Escape(profile.Label)} (verbose)[/] [{Theme.GreenDim}]stream full output[/]",
$"__debug__:{profile.Id}:verbose"));
if (profile.Attach is not null)
{
debugItems.Add(new MenuItem(
$"[{Theme.Green}]Attach instructions: {Markup.Escape(profile.Label)}[/]",
$"__debugattach__:{profile.Id}"));
}
}
prompt.AddChoiceGroup(
new MenuItem($"[bold {Theme.Amber}]DEBUG[/]", "__group__"),
debugItems);
}
var systemItems = new List<MenuItem>
{
new($"[{Theme.Green}]🩺 Run config doctor[/] [{Theme.GreenDim}]validate config, tools, paths[/]", "__doctor__"),
new($"[{Theme.Green}]📜 View run events[/] [{Theme.GreenDim}].sdt/events JSONL viewer[/]", "__events__"),
new($"[{Theme.Green}]⚙ Edit environment variables[/]", "__env__"),
};
@ -130,11 +217,16 @@ public sealed class App(
$"[{Theme.Green}]⬡ Toolchain management[/] [{Theme.GreenDim}]python / node[/]",
"__toolchains__"));
if (_workspace is not null && (_workspace.Projects.Count > 1))
if (_workspace is not null)
systemItems.Insert(0, new MenuItem(
$"[{Theme.Green}]⇄ Switch project[/] [{Theme.GreenDim}]{Markup.Escape(_workspace.Name)}[/]",
$"[{Theme.Green}]⇄ Workspace projects[/] [{Theme.GreenDim}]{Markup.Escape(_workspace.Name)}[/]",
"__workspace__"));
if (_config.Targets.Count > 0)
systemItems.Insert(0, new MenuItem(
$"[{Theme.Green}]⇪ Migrate legacy targets → workflows[/] [{Theme.GreenDim}]writes devtool.json + backup[/]",
"__migrate_legacy__"));
systemItems.Add(new MenuItem($"[{Theme.GreenDim}]✗ Quit[/]", "__quit__"));
prompt.AddChoiceGroup(
@ -144,84 +236,489 @@ public sealed class App(
return AnsiConsole.Prompt(prompt).Value;
}
// ── Target execution ──────────────────────────────────────────────────────
private async Task RunTargetAsync(BuildTarget target, IReadOnlyDictionary<string, BuildTarget> targetMap)
private async Task RunWorkflowAsync(
WorkflowDefinition workflow,
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap)
{
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule(target.Label));
var plan = TargetRunner.ResolvePlan(target, targetMap);
AnsiConsole.Write(Theme.SectionRule(workflow.Label));
var plan = new WorkflowPlanner().ResolvePlan(workflow, workflowMap);
if (plan.Count == 0)
{
AnsiConsole.MarkupLine(Theme.Warn("This target has no executable steps."));
AnsiConsole.MarkupLine(Theme.Warn("This workflow has no executable steps."));
return;
}
if (plan.Count > 1)
{
AnsiConsole.MarkupLine(Theme.Faint($"Execution plan — {plan.Count} steps:"));
foreach (var step in plan)
AnsiConsole.MarkupLine($" [{Theme.GreenDim}]→[/] [{Theme.Green}]{Markup.Escape(step.Label)}[/]");
AnsiConsole.MarkupLine(Theme.Faint($"Execution plan — {plan.Count} workflow(s):"));
foreach (var item in plan)
AnsiConsole.MarkupLine($" [{Theme.GreenDim}]→[/] [{Theme.Green}]{Markup.Escape(item.Label)}[/]");
AnsiConsole.WriteLine();
}
var allOk = true;
var totalSw = System.Diagnostics.Stopwatch.StartNew();
var outputLines = new List<string>();
using var eventRecorder = RunEventJsonlRecorder.Create(_projectRoot, "workflow");
foreach (var step in plan)
{
AnsiConsole.Write(Theme.DimRule());
var workingDir = Path.GetFullPath(Path.Combine(_projectRoot, step.WorkingDir));
var cmdDisplay = $"{step.Command} {string.Join(" ", step.Args)}";
AnsiConsole.MarkupLine($"[{Theme.GreenDim}]$ {Markup.Escape(cmdDisplay)}[/]");
AnsiConsole.MarkupLine($"[{Theme.GreenDim}] {Markup.Escape(workingDir)}[/]\n");
RunResult result;
try
var result = await _executor.ExecuteAsync(
workflow,
workflowMap,
_config,
_projectRoot,
confirmInstallAsync: ConfirmInstallAsync,
onOutput: (line, isErr) =>
{
result = await ProcessRunner.RunAsync(
step.Command!,
step.Args,
workingDir,
(line, isErr) =>
{
var escaped = Markup.Escape(line);
AnsiConsole.MarkupLine(isErr
? $"[{Theme.Amber}]{escaped}[/]"
: $"[{Theme.Green}]{escaped}[/]");
});
}
catch (Exception ex)
outputLines.Add((isErr ? "ERR: " : "OUT: ") + line);
var escaped = Markup.Escape(line);
AnsiConsole.MarkupLine(isErr
? $"[{Theme.Amber}]{escaped}[/]"
: $"[{Theme.Green}]{escaped}[/]");
},
onEvent: evt =>
{
AnsiConsole.MarkupLine("\n" + Theme.Fail($"Failed to launch: {ex.Message}"));
allOk = false;
break;
}
eventRecorder.Write(evt);
RenderRunEvent(evt);
});
AnsiConsole.WriteLine();
if (result.Success)
AnsiConsole.MarkupLine(Theme.Ok($"{step.Label} ({result.Elapsed.TotalSeconds:F1}s)"));
else
{
AnsiConsole.MarkupLine(Theme.Fail($"{step.Label} — exited {result.ExitCode} ({result.Elapsed.TotalSeconds:F1}s)"));
allOk = false;
break;
}
}
totalSw.Stop();
AnsiConsole.Write(Theme.SectionRule());
AnsiConsole.MarkupLine(allOk
? "\n" + Theme.Ok($"Done! Total: {totalSw.Elapsed.TotalSeconds:F1}s")
: "\n" + Theme.Fail("Build failed. Check output above."));
RenderStepSummary(result);
RenderProbeDiagnosticsSummary(outputLines);
if (result.Success)
{
var totalSeconds = result.Steps.Sum(s => s.Result.Elapsed.TotalSeconds);
AnsiConsole.MarkupLine("\n" + Theme.Ok($"Done! Total: {totalSeconds:F1}s"));
AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}"));
return;
}
AnsiConsole.MarkupLine("\n" + Theme.Fail($"{result.StopReason}: {result.Message}"));
AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}"));
await WriteWorkflowDiagnosticsAsync(workflow, workflowMap, result, outputLines);
}
// ── Environment editor ────────────────────────────────────────────────────
private static void RenderStepSummary(WorkflowExecutionResult result)
{
if (result.Steps.Count == 0)
return;
var table = new Table()
.Border(TableBorder.Rounded)
.BorderStyle(Theme.DimStyle)
.AddColumn(new TableColumn($"[{Theme.Amber}]Workflow[/]"))
.AddColumn(new TableColumn($"[{Theme.Amber}]Step[/]"))
.AddColumn(new TableColumn($"[{Theme.Amber}]Exit[/]").Width(8))
.AddColumn(new TableColumn($"[{Theme.Amber}]Seconds[/]").Width(10))
.AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(12));
foreach (var step in result.Steps)
{
var ok = step.Result.Success;
table.AddRow(
Theme.Faint(step.WorkflowId),
Theme.G(step.StepLabel),
Theme.Bold(step.Result.ExitCode.ToString()),
Theme.Faint($"{step.Result.Elapsed.TotalSeconds:F1}"),
ok ? Theme.Ok("ok") : Theme.Fail("failed"));
}
AnsiConsole.Write(table);
}
private async Task RunDebugProfileAsync(string profileId, bool verbose)
{
var profile = _config.Debug?.Profiles.FirstOrDefault(p => string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase));
if (profile is null)
{
AnsiConsole.MarkupLine(Theme.Fail($"Debug profile not found: {profileId}"));
return;
}
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule($"DEBUG — {profile.Label}"));
using var eventRecorder = RunEventJsonlRecorder.Create(_projectRoot, "debug");
if (profile.Attach is not null)
{
AnsiConsole.MarkupLine(Theme.Faint($"Attach: {profile.Attach.Kind}"));
if (profile.Attach.Port is not null)
AnsiConsole.MarkupLine(Theme.Faint($"Port: {profile.Attach.Port}"));
if (!string.IsNullOrWhiteSpace(profile.Attach.ProcessName))
AnsiConsole.MarkupLine(Theme.Faint($"Process: {profile.Attach.ProcessName}"));
if (!string.IsNullOrWhiteSpace(profile.Attach.Note))
AnsiConsole.MarkupLine(Theme.Faint(profile.Attach.Note!));
AnsiConsole.WriteLine();
}
var result = await _debugRunner.RunAsync(
profile,
_config,
_projectRoot,
verbose,
ConfirmInstallAsync,
(line, isErr) =>
{
var escaped = Markup.Escape(line);
AnsiConsole.MarkupLine(isErr
? $"[{Theme.Amber}]{escaped}[/]"
: $"[{Theme.Green}]{escaped}[/]");
},
evt =>
{
eventRecorder.Write(evt);
RenderRunEvent(evt);
});
if (result.Success)
{
var seconds = result.RunResult?.Elapsed.TotalSeconds ?? 0;
AnsiConsole.MarkupLine("\n" + Theme.Ok($"Debug run completed in {seconds:F1}s"));
AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}"));
return;
}
AnsiConsole.MarkupLine("\n" + Theme.Fail($"{result.StopReason}: {result.Message}"));
AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}"));
var diagnostics = _config.Debug?.Diagnostics ?? new DebugDiagnosticsOptions();
if (!diagnostics.Enabled || !diagnostics.BundleOnFailure)
return;
var bundle = await _diagnostics.WriteBundleAsync(
new DiagnosticsBundleRequest(
Category: "debug",
ProjectRoot: _projectRoot,
SummaryMessage: result.Message,
OutputLines: result.OutputLines,
WorkflowSteps: [],
Probes: result.Probes,
DiagnosticsOptions: diagnostics,
Config: _config,
StopReason: result.StopReason,
DebugRun: result.RunResult,
DebugProfile: result.Profile));
if (bundle.Success)
AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle: {bundle.BundleDirectory}"));
else
AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle failed: {bundle.Message}"));
}
private static void RenderProbeDiagnosticsSummary(IReadOnlyList<string> outputLines)
{
var diagnostics = new List<(string Tool, string Detail)>();
foreach (var line in outputLines)
{
const string marker = "Probe detail [";
var markerIndex = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0)
continue;
var toolStart = markerIndex + marker.Length;
var toolEnd = line.IndexOf(']', toolStart);
if (toolEnd <= toolStart)
continue;
var tool = line[toolStart..toolEnd].Trim();
var detailStart = line.IndexOf(':', toolEnd);
var detail = detailStart >= 0 && detailStart + 1 < line.Length
? line[(detailStart + 1)..].Trim()
: "";
if (!string.IsNullOrWhiteSpace(tool))
diagnostics.Add((tool, detail));
}
if (diagnostics.Count == 0)
return;
var table = new Table()
.Border(TableBorder.Rounded)
.BorderStyle(Theme.DimStyle)
.AddColumn(new TableColumn($"[{Theme.Amber}]Probe Tool[/]").Width(14))
.AddColumn(new TableColumn($"[{Theme.Amber}]Resolver / Probe Detail[/]"))
.AddColumn(new TableColumn($"[{Theme.Amber}]Suggested Fix[/]"));
foreach (var diag in diagnostics.Distinct())
{
table.AddRow(
Theme.Warn(diag.Tool),
Theme.Faint(diag.Detail),
Theme.Faint(GetProbeFixHint(diag.Tool, diag.Detail)));
}
AnsiConsole.WriteLine();
AnsiConsole.Write(Theme.SectionRule("PROBE DIAGNOSTICS"));
AnsiConsole.Write(table);
}
private static string GetProbeFixHint(string tool, string detail)
{
if (detail.Contains("ConfiguredOverride", StringComparison.OrdinalIgnoreCase))
return "Check tooling.tools[].executables paths.";
if (detail.Contains("NodeAdjacentShim", StringComparison.OrdinalIgnoreCase))
return "Node found; verify npm/yarn shim in node directory.";
if (detail.Contains("Fallback", StringComparison.OrdinalIgnoreCase))
return $"Add {tool} to PATH or set tooling.tools[].executables.";
return $"Install/configure {tool} then rerun.";
}
private static void RenderRunEvent(RunEvent evt)
{
var shouldRender = evt.Type is
RunEventType.WorkflowStarted or
RunEventType.WorkflowStepStarted or
RunEventType.WorkflowStepCompleted or
RunEventType.ProbeFailed or
RunEventType.InstallPlanPrepared or
RunEventType.DebugStarted or
RunEventType.DebugCommandStarted or
RunEventType.DebugCommandCompleted or
RunEventType.WorkflowCompleted or
RunEventType.DebugCompleted;
if (!shouldRender)
return;
var prefix = evt.Category.Equals("debug", StringComparison.OrdinalIgnoreCase) ? "DBG" : "RUN";
var tone = evt.Success is false ? Theme.Amber : Theme.GreenDim;
AnsiConsole.MarkupLine($"[{tone}]{prefix} {Markup.Escape(evt.Message)}[/]");
}
private void ShowAttachInstructions(string profileId)
{
var profile = _config.Debug?.Profiles.FirstOrDefault(p => string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase));
if (profile?.Attach is null)
{
AnsiConsole.MarkupLine(Theme.Warn("No attach instructions configured for this profile."));
return;
}
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule($"ATTACH — {profile.Label}"));
AnsiConsole.MarkupLine(Theme.Faint($"Kind: {profile.Attach.Kind}"));
if (profile.Attach.Port is not null)
AnsiConsole.MarkupLine(Theme.Faint($"Port: {profile.Attach.Port}"));
if (!string.IsNullOrWhiteSpace(profile.Attach.ProcessName))
AnsiConsole.MarkupLine(Theme.Faint($"Process: {profile.Attach.ProcessName}"));
if (!string.IsNullOrWhiteSpace(profile.Attach.Note))
AnsiConsole.MarkupLine(Theme.G(profile.Attach.Note!));
}
private async Task WriteWorkflowDiagnosticsAsync(
WorkflowDefinition workflow,
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap,
WorkflowExecutionResult result,
IReadOnlyList<string> outputLines)
{
var diagnostics = _config.Debug?.Diagnostics ?? new DebugDiagnosticsOptions();
if (!diagnostics.Enabled || !diagnostics.BundleOnFailure)
return;
var probes = await SnapshotWorkflowToolsAsync(workflow, workflowMap);
var bundle = await _diagnostics.WriteBundleAsync(
new DiagnosticsBundleRequest(
Category: "workflow",
ProjectRoot: _projectRoot,
SummaryMessage: result.Message,
OutputLines: outputLines,
WorkflowSteps: result.Steps,
Probes: probes,
DiagnosticsOptions: diagnostics,
Config: _config,
StopReason: result.StopReason));
if (bundle.Success)
AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle: {bundle.BundleDirectory}"));
else
AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle failed: {bundle.Message}"));
}
private async Task<IReadOnlyList<ProbeResult>> SnapshotWorkflowToolsAsync(
WorkflowDefinition workflow,
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap)
{
var plan = new WorkflowPlanner().ResolvePlan(workflow, workflowMap);
var tools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in plan)
{
foreach (var step in item.Steps)
{
foreach (var req in _requirementResolver.Resolve(step))
{
if (!string.IsNullOrWhiteSpace(req.Tool))
tools.Add(req.Tool);
}
}
}
var probeService = new ToolProbeService();
var probes = new List<ProbeResult>();
foreach (var tool in tools)
probes.Add(await probeService.ProbeAsync(tool, _projectRoot, _config));
return probes;
}
private Task<bool> ConfirmInstallAsync(string tool, InstallPlan plan)
{
AnsiConsole.MarkupLine(Theme.Warn($"Missing prerequisite: {tool}"));
AnsiConsole.MarkupLine(Theme.Faint(plan.Summary));
foreach (var cmd in plan.Commands)
AnsiConsole.MarkupLine(Theme.Faint($" $ {cmd.Command} {string.Join(" ", cmd.Args)}"));
var allow = AnsiConsole.Confirm(
$"[{Theme.Amber}]Run install commands for {Markup.Escape(tool)} now?[/]",
defaultValue: false);
return Task.FromResult(allow);
}
private async Task RunConfigDoctorAsync()
{
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule("CONFIG DOCTOR"));
AnsiConsole.WriteLine();
var service = new ConfigDoctorService(new ToolProbeService(), _requirementResolver);
var report = await service.RunAsync(_config, _projectRoot);
var table = new Table()
.Border(TableBorder.Rounded)
.BorderStyle(Theme.DimStyle)
.AddColumn(new TableColumn($"[{Theme.Amber}]Check[/]").Width(26))
.AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(10))
.AddColumn(new TableColumn($"[{Theme.Amber}]Detail[/]"))
.AddColumn(new TableColumn($"[{Theme.Amber}]Fix[/]"));
foreach (var check in report.Checks)
{
var statusText = check.Status switch
{
DoctorStatus.Pass => Theme.Ok("ok"),
DoctorStatus.Warn => Theme.Warn("warn"),
DoctorStatus.Fail => Theme.Fail("fail"),
_ => Theme.Faint("n/a"),
};
table.AddRow(
Theme.G(check.Name),
statusText,
Theme.Faint(check.Detail),
string.IsNullOrWhiteSpace(check.Fix) ? Theme.Faint("-") : Theme.Faint(check.Fix));
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
if (report.HasFailures)
AnsiConsole.MarkupLine(Theme.Fail("Doctor found blocking issues."));
else if (report.HasWarnings)
AnsiConsole.MarkupLine(Theme.Warn("Doctor completed with warnings."));
else
AnsiConsole.MarkupLine(Theme.Ok("Doctor completed: no issues found."));
if (!report.HasFailures && !report.HasWarnings)
return;
var applyFixes = AnsiConsole.Confirm(
$"[{Theme.Amber}]Apply common autofixes now?[/]",
defaultValue: false);
if (!applyFixes)
return;
var fixer = new ConfigDoctorAutoFixService();
var missingDirs = fixer.FindMissingWorkingDirectories(_config, _projectRoot);
if (missingDirs.Count > 0)
{
var createDirs = AnsiConsole.Confirm(
$"[{Theme.Amber}]Create {missingDirs.Count} missing working director{(missingDirs.Count == 1 ? "y" : "ies")}?[/]",
defaultValue: true);
if (createDirs)
{
var dirFix = fixer.CreateMissingWorkingDirectories(missingDirs);
AnsiConsole.MarkupLine(dirFix.Success ? Theme.Ok(dirFix.Message) : Theme.Fail(dirFix.Message));
}
}
if (_config.Targets.Count > 0)
{
var migrate = AnsiConsole.Confirm(
$"[{Theme.Amber}]Migrate legacy targets to workflows now?[/]",
defaultValue: true);
if (migrate)
{
var migration = fixer.ApplyLegacyMigration(_projectRoot);
if (migration.Success)
{
AnsiConsole.MarkupLine(Theme.Ok("Legacy migration applied from doctor."));
if (!string.IsNullOrWhiteSpace(migration.BackupPath))
AnsiConsole.MarkupLine(Theme.Faint($"Backup: {migration.BackupPath}"));
var reloaded = ConfigLoader.FindAndLoad(_projectRoot);
if (reloaded is not null)
{
_config = reloaded.Config;
var normalized = WorkflowModelBuilder.Normalize(_config, ResolveLegacyMode(), _requirementResolver);
_workflows = normalized.Workflows.ToList();
_warnings.Clear();
_warnings.AddRange(reloaded.Warnings);
_warnings.AddRange(normalized.Warnings);
}
}
else
{
AnsiConsole.MarkupLine(Theme.Fail(migration.Message));
}
}
}
}
private void ApplyLegacyMigration()
{
if (_config.Targets.Count == 0)
{
AnsiConsole.MarkupLine(Theme.Warn("No legacy targets found in this config."));
return;
}
var configPath = ConfigLoader.FindConfigPath(_projectRoot);
if (string.IsNullOrWhiteSpace(configPath))
{
AnsiConsole.MarkupLine(Theme.Fail("Could not locate devtool.json to migrate."));
return;
}
var confirm = AnsiConsole.Confirm(
$"[{Theme.Amber}]Migrate legacy targets to workflows and overwrite devtool.json (with backup)?[/]",
defaultValue: true);
if (!confirm)
return;
var result = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
if (!result.Success)
{
AnsiConsole.MarkupLine(Theme.Fail(result.Message));
return;
}
var reloaded = ConfigLoader.FindAndLoad(_projectRoot);
if (reloaded is null)
{
AnsiConsole.MarkupLine(Theme.Fail("Migration wrote config, but reload failed."));
return;
}
_config = reloaded.Config;
var normalized = WorkflowModelBuilder.Normalize(_config, ResolveLegacyMode(), _requirementResolver);
_workflows = normalized.Workflows.ToList();
_warnings.Clear();
_warnings.AddRange(reloaded.Warnings);
_warnings.AddRange(normalized.Warnings);
AnsiConsole.MarkupLine(Theme.Ok("Migration complete."));
if (!string.IsNullOrWhiteSpace(result.BackupPath))
AnsiConsole.MarkupLine(Theme.Faint($"Backup: {result.BackupPath}"));
}
private void EditEnvironment()
{

View File

@ -0,0 +1,126 @@
using Sdt.Core;
using Spectre.Console;
namespace Sdt.Tui;
public sealed class EventsScreen(string projectRoot, string projectName, string version)
{
private readonly string _projectRoot = projectRoot;
private readonly string _projectName = projectName;
private readonly string _version = version;
private readonly RunEventLogReader _reader = new();
public void Run()
{
while (true)
{
AnsiConsole.Clear();
RenderHeader("EVENTS");
var files = _reader.ListEventFiles(_projectRoot);
if (files.Count == 0)
{
AnsiConsole.MarkupLine(Theme.Warn("No event logs found yet."));
AnsiConsole.MarkupLine(Theme.Faint("Run a workflow/debug action first, then return here."));
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
Console.ReadKey(intercept: true);
return;
}
var table = new Table()
.Border(TableBorder.Rounded)
.BorderStyle(Theme.DimStyle)
.AddColumn(new TableColumn($"[{Theme.Amber}]File[/]"))
.AddColumn(new TableColumn($"[{Theme.Amber}]Updated[/]").Width(21))
.AddColumn(new TableColumn($"[{Theme.Amber}]Size[/]").Width(10));
foreach (var file in files.Take(12))
{
table.AddRow(
Theme.G(file.Name),
Theme.Faint(file.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss")),
Theme.Faint($"{Math.Max(1, file.SizeBytes / 1024)} KB"));
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
var choices = files
.Take(20)
.Select(f => new MenuItem(
$"[{Theme.Green}]{Markup.Escape(f.Name)}[/] [{Theme.GreenDim}]{f.LastWriteTime:yyyy-MM-dd HH:mm:ss}[/]",
f.Path))
.ToList();
choices.Add(new MenuItem(Theme.Faint("← Back"), "__back__"));
var selected = AnsiConsole.Prompt(
new SelectionPrompt<MenuItem>()
.Title($"[{Theme.Green}]Select an event log to view:[/]")
.PageSize(20)
.UseConverter(m => m.Display)
.AddChoices(choices));
if (selected.Value == "__back__")
return;
ShowEventFile(selected.Value);
}
}
private void ShowEventFile(string filePath)
{
var events = _reader.ReadEvents(filePath);
AnsiConsole.Clear();
RenderHeader("EVENTS VIEWER");
AnsiConsole.MarkupLine(Theme.Faint(Path.GetFileName(filePath)));
AnsiConsole.MarkupLine(Theme.Faint(filePath));
AnsiConsole.WriteLine();
if (events.Count == 0)
{
AnsiConsole.MarkupLine(Theme.Warn("No parseable events in selected file."));
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to return..."));
Console.ReadKey(intercept: true);
return;
}
var table = new Table()
.Border(TableBorder.Rounded)
.BorderStyle(Theme.DimStyle)
.AddColumn(new TableColumn($"[{Theme.Amber}]Time[/]").Width(12))
.AddColumn(new TableColumn($"[{Theme.Amber}]Type[/]").Width(26))
.AddColumn(new TableColumn($"[{Theme.Amber}]Message[/]"))
.AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(10));
foreach (var evt in events.TakeLast(120))
{
var status = evt.Success switch
{
true => Theme.Ok("ok"),
false => Theme.Fail("fail"),
null => Theme.Faint("-"),
};
var message = evt.Message;
if (!string.IsNullOrWhiteSpace(evt.Tool))
message += $" [{evt.Tool}]";
if (evt.ExitCode is not null)
message += $" (exit {evt.ExitCode})";
table.AddRow(
Theme.Faint(evt.OccurredAt.ToString("HH:mm:ss")),
Theme.G(evt.Type.ToString()),
Theme.Faint(message),
status);
}
AnsiConsole.Write(table);
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to return..."));
Console.ReadKey(intercept: true);
}
private void RenderHeader(string section)
{
AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor));
AnsiConsole.Write(
new Rule($"[bold {Theme.GreenBold}]{Markup.Escape(_projectName)}[/] [{Theme.GreenDim}]v{Markup.Escape(_version)}[/] [{Theme.Amber}]{Markup.Escape(section)}[/]")
.RuleStyle(Theme.DimStyle));
AnsiConsole.MarkupLine(Theme.Faint($"root: {_projectRoot}") + "\n");
}
}

View File

@ -1,4 +1,5 @@
using Sdt.Config;
using Sdt.Core;
using Sdt.Runner;
using Sdt.Tui;
using Spectre.Console;
@ -178,7 +179,7 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
// Upgrade pip first
AnsiConsole.MarkupLine(Theme.Faint("Upgrading pip..."));
await RunLiveAsync(venvPy, ["-m", "pip", "install", "--upgrade", "pip"], _projectRoot);
await RunPipAsync(py, venvPy, ["install", "--upgrade", "pip"]);
// Build install args
var installArgs = new List<string> { "-m", "pip", "install" };
@ -193,7 +194,7 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(Theme.G($"Installing {profile.Label}..."));
AnsiConsole.WriteLine();
await RunLiveAsync(venvPy, installArgs, _projectRoot);
await RunPipAsync(py, venvPy, installArgs.Skip(2)); // strip leading "-m pip"
// Post-install commands
foreach (var cmd in profile.PostInstallCommands)
@ -211,10 +212,9 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, py.VenvDir));
var venvPy = GetVenvPython(venvPath);
var exe = File.Exists(venvPy) ? venvPy : ResolvePythonExe(py);
AnsiConsole.MarkupLine(Theme.G($"Upgrading pip using: {exe}"));
AnsiConsole.WriteLine();
await RunLiveAsync(exe, ["-m", "pip", "install", "--upgrade", "pip"], _projectRoot);
await RunPipAsync(py, exe, ["install", "--upgrade", "pip"]);
}
// ── Node ──────────────────────────────────────────────────────────────────
@ -278,7 +278,7 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = command,
FileName = CommandResolver.Resolve(command),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
@ -310,4 +310,38 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
? Theme.Ok($"Done ({result.Elapsed.TotalSeconds:F1}s)")
: Theme.Fail($"Exited {result.ExitCode} ({result.Elapsed.TotalSeconds:F1}s)"));
}
private async Task RunPipAsync(PythonToolchain py, string pythonExe, IEnumerable<string> pipArgs)
{
if (!string.IsNullOrWhiteSpace(py.PipScript))
{
var pipScriptPath = ResolvePipScriptPath(py.PipScript);
if (File.Exists(pipScriptPath))
{
var ext = Path.GetExtension(pipScriptPath).ToLowerInvariant();
if (ext == ".py")
{
await RunLiveAsync(ResolvePythonExe(py), [pipScriptPath, .. pipArgs], _projectRoot);
return;
}
AnsiConsole.MarkupLine(Theme.Warn($"Ignoring non-Python pipScript: {pipScriptPath}"));
}
}
await RunLiveAsync(pythonExe, ["-m", "pip", .. pipArgs], _projectRoot);
}
private string ResolvePipScriptPath(string pipScriptConfigPath)
{
if (Path.IsPathRooted(pipScriptConfigPath))
return pipScriptConfigPath;
var fileName = Path.GetFileName(pipScriptConfigPath);
var bundled = ScriptLocator.FindHelperScript(_projectRoot, fileName);
if (bundled is not null)
return bundled;
return Path.GetFullPath(Path.Combine(_projectRoot, pipScriptConfigPath));
}
}

View File

@ -15,86 +15,159 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR
/// </summary>
public string? SelectProject()
{
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule("WORKSPACE — " + _workspace.Name));
AnsiConsole.WriteLine();
var projects = _workspace.Projects;
if (projects.Count == 0)
while (true)
{
AnsiConsole.MarkupLine(Theme.Warn("No projects defined in sdt-workspace.json."));
AnsiConsole.MarkupLine(Theme.Faint("Add entries to the \"projects\" array."));
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
Console.ReadKey(intercept: true);
return null;
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule("WORKSPACE — " + _workspace.Name));
AnsiConsole.WriteLine();
var projects = _workspace.Projects;
if (projects.Count == 0)
{
AnsiConsole.MarkupLine(Theme.Warn("No projects defined in sdt-workspace.json."));
AnsiConsole.MarkupLine(Theme.Faint("Add entries to the \"projects\" array."));
if (AnsiConsole.Confirm($"[{Theme.Amber}]Add an external project now?[/]", defaultValue: true))
{
AddExternalProject();
continue;
}
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
Console.ReadKey(intercept: true);
return null;
}
// Build choice list with current project marked
var choices = new List<WorkspaceMenuItem>();
foreach (var proj in projects)
{
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
var devtoolPath = Path.Combine(absPath, "devtool.json");
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
var exists = File.Exists(devtoolPath);
var label = isCurrent
? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/] [{Theme.GreenDim}](current)[/]"
: $"[{Theme.Green}] {Markup.Escape(proj.Name)}[/]";
if (proj.Disabled)
label += $" [{Theme.Amber}](disabled)[/]";
var desc = !exists
? $" [{Theme.Red}]devtool.json not found[/]"
: string.IsNullOrWhiteSpace(proj.Description)
? $" [{Theme.GreenDim}]{Markup.Escape(absPath)}[/]"
: $" [{Theme.GreenDim}]{Markup.Escape(proj.Description)}[/]";
if (proj.Tags.Count > 0)
desc += $" [{Theme.GreenDim}]tags: {Markup.Escape(string.Join(",", proj.Tags))}[/]";
choices.Add(new WorkspaceMenuItem(label + "\n" + desc, absPath, exists && !isCurrent && !proj.Disabled));
}
choices.Add(new WorkspaceMenuItem($"[{Theme.Green}] Add external project[/]", "__add__", true));
choices.Add(new WorkspaceMenuItem($"[{Theme.GreenDim}]← Cancel[/]", null, true));
// Show project table for overview
var table = new Table()
.Border(TableBorder.Rounded)
.BorderStyle(Theme.DimStyle)
.AddColumn(new TableColumn($"[{Theme.Amber}]Project[/]"))
.AddColumn(new TableColumn($"[{Theme.Amber}]Path[/]"))
.AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(12));
foreach (var proj in projects)
{
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
var hasConfig = File.Exists(Path.Combine(absPath, "devtool.json"));
var status = proj.Disabled
? Theme.Warn("disabled")
: hasConfig ? Theme.Ok("ready") : Theme.Fail("no config");
table.AddRow(
isCurrent
? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/]"
: Theme.G(proj.Name),
Theme.Faint(proj.Path),
status);
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
var switchable = choices.Where(c => c.Selectable).ToList();
if (switchable.Count == 1) // only Cancel
{
AnsiConsole.MarkupLine(Theme.Warn("No other projects available to switch to."));
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
Console.ReadKey(intercept: true);
return null;
}
var selected = AnsiConsole.Prompt(
new SelectionPrompt<WorkspaceMenuItem>()
.Title($"[{Theme.Green}]Switch to project:[/]")
.PageSize(15)
.UseConverter(m => m.Display)
.AddChoices(switchable));
if (selected.AbsPath == "__add__")
{
AddExternalProject();
continue;
}
return selected.AbsPath; // null = cancelled
}
}
private void AddExternalProject()
{
var raw = AnsiConsole.Ask<string>($"[{Theme.Amber}]Project root path[/]");
if (string.IsNullOrWhiteSpace(raw))
return;
var absolutePath = Path.GetFullPath(raw.Trim());
if (!Directory.Exists(absolutePath))
{
AnsiConsole.MarkupLine(Theme.Fail("Directory does not exist."));
Thread.Sleep(700);
return;
}
// Build choice list with current project marked
var choices = new List<WorkspaceMenuItem>();
foreach (var proj in projects)
var configPath = Path.Combine(absolutePath, "devtool.json");
if (!File.Exists(configPath))
{
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
var devtoolPath = Path.Combine(absPath, "devtool.json");
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
var exists = File.Exists(devtoolPath);
var create = AnsiConsole.Confirm(
$"[{Theme.Amber}]No devtool.json found. Create a minimal template?[/]",
defaultValue: true);
if (!create)
return;
var label = isCurrent
? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/] [{Theme.GreenDim}](current)[/]"
: $"[{Theme.Green}] {Markup.Escape(proj.Name)}[/]";
var desc = !exists
? $" [{Theme.Red}]devtool.json not found[/]"
: string.IsNullOrWhiteSpace(proj.Description)
? $" [{Theme.GreenDim}]{Markup.Escape(absPath)}[/]"
: $" [{Theme.GreenDim}]{Markup.Escape(proj.Description)}[/]";
choices.Add(new WorkspaceMenuItem(label + "\n" + desc, absPath, exists && !isCurrent));
File.WriteAllText(configPath, "{\n \"name\": \"SDT Project\",\n \"version\": \"0.1.0\",\n \"workflows\": []\n}\n");
}
choices.Add(new WorkspaceMenuItem($"[{Theme.GreenDim}]← Cancel[/]", null, true));
// Show project table for overview
var table = new Table()
.Border(TableBorder.Rounded)
.BorderStyle(Theme.DimStyle)
.AddColumn(new TableColumn($"[{Theme.Amber}]Project[/]"))
.AddColumn(new TableColumn($"[{Theme.Amber}]Path[/]"))
.AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(12));
foreach (var proj in projects)
if (_workspace.Projects.Any(p =>
string.Equals(WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, p), absolutePath, StringComparison.OrdinalIgnoreCase)))
{
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
var hasConfig = File.Exists(Path.Combine(absPath, "devtool.json"));
table.AddRow(
isCurrent
? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/]"
: Theme.G(proj.Name),
Theme.Faint(proj.Path),
hasConfig ? Theme.Ok("ready") : Theme.Fail("no config"));
AnsiConsole.MarkupLine(Theme.Warn("Project already exists in workspace."));
Thread.Sleep(700);
return;
}
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
var switchable = choices.Where(c => c.Selectable).ToList();
if (switchable.Count == 1) // only Cancel
var relativePath = Path.GetRelativePath(_workspaceRoot, absolutePath);
var useRelative = !relativePath.StartsWith("..", StringComparison.OrdinalIgnoreCase) && !Path.IsPathRooted(relativePath);
var projectEntry = new WorkspaceProject
{
AnsiConsole.MarkupLine(Theme.Warn("No other projects available to switch to."));
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
Console.ReadKey(intercept: true);
return null;
}
Name = new DirectoryInfo(absolutePath).Name,
Description = $"External project at {absolutePath}",
Path = useRelative ? relativePath : absolutePath,
Disabled = false,
};
var selected = AnsiConsole.Prompt(
new SelectionPrompt<WorkspaceMenuItem>()
.Title($"[{Theme.Green}]Switch to project:[/]")
.PageSize(15)
.UseConverter(m => m.Display)
.AddChoices(switchable));
return selected.AbsPath; // null = cancelled
_workspace.Projects.Add(projectEntry);
WorkspaceLoader.Save(_workspaceRoot, _workspace);
AnsiConsole.MarkupLine(Theme.Ok("Project added to workspace."));
Thread.Sleep(700);
}
private sealed record WorkspaceMenuItem(string Display, string? AbsPath, bool Selectable);

31
Journal.DevTool/package-lock.json generated Normal file
View File

@ -0,0 +1,31 @@
{
"name": "DevTool-master",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"tauri-plugin-mic-recorder-api": "^2.0.0"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/tauri-plugin-mic-recorder-api": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tauri-plugin-mic-recorder-api/-/tauri-plugin-mic-recorder-api-2.0.0.tgz",
"integrity": "sha512-04wqYCX4WIlYd6KUY7aS3+W4B5RtnSoVczaQCBSXKpQkEx9XdaaBN05XCee2unxGva0btSXBItFqQSdosnS4jQ==",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
}
}
}
}

View File

@ -0,0 +1,5 @@
{
"dependencies": {
"tauri-plugin-mic-recorder-api": "^2.0.0"
}
}

View File

@ -0,0 +1,63 @@
# Scripts (Python-first, cross-platform)
This folder now uses Python as the default runtime for orchestration and diagnostics.
## Preferred scripts
- `diag.py`: tool probing and install-plan generation (`dotnet`, `python`, `node`, `npm`, `cargo`, `tauri`)
- `build.py`: normalized build actions used by SDT workflows
- `dev_shell.py`: cross-platform shell bootstrap export/doctor helper
- `dotnet-min.py`: resilient `dotnet` wrapper with local cache env
- `pip-min.py`: resilient `pip` wrapper with local cache env and repo-local target default
- `npm-clean.py`: remove `node_modules` cross-platform
- `migration-gate.py`: build/test quality gate
- `nuget-export-cache.py`: archive `.nuget` cache
- `nuget-import-cache.py`: restore `.nuget` cache from archive
- `publish-app.py`: build web or tauri app (cross-platform)
- `publish-sidecar.py`: publish sidecar .NET service
- `publish-webgateway.py`: publish gateway .NET service and optional web assets
- `run-webgateway.py`: run gateway in dev or published-output mode
- `publish-output.py`: orchestrate sidecar/web/gateway/desktop publish steps
- `sync-output.py`: sweep newest build artifacts into `output/`
- `script_common.py`: shared helpers (repo root resolution, env shaping, command runner)
- `project.rootHints` supports glob markers (for example `*.sln`) and directory/file markers (`.git`, `package.json`)
- Windows PATH token expansion (`%NVM_HOME%`, `%NVM_SYMLINK%`, etc.) is applied during command resolution
## Shell bootstrap wrappers
- `dev-shell.ps1`: PowerShell wrapper over `dev_shell.py`
- `dev-shell.sh`: bash/zsh wrapper over `dev_shell.py`
- `dev-shell.cmd`: cmd wrapper over `dev_shell.py`
## Legacy scripts
Existing `.ps1` entrypoints are now compatibility wrappers that forward to Python scripts.
`script-common.ps1` is legacy-only compatibility and not used by active SDT workflows.
Original PowerShell implementations are archived under `scripts/legacy/` as `*.legacy.ps1` for reference during transition.
## Root Hint Semantics
`project.rootHints` is evaluated in this order:
1. Exact marker exists at candidate root (file or directory)
2. Root-level glob match (`glob`)
3. Recursive glob match (`rglob`)
Examples:
- `"*.sln"`
- `".git"`
- `"package.json"`
- `"src-tauri/tauri.conf.json"`
## Quick usage
```powershell
python scripts/diag.py probe --tool dotnet --json
python scripts/dotnet-min.py build
python scripts/migration-gate.py
python scripts/nuget-export-cache.py --output-zip nuget-cache-export.zip
python scripts/nuget-import-cache.py --input-zip nuget-cache-export.zip
python scripts/npm-clean.py --working-dir .
python scripts/dev_shell.py export --shell pwsh --json
python scripts/dev_shell.py doctor
```

View File

@ -0,0 +1,57 @@
# Cross-Platform Script Workflows
## 1) Probe toolchain availability
```powershell
python scripts/diag.py probe --tool dotnet --json
python scripts/diag.py probe --tool python --json
python scripts/diag.py probe --tool node --json
python scripts/diag.py probe --tool npm --json
python scripts/diag.py probe --tool cargo --json
python scripts/diag.py probe --tool tauri --json
python scripts/diag.py probe --tool git --json
python scripts/diag.py probe --tool docker --json
```
## Shell bootstrap (cross-platform)
```powershell
python scripts/dev_shell.py export --shell pwsh --json
python scripts/dev_shell.py doctor
```
## 2) Build and run SDT
```powershell
python scripts/dotnet-min.py build
dotnet run --project DevTool.csproj
```
## 3) Run migration gate
```powershell
python scripts/migration-gate.py
```
## 4) Manage NuGet cache
```powershell
python scripts/nuget-export-cache.py --output-zip nuget-cache-export.zip
python scripts/nuget-import-cache.py --input-zip nuget-cache-export.zip
```
## 5) Clean Node modules
```powershell
python scripts/npm-clean.py --working-dir .
```
## 6) Build app/gateway bundles
```powershell
python scripts/publish-app.py --target web
python scripts/publish-sidecar.py --project path/to/sidecar.csproj
python scripts/publish-webgateway.py --project path/to/gateway.csproj --skip-web-assets
python scripts/publish-output.py --dry-run
python scripts/sync-output.py
```

View File

@ -0,0 +1,39 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Resolve-SdtPython {
$candidates = @('python')
if ($IsWindows) { $candidates += 'py' } else { $candidates += 'python3' }
foreach ($c in $candidates) {
try {
& $c --version *> $null
if ($LASTEXITCODE -eq 0) { return $c }
} catch {}
}
return 'python'
}
function Resolve-SdtScriptPath {
param([Parameter(Mandatory=$true)][string]$ScriptName)
$bundled = Join-Path $PSScriptRoot $ScriptName
if (Test-Path $bundled) { return $bundled }
$project = Join-Path (Join-Path $PSScriptRoot '..') ('scripts\\' + $ScriptName)
if (Test-Path $project) { return (Resolve-Path $project).Path }
throw "Python helper script not found: $ScriptName"
}
function Invoke-SdtPythonScript {
param(
[Parameter(Mandatory=$true)][string]$ScriptName,
[string[]]$ForwardArgs = @()
)
$python = Resolve-SdtPython
$scriptPath = Resolve-SdtScriptPath -ScriptName $ScriptName
& $python $scriptPath @ForwardArgs
exit $LASTEXITCODE
}

View File

@ -0,0 +1,419 @@
#!/usr/bin/env python3
import argparse
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
import time
from script_common import resolve_command
def run_step(command, args, cwd):
resolved = resolve_command(command)
if shutil.which(resolved) is None and not pathlib.Path(resolved).exists():
return {
"command": resolved,
"args": args,
"cwd": cwd,
"exit_code": 127,
"elapsed_seconds": 0.0,
"status": "failed",
"failure_reason": f"command_not_found:{resolved}",
}
started = time.time()
proc = subprocess.run([resolved, *args], cwd=cwd, check=False)
elapsed = round(time.time() - started, 3)
return {
"command": resolved,
"args": args,
"cwd": cwd,
"exit_code": proc.returncode,
"elapsed_seconds": elapsed,
"status": "ok" if proc.returncode == 0 else "failed",
"failure_reason": None if proc.returncode == 0 else f"non_zero_exit:{proc.returncode}",
}
def resolve_python_executable():
candidates = ["py", "python"] if os.name == "nt" else ["python3", "python"]
for c in candidates:
if shutil.which(c):
return c
return "python"
def parse_common(parser):
parser.add_argument("--project-root", required=True)
parser.add_argument("--working-dir", default=".")
parser.add_argument("--json", action="store_true")
def resolve_cwd(project_root, working_dir):
return os.path.abspath(os.path.join(project_root, working_dir))
EXCLUDED_SCAN_DIRS = {".git", "node_modules", "bin", "obj", ".venv", "venv", ".sdt", "dist", "build"}
def discover_dotnet_target(project_root: str, cwd: str):
# Prefer nearest/top-level solution from cwd, then csproj, then bounded scan from project root.
local_sln = sorted(pathlib.Path(cwd).glob("*.sln"))
if len(local_sln) == 1:
return str(local_sln[0])
local_csproj = sorted(pathlib.Path(cwd).glob("*.csproj"))
if len(local_csproj) == 1:
return str(local_csproj[0])
sln_hits = bounded_find_files(project_root, ".sln", max_depth=4)
if len(sln_hits) == 1:
return sln_hits[0]
csproj_hits = bounded_find_files(project_root, ".csproj", max_depth=4)
if len(csproj_hits) == 1:
return csproj_hits[0]
return None
def bounded_find_files(root: str, extension: str, max_depth: int):
root_path = pathlib.Path(root).resolve()
results = []
for current_root, dirs, files in os.walk(root_path):
rel = pathlib.Path(current_root).resolve().relative_to(root_path)
depth = len(rel.parts)
dirs[:] = [d for d in dirs if d not in EXCLUDED_SCAN_DIRS]
if depth > max_depth:
dirs[:] = []
continue
for name in files:
if name.lower().endswith(extension.lower()):
results.append(str(pathlib.Path(current_root) / name))
return sorted(results)
def run_dotnet_action(project_root, working_dir, verb):
cwd = resolve_cwd(project_root, working_dir)
args = [verb]
target = discover_dotnet_target(project_root, cwd)
if target:
args.append(target)
step = run_step("dotnet", args, cwd)
if target:
step["resolved_target"] = target
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def _deps_hash(app_root):
h = hashlib.sha256()
for name in ("package.json", "package-lock.json"):
p = pathlib.Path(app_root) / name
if p.exists():
h.update(p.read_bytes())
return h.hexdigest()
def ensure_npm_dependencies(app_root):
node_modules = pathlib.Path(app_root) / "node_modules"
deps_hash_file = node_modules / ".sdt-deps.sha256"
expected = _deps_hash(app_root)
should_install = not node_modules.exists()
if not should_install:
if not deps_hash_file.exists():
should_install = True
else:
current = deps_hash_file.read_text(encoding="utf-8").strip()
should_install = current != expected
if not should_install:
return {"installed": False, "reason": "deps_unchanged"}
lock_exists = (pathlib.Path(app_root) / "package-lock.json").exists()
install_args = ["ci", "--no-audit", "--fund=false"] if lock_exists else ["install", "--no-audit", "--fund=false"]
install_step = run_step("npm", install_args, app_root)
if install_step["exit_code"] != 0:
if lock_exists and install_args[0] == "ci":
fallback = run_step("npm", ["install", "--no-audit", "--fund=false"], app_root)
if fallback["exit_code"] != 0:
fallback["failure_reason"] = "deps_install_failed_after_ci_fallback"
return {"installed": True, "reason": "install_failed", "step": fallback}
install_step = fallback
else:
return {"installed": True, "reason": "install_failed", "step": install_step}
node_modules.mkdir(parents=True, exist_ok=True)
deps_hash_file.write_text(expected, encoding="utf-8")
return {"installed": True, "reason": "installed", "step": install_step}
def action_dotnet_build(args):
return run_dotnet_action(args.project_root, args.working_dir, "build")
def action_dotnet_restore(args):
return run_dotnet_action(args.project_root, args.working_dir, "restore")
def action_dotnet_test(args):
return run_dotnet_action(args.project_root, args.working_dir, "test")
def action_dotnet_publish(args):
return run_dotnet_action(args.project_root, args.working_dir, "publish")
def action_npm_install(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("npm", ["install"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_npm_ci(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("npm", ["ci"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_npm_build(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
deps = ensure_npm_dependencies(cwd)
if deps.get("reason") == "install_failed":
step = deps["step"]
step["failure_reason"] = "deps_install_failed"
return step["exit_code"], step
step = run_step("npm", ["run", "build"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_npm_test(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
deps = ensure_npm_dependencies(cwd)
if deps.get("reason") == "install_failed":
step = deps["step"]
step["failure_reason"] = "deps_install_failed"
return step["exit_code"], step
step = run_step("npm", ["test"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_npm_audit(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("npm", ["audit"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_python_venv_create(args):
cwd = resolve_cwd(args.project_root, ".")
venv_dir = args.venv_dir or ".venv"
step = run_step(resolve_python_executable(), ["-m", "venv", venv_dir], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_python_pip_install(args):
cwd = resolve_cwd(args.project_root, ".")
req = args.requirements
step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_python_pip_sync(args):
cwd = resolve_cwd(args.project_root, ".")
req = args.requirements
step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_python_pytest(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step(resolve_python_executable(), ["-m", "pytest"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_cargo_build(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("cargo", ["build"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_cargo_test(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("cargo", ["test"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_tauri_build(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
deps = ensure_npm_dependencies(cwd)
if deps.get("reason") == "install_failed":
step = deps["step"]
step["failure_reason"] = "deps_install_failed"
return step["exit_code"], step
tauri_args = ["run", "tauri", "build"]
if args.no_bundle:
tauri_args.extend(["--", "--no-bundle"])
step = run_step("npm", tauri_args, cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_git_status(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["status"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_git_fetch(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["fetch"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_git_pull(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["pull"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_git_clean(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["clean", "-fd"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_docker_build(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("docker", ["build", "."], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_docker_compose_up(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("docker", ["compose", "up", "-d"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_docker_compose_down(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("docker", ["compose", "down"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def main():
parser = argparse.ArgumentParser(description="SDT normalized build actions")
sub = parser.add_subparsers(dest="action", required=True)
p0 = sub.add_parser("dotnet-restore")
parse_common(p0)
p1 = sub.add_parser("dotnet-build")
parse_common(p1)
p1b = sub.add_parser("dotnet-test")
parse_common(p1b)
p1c = sub.add_parser("dotnet-publish")
parse_common(p1c)
p2 = sub.add_parser("npm-install")
parse_common(p2)
p2b = sub.add_parser("npm-ci")
parse_common(p2b)
p3 = sub.add_parser("npm-build")
parse_common(p3)
p3b = sub.add_parser("npm-test")
parse_common(p3b)
p3c = sub.add_parser("npm-audit")
parse_common(p3c)
p4 = sub.add_parser("python-venv-create")
parse_common(p4)
p4.add_argument("--venv-dir", default=".venv")
p5 = sub.add_parser("python-pip-install")
parse_common(p5)
p5.add_argument("--requirements", required=True)
p5b = sub.add_parser("python-pip-sync")
parse_common(p5b)
p5b.add_argument("--requirements", required=True)
p5c = sub.add_parser("python-pytest")
parse_common(p5c)
p6 = sub.add_parser("cargo-build")
parse_common(p6)
p6b = sub.add_parser("cargo-test")
parse_common(p6b)
p7 = sub.add_parser("tauri-build")
parse_common(p7)
p7.add_argument("--no-bundle", action="store_true")
p8 = sub.add_parser("git-status")
parse_common(p8)
p9 = sub.add_parser("git-fetch")
parse_common(p9)
p10 = sub.add_parser("git-pull")
parse_common(p10)
p11 = sub.add_parser("git-clean")
parse_common(p11)
p12 = sub.add_parser("docker-build")
parse_common(p12)
p13 = sub.add_parser("docker-compose-up")
parse_common(p13)
p14 = sub.add_parser("docker-compose-down")
parse_common(p14)
args = parser.parse_args()
handlers = {
"dotnet-restore": action_dotnet_restore,
"dotnet-build": action_dotnet_build,
"dotnet-test": action_dotnet_test,
"dotnet-publish": action_dotnet_publish,
"npm-install": action_npm_install,
"npm-ci": action_npm_ci,
"npm-build": action_npm_build,
"npm-test": action_npm_test,
"npm-audit": action_npm_audit,
"python-venv-create": action_python_venv_create,
"python-pip-install": action_python_pip_install,
"python-pip-sync": action_python_pip_sync,
"python-pytest": action_python_pytest,
"cargo-build": action_cargo_build,
"cargo-test": action_cargo_test,
"tauri-build": action_tauri_build,
"git-status": action_git_status,
"git-fetch": action_git_fetch,
"git-pull": action_git_pull,
"git-clean": action_git_clean,
"docker-build": action_docker_build,
"docker-compose-up": action_docker_compose_up,
"docker-compose-down": action_docker_compose_down,
}
code, summary = handlers[args.action](args)
if args.json:
print(json.dumps(summary))
return code
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,17 @@
@echo off
set "SCRIPT_DIR=%~dp0"
where py >nul 2>nul
if %ERRORLEVEL%==0 (
set "PYEXE=py"
) else (
where python >nul 2>nul
if not %ERRORLEVEL%==0 (
echo python not found.
exit /b 1
)
set "PYEXE=python"
)
for /f "usebackq delims=" %%L in (`"%PYEXE%" "%SCRIPT_DIR%dev_shell.py" export --shell cmd`) do %%L
echo Development shell initialized from Python bootstrap script.

View File

@ -0,0 +1,21 @@
# Run this in PowerShell before development commands:
# . ./scripts/dev-shell.ps1
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot '_pwsh-python-shim.ps1')
$scriptPath = Resolve-SdtScriptPath -ScriptName 'dev_shell.py'
$python = Resolve-SdtPython
$lines = & $python $scriptPath export --shell pwsh
if ($LASTEXITCODE -ne 0) {
throw "Failed to initialize development shell via dev_shell.py"
}
foreach ($line in $lines) {
Invoke-Expression $line
}
Write-Host "Development shell initialized from Python bootstrap script."

View File

@ -0,0 +1,16 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
if command -v python3 >/dev/null 2>&1; then
PYTHON_EXE="python3"
elif command -v python >/dev/null 2>&1; then
PYTHON_EXE="python"
else
echo "python3/python not found." >&2
exit 1
fi
eval "$("$PYTHON_EXE" "$SCRIPT_DIR/dev_shell.py" export --shell bash)"
echo "Development shell initialized from Python bootstrap script."

View File

@ -0,0 +1,148 @@
#!/usr/bin/env python3
import argparse
import json
import pathlib
import sys
from script_common import PROXY_VARS, clean_proxy_env, dotnet_env, ensure_dirs, pip_env, resolve_repo_root
def huggingface_env(repo_root: pathlib.Path) -> dict[str, str]:
env = {}
hf_home = repo_root / ".cache" / "huggingface"
hf_hub_cache = hf_home / "hub"
ensure_dirs([hf_hub_cache])
env["HF_HOME"] = str(hf_home)
env["HUGGINGFACE_HUB_CACHE"] = str(hf_hub_cache)
env["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
return env
def resolved_env(repo_root: pathlib.Path) -> dict[str, str]:
env = {}
dotnet = dotnet_env(repo_root)
pip = pip_env(repo_root)
hf = huggingface_env(repo_root)
dotnet_keys = [
"DOTNET_CLI_HOME",
"NUGET_PACKAGES",
"NUGET_HTTP_CACHE_PATH",
"DOTNET_SKIP_FIRST_TIME_EXPERIENCE",
"DOTNET_ADD_GLOBAL_TOOLS_TO_PATH",
"DOTNET_GENERATE_ASPNET_CERTIFICATE",
"DOTNET_CLI_TELEMETRY_OPTOUT",
"NUGET_CERT_REVOCATION_MODE",
]
pip_keys = [
"PIP_CACHE_DIR",
"PIP_DISABLE_PIP_VERSION_CHECK",
"PIP_DEFAULT_TIMEOUT",
"PIP_RETRIES",
"TEMP",
"TMP",
]
for key in dotnet_keys:
env[key] = dotnet[key]
for key in pip_keys:
env[key] = pip[key]
env.update(hf)
clean_proxy_env(env)
return env
def export_lines(shell: str, env_map: dict[str, str]) -> list[str]:
def sh_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
if shell == "pwsh":
lines = [f"Remove-Item Env:{k} -ErrorAction SilentlyContinue" for k in PROXY_VARS]
lines.extend(f"$env:{k} = \"{v.replace('\"', '`\"')}\"" for k, v in env_map.items())
return lines
if shell in ("bash", "zsh"):
lines = [f"unset {k}" for k in PROXY_VARS]
lines.extend(f"export {k}={sh_quote(v)}" for k, v in env_map.items())
return lines
if shell == "cmd":
lines = [f"set {k}=" for k in PROXY_VARS]
lines.extend(f"set {k}={v}" for k, v in env_map.items())
return lines
raise ValueError(shell)
def cmd_export(args):
try:
repo_root = resolve_repo_root(args.project_root)
except Exception as ex:
print(f"Failed to resolve project root: {ex}", file=sys.stderr)
return 2
env_map = resolved_env(repo_root)
payload = {
"projectRoot": str(repo_root),
"env": env_map,
"createdDirs": [
str(repo_root / ".dotnet_home"),
str(repo_root / ".nuget" / "packages"),
str(repo_root / ".nuget" / "http-cache"),
str(repo_root / ".pip" / "cache"),
str(repo_root / ".tmp" / "pip-temp"),
str(repo_root / ".cache" / "huggingface" / "hub"),
],
"warnings": [],
}
try:
lines = export_lines(args.shell, env_map)
except ValueError:
print(f"Unsupported shell target: {args.shell}", file=sys.stderr)
return 3
if args.json:
print(json.dumps(payload))
else:
for line in lines:
print(line)
return 0
def cmd_doctor(args):
try:
repo_root = resolve_repo_root(args.project_root)
except Exception as ex:
print(f"Failed to resolve project root: {ex}", file=sys.stderr)
return 2
env_map = resolved_env(repo_root)
checks = {
"repo_root": str(repo_root),
"dotnet_home_exists": (repo_root / ".dotnet_home").exists(),
"nuget_cache_exists": (repo_root / ".nuget" / "packages").exists(),
"pip_cache_exists": (repo_root / ".pip" / "cache").exists(),
"hf_cache_exists": (repo_root / ".cache" / "huggingface" / "hub").exists(),
"env_count": len(env_map),
}
print(json.dumps(checks))
return 0
def main():
parser = argparse.ArgumentParser(description="SDT cross-platform shell bootstrap helper")
sub = parser.add_subparsers(dest="command", required=True)
p_export = sub.add_parser("export", help="Print env exports for a shell")
p_export.add_argument("--shell", required=True)
p_export.add_argument("--project-root")
p_export.add_argument("--json", action="store_true")
p_doctor = sub.add_parser("doctor", help="Validate env bootstrap paths")
p_doctor.add_argument("--project-root")
args = parser.parse_args()
if args.command == "export":
return cmd_export(args)
return cmd_doctor(args)
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,128 @@
#!/usr/bin/env python3
import argparse
import json
import os
import platform
import shutil
import subprocess
import sys
from script_common import resolve_command
def run_capture(cmd):
try:
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
out = (proc.stdout or "").strip()
err = (proc.stderr or "").strip()
text = out if out else err
return proc.returncode == 0, text
except Exception as ex:
return False, str(ex)
def probe_tool(tool):
mapping = {
"dotnet": ["dotnet", "--version"],
"node": ["node", "--version"],
"npm": ["npm", "--version"],
"python": ["python", "--version"],
"cargo": ["cargo", "--version"],
"tauri": ["tauri", "--version"],
"git": ["git", "--version"],
"docker": ["docker", "--version"],
}
cmd = mapping.get(tool, [tool, "--version"])
resolved = resolve_command(cmd[0])
if shutil.which(resolved) is None and not os.path.exists(resolved):
return {"tool": tool, "available": False, "version": None, "details": f"{cmd[0]} not found in PATH"}
cmd = [resolved, *cmd[1:]]
ok, text = run_capture(cmd)
return {"tool": tool, "available": ok, "version": text if ok else None, "details": None if ok else text}
def install_plan(tool):
is_windows = platform.system().lower().startswith("win")
if is_windows:
plans = {
"dotnet": [("winget", ["install", "Microsoft.DotNet.SDK.10"])],
"node": [("winget", ["install", "OpenJS.NodeJS.LTS"])],
"npm": [("winget", ["install", "OpenJS.NodeJS.LTS"])],
"python": [("winget", ["install", "Python.Python.3.12"])],
"cargo": [("winget", ["install", "Rustlang.Rustup"])],
"tauri": [("npm", ["install", "-g", "@tauri-apps/cli"])],
"git": [("winget", ["install", "Git.Git"])],
"docker": [("winget", ["install", "Docker.DockerDesktop"])],
}
else:
plans = {
"dotnet": [("sh", ["-c", "echo install dotnet sdk with your package manager"])],
"node": [("sh", ["-c", "echo install nodejs with your package manager"])],
"npm": [("sh", ["-c", "echo install npm with your package manager"])],
"python": [("sh", ["-c", "echo install python3 with your package manager"])],
"cargo": [("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh"])],
"tauri": [("npm", ["install", "-g", "@tauri-apps/cli"])],
"git": [("sh", ["-c", "echo install git with your package manager"])],
"docker": [("sh", ["-c", "echo install docker with your package manager"])],
}
cmds = plans.get(tool, [])
return {
"tool": tool,
"supported": len(cmds) > 0,
"summary": f"Install plan for {tool} on {platform.system()}",
"commands": [{"command": c, "args": a} for c, a in cmds],
}
def run_install(tool):
plan = install_plan(tool)
if not plan["supported"]:
return 2
for cmd in plan["commands"]:
proc = subprocess.run([cmd["command"], *cmd["args"]], check=False)
if proc.returncode != 0:
return proc.returncode
return 0
def main():
parser = argparse.ArgumentParser(description="SDT diagnostics and install planner")
sub = parser.add_subparsers(dest="cmd", required=True)
p_probe = sub.add_parser("probe")
p_probe.add_argument("--tool", required=True)
p_probe.add_argument("--json", action="store_true")
p_plan = sub.add_parser("install-plan")
p_plan.add_argument("--tool", required=True)
p_plan.add_argument("--json", action="store_true")
p_run = sub.add_parser("install-run")
p_run.add_argument("--tool", required=True)
args = parser.parse_args()
if args.cmd == "probe":
result = probe_tool(args.tool.lower())
if args.json:
print(json.dumps(result))
else:
print(result)
return 0 if result["available"] else 1
if args.cmd == "install-plan":
result = install_plan(args.tool.lower())
if args.json:
print(json.dumps(result))
else:
print(result)
return 0 if result["supported"] else 2
if args.cmd == "install-run":
return run_install(args.tool.lower())
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,34 @@
#!/usr/bin/env python3
import argparse
import sys
from script_common import dotnet_env, resolve_repo_root, run
DOTNET_SAFE_CMDS = {"restore", "build", "run", "test", "publish", "pack"}
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform minimal dotnet wrapper")
parser.add_argument("dotnet_args", nargs=argparse.REMAINDER)
parser.add_argument("--repo-root", default=None)
args = parser.parse_args()
if not args.dotnet_args:
print("Usage: python scripts/dotnet-min.py <dotnet args>", file=sys.stderr)
return 2
repo_root = resolve_repo_root(args.repo_root)
dotnet_args = list(args.dotnet_args)
cmd = dotnet_args[0].lower()
if cmd in DOTNET_SAFE_CMDS:
dotnet_args.extend(["-p:RestoreIgnoreFailedSources=true", "-p:NuGetAudit=false"])
if cmd == "restore":
dotnet_args.append("--ignore-failed-sources")
return run("dotnet", dotnet_args, repo_root, env=dotnet_env(repo_root))
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,44 @@
#!/usr/bin/env python3
import argparse
import subprocess
import sys
from pathlib import Path
from script_common import resolve_repo_root
def run_step(repo_root: Path, title: str, command: list[str]) -> int:
print(f"\n== {title} ==")
print("$", " ".join(command))
proc = subprocess.run(command, cwd=str(repo_root), check=False)
return proc.returncode
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform migration quality gate")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--skip-tests", action="store_true")
parser.add_argument("--test-project", default=None, help="Optional test csproj path")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
code = run_step(repo_root, "Build", [sys.executable, "scripts/dotnet-min.py", "build"])
if code != 0:
return code
if not args.skip_tests:
if args.test_project:
test_cmd = [sys.executable, "scripts/dotnet-min.py", "test", args.test_project]
else:
test_cmd = [sys.executable, "scripts/dotnet-min.py", "test"]
code = run_step(repo_root, "Tests", test_cmd)
if code != 0:
return code
print("\nMigration gate passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
import argparse
import shutil
from pathlib import Path
from script_common import resolve_repo_root
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform node_modules cleanup")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--working-dir", default=".")
parser.add_argument("--also-cache", action="store_true")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
work_dir = (repo_root / args.working_dir).resolve()
node_modules = work_dir / "node_modules"
if node_modules.exists():
shutil.rmtree(node_modules)
print(f"Removed: {node_modules}")
else:
print(f"Not found: {node_modules}")
if args.also_cache:
npm_cache = repo_root / ".npm" / "cache"
if npm_cache.exists():
shutil.rmtree(npm_cache)
print(f"Removed: {npm_cache}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,42 @@
#!/usr/bin/env python3
import argparse
import shutil
import tempfile
from pathlib import Path
from script_common import resolve_repo_root
def main() -> int:
parser = argparse.ArgumentParser(description="Export local NuGet cache to zip")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--output-zip", default="nuget-cache-export.zip")
parser.add_argument("--include-dotnet-home", action="store_true")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
output_zip = (repo_root / args.output_zip).resolve()
nuget_dir = repo_root / ".nuget"
dotnet_home = repo_root / ".dotnet_home"
if not nuget_dir.exists():
print(f"NuGet cache not found: {nuget_dir}")
return 2
with tempfile.TemporaryDirectory() as td:
stage = Path(td) / "cache-export"
stage.mkdir(parents=True, exist_ok=True)
shutil.copytree(nuget_dir, stage / ".nuget")
if args.include_dotnet_home and dotnet_home.exists():
shutil.copytree(dotnet_home, stage / ".dotnet_home")
manifest = stage / "nuget-cache-manifest.txt"
manifest.write_text("exported_by=nuget-export-cache.py\n", encoding="utf-8")
archive_base = str(output_zip.with_suffix(""))
shutil.make_archive(archive_base, "zip", root_dir=str(stage))
print(f"Exported cache: {output_zip}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,28 @@
#!/usr/bin/env python3
import argparse
import shutil
from pathlib import Path
from script_common import resolve_repo_root
def main() -> int:
parser = argparse.ArgumentParser(description="Import NuGet cache from zip")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--input-zip", default="nuget-cache-export.zip")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
input_zip = (repo_root / args.input_zip).resolve()
if not input_zip.exists():
print(f"Input zip not found: {input_zip}")
return 2
shutil.unpack_archive(str(input_zip), extract_dir=str(repo_root))
print(f"Imported cache from: {input_zip}")
print("Run `python scripts/dotnet-min.py restore` to validate restore in this repo.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
import argparse
import os
import sys
from script_common import pip_env, resolve_repo_root, run
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform minimal pip wrapper")
parser.add_argument("pip_args", nargs=argparse.REMAINDER)
parser.add_argument("--repo-root", default=None)
args = parser.parse_args()
if not args.pip_args:
print("Usage: python scripts/pip-min.py <pip args>", file=sys.stderr)
return 2
repo_root = resolve_repo_root(args.repo_root)
pip_args = list(args.pip_args)
# Preserve legacy behavior: for bare install, default target to repo-local deps.
if pip_args and pip_args[0].lower() == "install":
has_target = any(a in ("--target", "--prefix") for a in pip_args)
if not has_target:
pip_args = [a for a in pip_args if a != "--user"]
target = repo_root / ".pydeps" / f"py{sys.version_info.major}{sys.version_info.minor}"
os.makedirs(target, exist_ok=True)
pip_args.extend(["--target", str(target)])
return run(sys.executable, ["-m", "pip", *pip_args], repo_root, env=pip_env(repo_root))
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,46 @@
from __future__ import annotations
import os
import tempfile
from typing import Callable
def _mkdtemp_compat(
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
) -> str:
# Python 3.14 on some Windows hosts creates mkdtemp dirs that are
# immediately non-writable by the same process when mode=0o700 is used.
# pip relies heavily on tempfile; force 0o777 for compatibility.
if dir is None:
dir = tempfile.gettempdir()
if prefix is None:
prefix = tempfile.template
if suffix is None:
suffix = ""
names = tempfile._get_candidate_names()
for _ in range(tempfile.TMP_MAX):
name = next(names)
path = os.path.join(dir, f"{prefix}{name}{suffix}")
try:
os.mkdir(path, 0o777)
return path
except FileExistsError:
continue
raise FileExistsError("No usable temporary directory name found.")
def main(argv: list[str]) -> int:
tempfile.mkdtemp = _mkdtemp_compat # type: ignore[assignment]
from pip._internal.cli.main import main as pip_main
return int(pip_main(argv))
if __name__ == "__main__":
raise SystemExit(main(__import__("sys").argv[1:]))

View File

@ -0,0 +1,91 @@
#!/usr/bin/env python3
import argparse
from pathlib import Path
from script_common import find_node_app_root, resolve_repo_root, run, sha256_files
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform web/tauri publish helper")
parser.add_argument("--target", choices=["web", "tauri"], default="web")
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
parser.add_argument("--tauri-bundles", choices=["none", "nsis", "msi"], default="none")
parser.add_argument("--install-deps", action="store_true")
parser.add_argument("--skip-install", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--app-root", default=None, help="Relative or absolute app root with package.json")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
app_root = find_node_app_root(repo_root, args.app_root)
if app_root is None:
print("Unable to locate app root (no unique package.json found).")
return 2
package_json = app_root / "package.json"
lock_file = app_root / "package-lock.json"
node_modules = app_root / "node_modules"
deps_hash_file = node_modules / ".sdt-deps.sha256"
expected_hash = sha256_files([package_json, lock_file])
should_install = args.install_deps or not node_modules.exists()
if not should_install and not args.skip_install:
if not deps_hash_file.exists():
should_install = True
else:
current = deps_hash_file.read_text(encoding="utf-8").strip()
should_install = current != expected_hash
if args.skip_install:
should_install = False
print(f"App root: {app_root}")
print(f"Target: {args.target} ({args.configuration})")
if should_install:
install_args = ["ci", "--no-audit", "--fund=false"] if lock_file.exists() else ["install", "--no-audit", "--fund=false"]
print("$ npm " + " ".join(install_args))
if not args.dry_run:
code = run("npm", install_args, app_root)
if code != 0:
if lock_file.exists() and install_args[0] == "ci":
print("npm ci failed (likely lockfile out of sync). Falling back to npm install...")
fallback_args = ["install", "--no-audit", "--fund=false"]
print("$ npm " + " ".join(fallback_args))
code = run("npm", fallback_args, app_root)
if code != 0:
return code
else:
return code
node_modules.mkdir(parents=True, exist_ok=True)
deps_hash_file.write_text(expected_hash, encoding="utf-8")
else:
print("Skipping dependency install.")
if args.target == "web":
cmd = ["run", "build"]
print("$ npm " + " ".join(cmd))
if not args.dry_run:
return run("npm", cmd, app_root)
return 0
tauri_cmd = ["run", "tauri", "build"]
tauri_tail: list[str] = []
if args.tauri_bundles == "none":
tauri_tail.extend(["--no-bundle"])
else:
tauri_tail.extend(["--bundles", args.tauri_bundles])
if args.configuration == "Debug":
tauri_tail.append("--debug")
if tauri_tail:
tauri_cmd.extend(["--", *tauri_tail])
print("$ npm " + " ".join(tauri_cmd))
if not args.dry_run:
return run("npm", tauri_cmd, app_root)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,90 @@
#!/usr/bin/env python3
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
from script_common import find_node_app_root, resolve_repo_root
def run_step(label: str, cmd: list[str], cwd: Path, dry_run: bool) -> int:
print(f"\n> {label}")
print("$", " ".join(cmd))
if dry_run:
return 0
proc = subprocess.run(cmd, cwd=str(cwd), check=False)
return proc.returncode
def main() -> int:
parser = argparse.ArgumentParser(description="Publish bundled outputs using Python script entrypoints")
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
parser.add_argument("--runtime", default="win-x64")
parser.add_argument("--skip-sidecar", action="store_true")
parser.add_argument("--skip-web", action="store_true")
parser.add_argument("--skip-webgateway", action="store_true")
parser.add_argument("--skip-tauri", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--sidecar-project", default=None)
parser.add_argument("--gateway-project", default=None)
parser.add_argument("--app-root", default=None)
parser.add_argument("--output-dir", default="output")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
output_root = (repo_root / args.output_dir).resolve()
output_root.mkdir(parents=True, exist_ok=True)
py = sys.executable
if not args.skip_sidecar:
cmd = [py, "scripts/publish-sidecar.py", "--configuration", args.configuration, "--runtime", args.runtime]
if args.sidecar_project:
cmd.extend(["--project", args.sidecar_project])
code = run_step("Publish sidecar", cmd, repo_root, args.dry_run)
if code != 0:
return code
if not args.skip_web:
cmd = [py, "scripts/publish-app.py", "--target", "web", "--configuration", args.configuration]
if args.app_root:
cmd.extend(["--app-root", args.app_root])
code = run_step("Build web", cmd, repo_root, args.dry_run)
if code != 0:
return code
if not args.skip_webgateway:
cmd = [py, "scripts/publish-webgateway.py", "--configuration", args.configuration, "--runtime", args.runtime]
if args.gateway_project:
cmd.extend(["--project", args.gateway_project])
code = run_step("Publish web gateway", cmd, repo_root, args.dry_run)
if code != 0:
return code
if not args.skip_tauri:
cmd = [py, "scripts/publish-app.py", "--target", "tauri", "--configuration", args.configuration, "--tauri-bundles", "none"]
if args.app_root:
cmd.extend(["--app-root", args.app_root])
code = run_step("Build tauri", cmd, repo_root, args.dry_run)
if code != 0:
return code
app_root = find_node_app_root(repo_root, args.app_root)
if app_root is not None:
target_dir = app_root / "src-tauri" / "target" / ("debug" if args.configuration == "Debug" else "release")
exes = sorted(target_dir.glob("*.exe"), key=lambda p: p.stat().st_mtime, reverse=True)
if exes:
staged = output_root / exes[0].name
if args.dry_run:
print(f"Would copy: {exes[0]} -> {staged}")
else:
shutil.copy2(exes[0], staged)
print(f"Staged desktop executable: {staged}")
print("\nPublish output workflow complete.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,10 @@
param(
[Parameter(ValueFromRemainingArguments = $($true))]
[string[]]$ForwardArgs
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot '_pwsh-python-shim.ps1')
Invoke-SdtPythonScript -ScriptName 'publish-sidecar.py' -ForwardArgs $ForwardArgs

View File

@ -0,0 +1,58 @@
#!/usr/bin/env python3
import argparse
import os
from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform .NET sidecar publish helper")
parser.add_argument("--configuration", default="Release")
parser.add_argument("--runtime", default="win-x64")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--project", default=None, help="Relative/absolute path to sidecar csproj")
parser.add_argument("--output-dir", default="output")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
output_dir = (repo_root / args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
if args.project:
csproj = (repo_root / args.project).resolve()
else:
csproj = find_csproj_by_keyword(repo_root, ["sidecar"])
if csproj is None or not csproj.exists():
print("Could not locate sidecar project. Pass --project <path/to/project.csproj>.")
return 2
publish_args = [
"publish",
str(csproj),
"-c",
args.configuration,
"-r",
args.runtime,
"--self-contained",
"-p:PublishSingleFile=true",
"-p:IncludeNativeLibrariesForSelfExtract=true",
"-p:RestoreIgnoreFailedSources=true",
"-p:NuGetAudit=false",
"-o",
str(output_dir),
]
code = run("dotnet", publish_args, repo_root, env=dotnet_env(repo_root))
if code != 0:
return code
binary_name = csproj.stem + (".exe" if args.runtime.startswith("win-") else "")
binary_path = output_dir / binary_name
if binary_path.exists():
print(f"Published executable: {binary_path}")
else:
print(f"Publish completed. Output directory: {output_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,78 @@
#!/usr/bin/env python3
import argparse
import shutil
from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform ASP.NET gateway publish helper")
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
parser.add_argument("--runtime", default="win-x64")
parser.add_argument("--self-contained", action="store_true")
parser.add_argument("--skip-web-assets", action="store_true")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--project", default=None, help="Relative/absolute path to gateway csproj")
parser.add_argument("--web-build-dir", default=None, help="Relative path to web build assets root")
parser.add_argument("--output-dir", default="output/webgateway")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
output_dir = (repo_root / args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
if args.project:
csproj = (repo_root / args.project).resolve()
else:
csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"])
if csproj is None or not csproj.exists():
print("Could not locate web gateway project. Pass --project <path/to/project.csproj>.")
return 2
publish_args = [
"publish",
str(csproj),
"-c",
args.configuration,
"-r",
args.runtime,
"--self-contained",
"true" if args.self_contained else "false",
"-p:RestoreIgnoreFailedSources=true",
"-p:NuGetAudit=false",
"-o",
str(output_dir),
]
code = run("dotnet", publish_args, repo_root, env=dotnet_env(repo_root))
if code != 0:
return code
if not args.skip_web_assets:
if args.web_build_dir:
web_build_dir = (repo_root / args.web_build_dir).resolve()
else:
web_build_dir = next((p.parent for p in repo_root.rglob("package.json") if (p.parent / "build").exists()), None)
if web_build_dir is not None:
web_build_dir = web_build_dir / "build"
if web_build_dir is None or not web_build_dir.exists():
print("Web assets not found. Skip with --skip-web-assets or pass --web-build-dir.")
else:
web_out = output_dir / "wwwroot"
web_out.mkdir(parents=True, exist_ok=True)
for item in web_build_dir.iterdir():
dst = web_out / item.name
if item.is_dir():
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(item, dst)
else:
shutil.copy2(item, dst)
print(f"Copied web assets: {web_out}")
print(f"Publish completed: {output_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,60 @@
#!/usr/bin/env python3
import argparse
import os
from pathlib import Path
from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run
def main() -> int:
parser = argparse.ArgumentParser(description="Run gateway in dev or output mode")
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
parser.add_argument("--urls", default="http://0.0.0.0:5180")
parser.add_argument("--project-root", default=None, help="Runtime project root exposed via SDT_PROJECT_ROOT")
parser.add_argument("--mode", choices=["Dev", "Output"], default="Dev")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--project", default=None, help="Gateway csproj path")
parser.add_argument("--output-exe", default=None, help="Published gateway executable path")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
effective_project_root = Path(args.project_root).resolve() if args.project_root else repo_root
if not effective_project_root.exists():
print(f"Project root does not exist: {effective_project_root}")
return 2
env = dotnet_env(repo_root)
env["SDT_PROJECT_ROOT"] = str(effective_project_root)
if args.mode == "Output":
exe_path = Path(args.output_exe).resolve() if args.output_exe else (repo_root / "output" / "webgateway" / ("webgateway.exe" if os.name == "nt" else "webgateway"))
if not exe_path.exists():
print(f"Output executable not found: {exe_path}")
return 2
return run(str(exe_path), ["--urls", args.urls], repo_root, env=env)
if args.project:
csproj = (repo_root / args.project).resolve()
else:
csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"])
if csproj is None or not csproj.exists():
print("Could not locate gateway project. Pass --project <path/to/project.csproj>.")
return 2
run_args = [
"run",
"--project",
str(csproj),
"-c",
args.configuration,
"--no-launch-profile",
"--urls",
args.urls,
"-p:RestoreIgnoreFailedSources=true",
"-p:NuGetAudit=false",
]
return run("dotnet", run_args, repo_root, env=env)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,124 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Legacy compatibility helper only.
# Active SDT workflows and shell bootstrap now use Python scripts.
function Clear-SdtProxyEnv {
Remove-Item Env:HTTP_PROXY -ErrorAction SilentlyContinue
Remove-Item Env:HTTPS_PROXY -ErrorAction SilentlyContinue
Remove-Item Env:ALL_PROXY -ErrorAction SilentlyContinue
Remove-Item Env:http_proxy -ErrorAction SilentlyContinue
Remove-Item Env:https_proxy -ErrorAction SilentlyContinue
Remove-Item Env:all_proxy -ErrorAction SilentlyContinue
Remove-Item Env:GIT_HTTP_PROXY -ErrorAction SilentlyContinue
Remove-Item Env:GIT_HTTPS_PROXY -ErrorAction SilentlyContinue
Remove-Item Env:PIP_NO_INDEX -ErrorAction SilentlyContinue
}
function Resolve-SdtRepoRoot {
param([string]$StartPath)
$candidateStarts = @()
if (-not [string]::IsNullOrWhiteSpace($StartPath)) {
$candidateStarts += $StartPath
}
$cwd = (Get-Location).Path
if (-not [string]::IsNullOrWhiteSpace($cwd) -and ($candidateStarts -notcontains $cwd)) {
$candidateStarts += $cwd
}
$override = $env:SDT_REPO_ROOT
if ([string]::IsNullOrWhiteSpace($override)) {
$override = $env:JOURNAL_REPO_ROOT # backward compatibility
}
if (-not [string]::IsNullOrWhiteSpace($override)) {
$overridePath = [System.IO.Path]::GetFullPath($override)
if (Test-Path (Join-Path $overridePath "devtool.json")) {
return $overridePath
}
}
foreach ($start in $candidateStarts) {
$cursor = [System.IO.Path]::GetFullPath($start)
while (-not [string]::IsNullOrWhiteSpace($cursor)) {
if (Test-Path (Join-Path $cursor "devtool.json")) {
return $cursor
}
$parent = [System.IO.Directory]::GetParent($cursor)
if ($null -eq $parent -or $parent.FullName -eq $cursor) {
break
}
$cursor = $parent.FullName
}
}
if (Get-Command git -ErrorAction SilentlyContinue) {
foreach ($start in $candidateStarts) {
try {
$gitRoot = & git -C $start rev-parse --show-toplevel 2>$null
if ($? -and -not [string]::IsNullOrWhiteSpace($gitRoot)) {
return [System.IO.Path]::GetFullPath($gitRoot.Trim())
}
}
catch {}
}
}
throw "Could not locate repository root. Ensure a devtool.json exists in the project root."
}
function Initialize-SdtDotnetEnv {
param([Parameter(Mandatory = $true)][string]$RepoRoot)
$dotnetCliHome = Join-Path $RepoRoot ".dotnet_home"
$nugetPackages = Join-Path $RepoRoot ".nuget\packages"
$nugetHttpCachePath = Join-Path $RepoRoot ".nuget\http-cache"
$env:DOTNET_CLI_HOME = $dotnetCliHome
$env:NUGET_PACKAGES = $nugetPackages
$env:NUGET_HTTP_CACHE_PATH = $nugetHttpCachePath
$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1"
$env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = "0"
$env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "0"
$env:DOTNET_CLI_TELEMETRY_OPTOUT = "1"
$env:NUGET_CERT_REVOCATION_MODE = "offline"
New-Item -ItemType Directory -Force -Path $dotnetCliHome, $nugetPackages, $nugetHttpCachePath | Out-Null
}
function Initialize-SdtPipEnv {
param([Parameter(Mandatory = $true)][string]$RepoRoot)
$pipCacheDir = Join-Path $RepoRoot ".pip\cache"
$pipTempDir = Join-Path $RepoRoot ".tmp\pip-temp"
$env:PIP_CACHE_DIR = $pipCacheDir
$env:TEMP = $pipTempDir
$env:TMP = $pipTempDir
$env:PIP_DISABLE_PIP_VERSION_CHECK = "1"
$env:PIP_DEFAULT_TIMEOUT = "30"
$env:PIP_RETRIES = "2"
New-Item -ItemType Directory -Force -Path $pipCacheDir, $pipTempDir | Out-Null
}
function Initialize-SdtHuggingFaceEnv {
param([Parameter(Mandatory = $true)][string]$RepoRoot)
$hfHome = Join-Path $RepoRoot ".cache\huggingface"
$hfHubCache = Join-Path $hfHome "hub"
$env:HF_HOME = $hfHome
$env:HUGGINGFACE_HUB_CACHE = $hfHubCache
$env:HF_HUB_DISABLE_SYMLINKS_WARNING = "1"
New-Item -ItemType Directory -Force -Path $hfHubCache | Out-Null
}
# Backward-compatible aliases (legacy script calls)
Set-Alias -Name Clear-JournalProxyEnv -Value Clear-SdtProxyEnv -Scope Script
Set-Alias -Name Resolve-JournalRepoRoot -Value Resolve-SdtRepoRoot -Scope Script
Set-Alias -Name Initialize-JournalDotnetEnv -Value Initialize-SdtDotnetEnv -Scope Script
Set-Alias -Name Initialize-JournalPipEnv -Value Initialize-SdtPipEnv -Scope Script
Set-Alias -Name Initialize-JournalHuggingFaceEnv -Value Initialize-SdtHuggingFaceEnv -Scope Script

View File

@ -0,0 +1,315 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
from typing import Dict, Iterable, List, Sequence
PROXY_VARS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"GIT_HTTP_PROXY",
"GIT_HTTPS_PROXY",
"PIP_NO_INDEX",
]
def resolve_repo_root(start: str | None = None) -> pathlib.Path:
base = pathlib.Path(start or os.getcwd()).resolve()
# Preferred marker for SDT-managed projects.
for cur in [base, *base.parents]:
cfg = cur / "devtool.json"
if cfg.exists():
hints = load_project_root_hints(cur)
if not hints:
return cur
if any(_hint_matches(cur, hint) for hint in hints):
return cur
# Fall back to git root when available.
try:
proc = subprocess.run(
["git", "-C", str(base), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if proc.returncode == 0:
git_root = proc.stdout.strip()
if git_root:
return pathlib.Path(git_root).resolve()
except Exception:
pass
return base
def load_project_root_hints(repo_root: pathlib.Path) -> list[str]:
cfg = repo_root / "devtool.json"
if not cfg.exists():
return []
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
hints = data.get("project", {}).get("rootHints", [])
return [str(x) for x in hints if isinstance(x, str) and x.strip()]
except Exception:
return []
def ensure_dirs(paths: List[pathlib.Path]) -> None:
for p in paths:
p.mkdir(parents=True, exist_ok=True)
def clean_proxy_env(env: Dict[str, str]) -> None:
for k in PROXY_VARS:
env.pop(k, None)
def dotnet_env(repo_root: pathlib.Path) -> Dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
dotnet_cli_home = repo_root / ".dotnet_home"
nuget_packages = repo_root / ".nuget" / "packages"
nuget_http_cache = repo_root / ".nuget" / "http-cache"
ensure_dirs([dotnet_cli_home, nuget_packages, nuget_http_cache])
env["DOTNET_CLI_HOME"] = str(dotnet_cli_home)
env["NUGET_PACKAGES"] = str(nuget_packages)
env["NUGET_HTTP_CACHE_PATH"] = str(nuget_http_cache)
env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
env["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "0"
env["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "0"
env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
env["NUGET_CERT_REVOCATION_MODE"] = "offline"
return env
def pip_env(repo_root: pathlib.Path) -> Dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
pip_cache = repo_root / ".pip" / "cache"
pip_tmp = repo_root / ".tmp" / "pip-temp"
ensure_dirs([pip_cache, pip_tmp])
env["PIP_CACHE_DIR"] = str(pip_cache)
env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
env["PIP_DEFAULT_TIMEOUT"] = "30"
env["PIP_RETRIES"] = "2"
env["TEMP"] = str(pip_tmp)
env["TMP"] = str(pip_tmp)
return env
def run(command: str, args: List[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> int:
resolved = resolve_command(command)
try:
proc = subprocess.run([resolved, *args], cwd=str(cwd), env=env, check=False)
return proc.returncode
except FileNotFoundError:
print(f"Command not found: {resolved}", file=sys.stderr)
return 127
def run_capture(command: str, args: Sequence[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> tuple[int, str, str]:
resolved = resolve_command(command)
try:
proc = subprocess.run(
[resolved, *args],
cwd=str(cwd),
env=env,
capture_output=True,
text=True,
check=False,
)
return proc.returncode, proc.stdout, proc.stderr
except FileNotFoundError:
return 127, "", f"Command not found: {resolved}"
def resolve_command(command: str) -> str:
if not command:
return command
if os.name != "nt":
return command
if any(sep in command for sep in ("\\", "/")):
return command
if pathlib.Path(command).suffix:
found = shutil.which(command)
return found or command
candidates = []
lowered = command.lower()
if lowered in ("npm", "npx", "pnpm", "yarn", "tauri"):
candidates.extend([f"{command}.cmd", f"{command}.exe", f"{command}.bat", command])
else:
candidates.append(command)
for c in candidates:
found = _which_windows(c)
if found:
name = pathlib.Path(found).name.lower()
if name in ("npm", "npx", "pnpm", "yarn", "tauri"):
shim = pathlib.Path(found).with_name(name + ".cmd")
if shim.exists():
return str(shim)
return found
if lowered in ("npm", "npx", "pnpm", "yarn"):
node = _which_windows("node.exe") or _which_windows("node")
if node:
node_dir = pathlib.Path(node).parent
shim = node_dir / f"{lowered}.cmd"
if shim.exists():
return str(shim)
return candidates[-1]
def _hint_matches(root: pathlib.Path, hint: str) -> bool:
h = hint.strip()
if not h:
return False
has_glob = any(ch in h for ch in ("*", "?", "["))
if has_glob:
# Match both anywhere in root and directly at root-level for common hints like "*.sln".
if any(root.glob(h)):
return True
return any(root.rglob(h))
marker = root / h
if marker.exists():
return True
# If hint is just a filename marker, look bounded in tree.
if not any(sep in h for sep in ("\\", "/")):
return any(p.name == h for p in root.rglob(h))
return False
def _expand_windows_path_segment(segment: str) -> str:
expanded = segment
# Expand %VAR% tokens repeatedly for nested references.
for _ in range(4):
next_value = os.path.expandvars(expanded)
if next_value == expanded:
break
expanded = next_value
return expanded
def _which_windows(command: str) -> str | None:
found = shutil.which(command)
if found:
return found
if os.name != "nt":
return None
path_value = os.environ.get("PATH", "")
pathext = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD")
exts = [e.lower() for e in pathext.split(";") if e]
has_ext = pathlib.Path(command).suffix != ""
names = [command] if has_ext else [command, *(command + e.lower() for e in exts)]
for raw_segment in path_value.split(os.pathsep):
segment = _expand_windows_path_segment(raw_segment.strip())
if not segment:
continue
base = pathlib.Path(segment)
for name in names:
candidate = base / name
if candidate.exists():
return str(candidate)
return None
def sha256_files(paths: Iterable[pathlib.Path]) -> str:
h = hashlib.sha256()
for p in paths:
if not p.exists():
continue
h.update(p.read_bytes())
return h.hexdigest()
def first_existing(paths: Iterable[pathlib.Path]) -> pathlib.Path | None:
for p in paths:
if p.exists():
return p
return None
def find_csproj(repo_root: pathlib.Path, hints: Sequence[str] | None = None) -> pathlib.Path | None:
if hints:
for hint in hints:
candidate = (repo_root / hint).resolve()
if candidate.exists() and candidate.suffix.lower() == ".csproj":
return candidate
csprojs = sorted(repo_root.rglob("*.csproj"))
if not csprojs:
return None
if len(csprojs) == 1:
return csprojs[0]
return None
def find_csproj_by_keyword(repo_root: pathlib.Path, keywords: Sequence[str]) -> pathlib.Path | None:
kws = [k.lower() for k in keywords]
matches: list[pathlib.Path] = []
for p in repo_root.rglob("*.csproj"):
text = str(p).lower()
if any(k in text for k in kws):
matches.append(p)
if len(matches) == 1:
return matches[0]
return None
def find_node_app_root(repo_root: pathlib.Path, preferred: str | None = None) -> pathlib.Path | None:
if preferred:
p = (repo_root / preferred).resolve()
if (p / "package.json").exists():
return p
direct = repo_root / "package.json"
if direct.exists():
return repo_root
tauri_candidates = []
for package_json in repo_root.rglob("package.json"):
d = package_json.parent
if (d / "src-tauri" / "tauri.conf.json").exists():
tauri_candidates.append(d)
if len(tauri_candidates) == 1:
return tauri_candidates[0]
all_candidates = [p.parent for p in repo_root.rglob("package.json")]
if len(all_candidates) == 1:
return all_candidates[0]
return None
def newest_file(search_root: pathlib.Path, pattern: str) -> pathlib.Path | None:
if not search_root.exists():
return None
files = [p for p in search_root.rglob(pattern) if p.is_file() and "\\obj\\" not in str(p).replace("/", "\\")]
if not files:
return None
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return files[0]

View File

@ -0,0 +1,82 @@
#!/usr/bin/env python3
import argparse
import os
import shutil
from pathlib import Path
from script_common import newest_file, resolve_repo_root
def copy_tree_contents(src: Path, dst: Path) -> None:
dst.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
target = dst / item.name
if item.is_dir():
if target.exists():
shutil.rmtree(target)
shutil.copytree(item, target)
else:
shutil.copy2(item, target)
def main() -> int:
parser = argparse.ArgumentParser(description="Sync newest built assets into output folder")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--output-dir", default="output")
parser.add_argument("--web-build-dir", default=None, help="Path to web build output")
parser.add_argument("--sidecar-bin-dir", default=None, help="Path to sidecar bin root")
parser.add_argument("--gateway-bin-dir", default=None, help="Path to gateway bin root")
parser.add_argument("--tauri-target-dir", default=None, help="Path to tauri target root")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
output_dir = (repo_root / args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
web_build = (repo_root / args.web_build_dir).resolve() if args.web_build_dir else None
if web_build is None:
web_build = next((p for p in repo_root.rglob("build") if (p.parent / "package.json").exists()), None)
if web_build is not None and web_build.exists():
web_out = output_dir / "webgateway" / "wwwroot"
copy_tree_contents(web_build, web_out)
print(f"Synced web assets -> {web_out}")
sidecar_bin = (repo_root / args.sidecar_bin_dir).resolve() if args.sidecar_bin_dir else None
if sidecar_bin is None:
sidecar_proj = next((p.parent for p in repo_root.rglob("*.csproj") if "sidecar" in str(p).lower()), None)
sidecar_bin = sidecar_proj / "bin" if sidecar_proj else None
if sidecar_bin is not None:
sidecar_pattern = "*.exe" if os.name == "nt" else "*"
sidecar_exe = newest_file(sidecar_bin, sidecar_pattern)
if sidecar_exe is not None:
copy_tree_contents(sidecar_exe.parent, output_dir)
print(f"Synced sidecar -> {output_dir}")
gateway_bin = (repo_root / args.gateway_bin_dir).resolve() if args.gateway_bin_dir else None
if gateway_bin is None:
gateway_proj = next((p.parent for p in repo_root.rglob("*.csproj") if "gateway" in str(p).lower()), None)
gateway_bin = gateway_proj / "bin" if gateway_proj else None
if gateway_bin is not None:
gateway_pattern = "*.exe" if os.name == "nt" else "*"
gw_exe = newest_file(gateway_bin, gateway_pattern)
if gw_exe is not None:
gw_out = output_dir / "webgateway"
copy_tree_contents(gw_exe.parent, gw_out)
print(f"Synced gateway -> {gw_out}")
tauri_target = (repo_root / args.tauri_target_dir).resolve() if args.tauri_target_dir else None
if tauri_target is None:
tauri_target = next((p for p in repo_root.rglob("src-tauri") if (p / "target").exists()), None)
tauri_target = tauri_target / "target" if tauri_target else None
if tauri_target is not None:
app_exe = newest_file(tauri_target, "*.exe")
if app_exe is not None:
shutil.copy2(app_exe, output_dir / app_exe.name)
print(f"Synced desktop app ({app_exe.name}) -> {output_dir}")
print("Sync complete.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,31 @@
using Sdt.Config;
using Sdt.Core;
using Xunit;
namespace DevTool.Tests;
public sealed class ActionRunnerLegacyPwshTests
{
[Fact]
public async Task LegacyPwshTarget_ReroutesToPythonScript_WhenPs1Missing()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-actionrunner-" + Guid.NewGuid().ToString("N"));
var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(scripts);
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('rerouted')");
var step = new WorkflowStep
{
Id = "legacy",
Label = "legacy",
Command = "pwsh",
Args = ["-NoProfile", "-File", "scripts/publish-app.ps1", "-Target", "web"],
WorkingDir = "."
};
var runner = new ActionRunner();
var run = await runner.RunStepAsync(step, root, (_, _) => { });
Assert.True(run.Success);
}
}

View File

@ -0,0 +1,124 @@
using Sdt.Core;
using Sdt.Config;
using Xunit;
namespace DevTool.Tests;
public sealed class CommandResolverTests
{
[Fact]
public void Resolve_Npm_OnWindows_UsesCmdShim()
{
var resolved = CommandResolver.Resolve("npm");
if (OperatingSystem.IsWindows())
Assert.True(
resolved.EndsWith("npm.cmd", StringComparison.OrdinalIgnoreCase) ||
resolved.EndsWith("\\npm", StringComparison.OrdinalIgnoreCase) ||
string.Equals(resolved, "npm.cmd", StringComparison.OrdinalIgnoreCase),
$"Resolved npm path was '{resolved}'");
else
Assert.Equal("npm", resolved);
}
[Fact]
public void Resolve_PathOrExtension_Unchanged()
{
Assert.Equal("C:\\tools\\npm.cmd", CommandResolver.Resolve("C:\\tools\\npm.cmd"));
var resolved = CommandResolver.Resolve("dotnet.exe");
if (OperatingSystem.IsWindows() && Path.IsPathRooted(resolved))
Assert.EndsWith("dotnet.exe", resolved, StringComparison.OrdinalIgnoreCase);
else
Assert.Equal("dotnet.exe", resolved);
}
[Fact]
public void Resolve_Npm_PrefersCmdShim_WhenBothBareAndCmdExist()
{
if (!OperatingSystem.IsWindows())
return;
var temp = Path.Combine(Path.GetTempPath(), "sdt-cmdresolve-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temp);
File.WriteAllText(Path.Combine(temp, "npm"), "");
File.WriteAllText(Path.Combine(temp, "npm.cmd"), "@echo off");
var originalPath = Environment.GetEnvironmentVariable("PATH");
try
{
Environment.SetEnvironmentVariable("PATH", temp);
var resolved = CommandResolver.Resolve("npm");
Assert.EndsWith("npm.cmd", resolved, StringComparison.OrdinalIgnoreCase);
}
finally
{
Environment.SetEnvironmentVariable("PATH", originalPath);
try { Directory.Delete(temp, recursive: true); } catch { }
}
}
[Fact]
public void ResolveWithTrace_ConfiguredOverride_IsUsed()
{
if (!OperatingSystem.IsWindows())
return;
var temp = Path.Combine(Path.GetTempPath(), "sdt-cmdresolve-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temp);
var overridePath = Path.Combine(temp, "npm.cmd");
File.WriteAllText(overridePath, "@echo off");
var cfg = new DevToolConfig
{
Tooling = new ToolingConfig
{
Tools =
[
new ToolInstallDefinition
{
Tool = "npm",
Executables = [overridePath]
}
]
}
};
var resolved = CommandResolver.ResolveWithTrace("npm", cfg, "npm");
Assert.Equal(CommandResolutionSource.ConfiguredOverride, resolved.Source);
Assert.EndsWith("npm.cmd", resolved.Resolved, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ResolveWithTrace_ExpandsWindowsPathTokens()
{
if (!OperatingSystem.IsWindows())
return;
var nvmHome = Path.Combine(Path.GetTempPath(), "sdt-nvmhome-" + Guid.NewGuid().ToString("N"));
var nvmLink = Path.Combine(Path.GetTempPath(), "sdt-nvmlink-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(nvmHome);
Directory.CreateDirectory(nvmLink);
File.WriteAllText(Path.Combine(nvmLink, "npm.cmd"), "@echo off");
var originalPath = Environment.GetEnvironmentVariable("PATH");
var originalHome = Environment.GetEnvironmentVariable("NVM_HOME");
var originalLink = Environment.GetEnvironmentVariable("NVM_SYMLINK");
try
{
Environment.SetEnvironmentVariable("NVM_HOME", nvmHome);
Environment.SetEnvironmentVariable("NVM_SYMLINK", nvmLink);
Environment.SetEnvironmentVariable("PATH", "%NVM_HOME%;%NVM_SYMLINK%");
var result = CommandResolver.ResolveWithTrace("npm");
Assert.True(result.Source is CommandResolutionSource.Shim or CommandResolutionSource.Path);
Assert.EndsWith("npm.cmd", result.Resolved, StringComparison.OrdinalIgnoreCase);
}
finally
{
Environment.SetEnvironmentVariable("PATH", originalPath);
Environment.SetEnvironmentVariable("NVM_HOME", originalHome);
Environment.SetEnvironmentVariable("NVM_SYMLINK", originalLink);
try { Directory.Delete(nvmHome, recursive: true); } catch { }
try { Directory.Delete(nvmLink, recursive: true); } catch { }
}
}
}

View File

@ -0,0 +1,108 @@
using Sdt.Config;
using Xunit;
namespace DevTool.Tests;
public sealed class ConfigBootstrapperTests
{
[Fact]
public void Scan_DetectsDotnetAndNode()
{
var root = CreateTempDir();
File.WriteAllText(Path.Combine(root, "sample.sln"), "");
File.WriteAllText(Path.Combine(root, "package.json"), "{}");
var scan = ConfigBootstrapper.Scan(root);
Assert.Contains("dotnet", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
Assert.Contains("node", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
Assert.Contains("npm", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void Scan_DetectsPythonFromScriptsDirectory()
{
var root = CreateTempDir();
var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(scripts);
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
var scan = ConfigBootstrapper.Scan(root);
Assert.Contains("python", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void BuildDefaultConfig_ProducesWorkflowsAndDebugSection()
{
var scan = new BootstrapScanResult(
ProjectRoot: Path.GetTempPath(),
ProjectName: "demo",
ToolFamilies: ["dotnet", "git"],
NodeWorkingDir: null,
PythonRequirementsFile: null,
HasDockerCompose: false,
RootHints: ["*.sln"]);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
Assert.NotNull(cfg.Debug);
Assert.Contains(cfg.Workflows, w => w.Id == "build");
Assert.Contains(cfg.Workflows, w => w.Id == "repo-health");
Assert.False(cfg.Debug!.Diagnostics.IncludeAllEnv);
Assert.Contains("SDT_LOG_LEVEL", cfg.Debug.Diagnostics.CaptureEnvKeys);
}
[Fact]
public void BuildDefaultConfig_IncludesScriptDrivenWorkflow_WhenHelpersExist()
{
var root = CreateTempDir();
var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(scripts);
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
File.WriteAllText(Path.Combine(scripts, "publish-sidecar.py"), "print('ok')");
var scan = ConfigBootstrapper.Scan(root);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
Assert.Contains(cfg.Workflows, w => w.Id == "web");
Assert.Contains(cfg.Workflows, w => w.Id == "sidecar");
}
[Fact]
public void WriteDefaultConfig_WritesDevtoolJson()
{
var root = CreateTempDir();
var scan = new BootstrapScanResult(
ProjectRoot: root,
ProjectName: "demo",
ToolFamilies: ["dotnet"],
NodeWorkingDir: null,
PythonRequirementsFile: null,
HasDockerCompose: false,
RootHints: ["*.sln"]);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
var path = ConfigBootstrapper.WriteDefaultConfig(root, cfg);
Assert.True(File.Exists(path));
}
[Fact]
public void Scan_IgnoresExcludedDirectories_ForToolDetection()
{
var root = CreateTempDir();
var nodeModules = Path.Combine(root, "node_modules", "nested");
Directory.CreateDirectory(nodeModules);
File.WriteAllText(Path.Combine(nodeModules, "package.json"), "{}");
var scan = ConfigBootstrapper.Scan(root);
Assert.DoesNotContain("node", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
}
private static string CreateTempDir()
{
var path = Path.Combine(Path.GetTempPath(), "sdt-bootstrap-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
}

View File

@ -0,0 +1,54 @@
using Sdt.Config;
using Sdt.Core;
using Xunit;
namespace DevTool.Tests;
public sealed class ConfigDoctorAutoFixServiceTests
{
[Fact]
public void FindMissingWorkingDirectories_ReturnsMissingPaths()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-autofix-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
Directory.CreateDirectory(Path.Combine(root, "exists"));
var cfg = new DevToolConfig
{
Workflows =
[
new WorkflowDefinition
{
Id = "build",
Label = "Build",
Steps =
[
new WorkflowStep { Id = "s1", Label = "S1", Action = "dotnet-build", WorkingDir = "exists" },
new WorkflowStep { Id = "s2", Label = "S2", Action = "dotnet-build", WorkingDir = "missing/sub" }
]
}
]
};
var service = new ConfigDoctorAutoFixService();
var missing = service.FindMissingWorkingDirectories(cfg, root);
Assert.Single(missing);
Assert.EndsWith(Path.Combine("missing", "sub"), missing[0], StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CreateMissingWorkingDirectories_CreatesPaths()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-autofix-" + Guid.NewGuid().ToString("N"));
var path = Path.Combine(root, "a", "b", "c");
Directory.CreateDirectory(root);
var service = new ConfigDoctorAutoFixService();
var result = service.CreateMissingWorkingDirectories([path]);
Assert.True(result.Success);
Assert.True(Directory.Exists(path));
Assert.Equal(1, result.CreatedDirectories);
}
}

View File

@ -0,0 +1,80 @@
using Sdt.Config;
using Sdt.Core;
using Xunit;
namespace DevTool.Tests;
public sealed class ConfigDoctorServiceTests
{
[Fact]
public async Task TargetsOnly_Config_IsFlaggedAsFail()
{
var config = new DevToolConfig
{
Targets =
[
new BuildTarget
{
Id = "build",
Label = "Build",
Command = "dotnet",
Args = ["build"]
}
],
Workflows = []
};
var doctor = new ConfigDoctorService(new AlwaysAvailableProbe(), new RequirementResolver());
var report = await doctor.RunAsync(config, Directory.GetCurrentDirectory());
Assert.Contains(report.Checks, c => c.Name == "Legacy schema" && c.Status == DoctorStatus.Fail);
}
[Fact]
public async Task MissingTool_IsReportedWithFix()
{
var config = new DevToolConfig
{
Workflows =
[
new WorkflowDefinition
{
Id = "build",
Label = "Build",
Steps =
[
new WorkflowStep { Id = "s1", Label = "Build", Command = "dotnet", Args = ["build"] }
]
}
]
};
var doctor = new ConfigDoctorService(new AlwaysMissingProbe(), new RequirementResolver());
var report = await doctor.RunAsync(config, Directory.GetCurrentDirectory());
Assert.Contains(report.Checks, c =>
c.Name.Equals("Tool: dotnet", StringComparison.OrdinalIgnoreCase) &&
c.Status == DoctorStatus.Fail &&
!string.IsNullOrWhiteSpace(c.Fix));
}
private sealed class AlwaysAvailableProbe : IToolProbe
{
public Task<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new ProbeResult(tool, true, Version: "1.0.0"));
}
private sealed class AlwaysMissingProbe : IToolProbe
{
public Task<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new ProbeResult(tool, false, Details: "Fallback: unresolved command"));
}
}

View File

@ -0,0 +1,40 @@
using System.Text.Json;
using Sdt.Config;
using Xunit;
namespace DevTool.Tests;
public sealed class DebugConfigTests
{
[Fact]
public void DebugSectionAbsent_DeserializesWithSafeDefaults()
{
const string json = """
{
"name": "Test",
"version": "1.0.0",
"workflows": []
}
""";
var cfg = JsonSerializer.Deserialize<DevToolConfig>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
});
Assert.NotNull(cfg);
Assert.Null(cfg!.Debug);
}
[Fact]
public void DebugDiagnostics_DefaultOutputDir_IsSdtDebug()
{
var options = new DebugDiagnosticsOptions();
Assert.True(options.Enabled);
Assert.True(options.BundleOnFailure);
Assert.Equal(".sdt/debug", options.OutputDir);
}
}

View File

@ -0,0 +1,191 @@
using Sdt.Config;
using Sdt.Core;
using Sdt.Core.Debug;
using Sdt.Runner;
using System.Text.Json;
using Xunit;
namespace DevTool.Tests;
public sealed class DebugServicesTests
{
[Fact]
public async Task DebugRunner_MissingPrereqDeclined_ReturnsUserDeclined()
{
var runner = new DebugProfileRunner(
new FakeProbeService(false),
new FakeInstallerService(true));
var profile = new DebugProfileDefinition
{
Id = "p1",
Label = "Profile",
Type = "python",
Command = "python",
Args = ["--version"],
Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }]
};
var result = await runner.RunAsync(
profile,
new DevToolConfig(),
Directory.GetCurrentDirectory(),
verbose: false,
confirmInstallAsync: (_, _) => Task.FromResult(false),
onOutput: (_, _) => { });
Assert.False(result.Success);
Assert.Equal(ExecutionStopReason.UserDeclined, result.StopReason);
}
[Fact]
public async Task DiagnosticsBundle_WritesFiles()
{
var service = new DiagnosticsBundleService();
var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var request = new DiagnosticsBundleRequest(
Category: "workflow",
ProjectRoot: root,
SummaryMessage: "failed",
OutputLines: ["hello"],
WorkflowSteps: [],
Probes: [],
DiagnosticsOptions: new DebugDiagnosticsOptions { OutputDir = ".sdt/debug" },
Config: new DevToolConfig(),
StopReason: ExecutionStopReason.CommandFailed);
var result = await service.WriteBundleAsync(request);
Assert.True(result.Success);
Assert.True(File.Exists(Path.Combine(result.BundleDirectory, "summary.json")));
Assert.True(File.Exists(Path.Combine(result.BundleDirectory, "output.log")));
}
[Fact]
public async Task DiagnosticsBundle_EmptyAllowlist_CapturesNoEnvByDefault()
{
var service = new DiagnosticsBundleService();
var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var request = new DiagnosticsBundleRequest(
Category: "workflow",
ProjectRoot: root,
SummaryMessage: "failed",
OutputLines: [],
WorkflowSteps: [],
Probes: [],
DiagnosticsOptions: new DebugDiagnosticsOptions
{
OutputDir = ".sdt/debug",
IncludeAllEnv = false,
CaptureEnvKeys = []
},
Config: new DevToolConfig(),
StopReason: ExecutionStopReason.CommandFailed);
var result = await service.WriteBundleAsync(request);
Assert.True(result.Success);
var envJson = await File.ReadAllTextAsync(Path.Combine(result.BundleDirectory, "env.json"));
using var doc = JsonDocument.Parse(envJson);
Assert.Empty(doc.RootElement.EnumerateObject());
}
[Fact]
public async Task DiagnosticsBundle_Allowlist_CapturesOnlyListedKeys()
{
var service = new DiagnosticsBundleService();
var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
Environment.SetEnvironmentVariable("SDT_TEST_ENV_A", "A");
Environment.SetEnvironmentVariable("SDT_TEST_ENV_B", "B");
var request = new DiagnosticsBundleRequest(
Category: "workflow",
ProjectRoot: root,
SummaryMessage: "failed",
OutputLines: [],
WorkflowSteps: [],
Probes: [],
DiagnosticsOptions: new DebugDiagnosticsOptions
{
OutputDir = ".sdt/debug",
IncludeAllEnv = false,
CaptureEnvKeys = ["SDT_TEST_ENV_A"]
},
Config: new DevToolConfig(),
StopReason: ExecutionStopReason.CommandFailed);
var result = await service.WriteBundleAsync(request);
Assert.True(result.Success);
var envJson = await File.ReadAllTextAsync(Path.Combine(result.BundleDirectory, "env.json"));
using var doc = JsonDocument.Parse(envJson);
Assert.True(doc.RootElement.TryGetProperty("SDT_TEST_ENV_A", out _));
Assert.False(doc.RootElement.TryGetProperty("SDT_TEST_ENV_B", out _));
}
[Fact]
public async Task DebugRunner_EmitsRunEvents()
{
var runner = new DebugProfileRunner(
new FakeProbeService(true),
new FakeInstallerService(true));
var profile = new DebugProfileDefinition
{
Id = "p1",
Label = "Profile",
Type = "python",
Command = "python",
Args = ["--version"],
Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }]
};
var events = new List<RunEvent>();
var result = await runner.RunAsync(
profile,
new DevToolConfig(),
Directory.GetCurrentDirectory(),
verbose: false,
confirmInstallAsync: (_, _) => Task.FromResult(false),
onOutput: (_, _) => { },
onEvent: events.Add);
Assert.True(result.Success);
Assert.Contains(events, e => e.Type == RunEventType.DebugStarted);
Assert.Contains(events, e => e.Type == RunEventType.DebugCommandStarted);
Assert.Contains(events, e => e.Type == RunEventType.DebugCommandCompleted);
Assert.Contains(events, e => e.Type == RunEventType.DebugCompleted && e.Success == true);
}
private sealed class FakeProbeService(bool isAvailable) : IToolProbe
{
public Task<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new ProbeResult(tool, isAvailable, Version: isAvailable ? "1.0.0" : null));
}
private sealed class FakeInstallerService(bool success) : IPrereqInstaller
{
public Task<InstallPlan> GetInstallPlanAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new InstallPlan(tool, Supported: true, "test", [new InstallCommand("echo", ["ok"])]));
public Task<RunResult> RunInstallAsync(
InstallCommand command,
string projectRoot,
Action<string, bool> onOutput,
CancellationToken cancellationToken = default)
=> Task.FromResult(new RunResult(success ? 0 : 1, TimeSpan.FromMilliseconds(5)));
}
}

View File

@ -0,0 +1,120 @@
using System.Diagnostics;
using System.Text.Json;
using Xunit;
namespace DevTool.Tests;
public sealed class DevShellScriptTests
{
[Theory]
[InlineData("pwsh")]
[InlineData("bash")]
[InlineData("zsh")]
[InlineData("cmd")]
public async Task DevShellExport_ReturnsSuccess_ForSupportedShells(string shell)
{
var python = ResolvePython();
var result = await RunAsync(
python,
["scripts/dev_shell.py", "export", "--shell", shell, "--json"]);
Assert.Equal(0, result.ExitCode);
using var doc = JsonDocument.Parse(result.StdOut);
Assert.True(doc.RootElement.TryGetProperty("projectRoot", out _));
Assert.True(doc.RootElement.TryGetProperty("env", out _));
}
[Fact]
public async Task DevShellExport_InvalidShell_ReturnsExitCode3()
{
var python = ResolvePython();
var result = await RunAsync(
python,
["scripts/dev_shell.py", "export", "--shell", "fish"]);
Assert.Equal(3, result.ExitCode);
}
[Fact]
public async Task DevShellDoctor_ReturnsJson()
{
var python = ResolvePython();
var result = await RunAsync(
python,
["scripts/dev_shell.py", "doctor"]);
Assert.Equal(0, result.ExitCode);
using var doc = JsonDocument.Parse(result.StdOut);
Assert.True(doc.RootElement.TryGetProperty("repo_root", out _));
}
private static string ResolvePython()
{
var candidates = OperatingSystem.IsWindows()
? new[] { "python", "py" }
: new[] { "python3", "python" };
foreach (var candidate in candidates)
{
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = candidate,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
}
};
process.StartInfo.ArgumentList.Add("--version");
process.Start();
process.WaitForExit(2000);
if (process.ExitCode == 0)
return candidate;
}
catch
{
}
}
throw new InvalidOperationException("Python executable not found.");
}
private static async Task<(int ExitCode, string StdOut, string StdErr)> RunAsync(string command, IReadOnlyList<string> args)
{
var psi = new ProcessStartInfo
{
FileName = command,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = RepoRoot(),
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
using var process = new Process { StartInfo = psi };
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode, stdout, stderr);
}
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "devtool.json")))
return dir.FullName;
dir = dir.Parent;
}
throw new InvalidOperationException("Could not locate repo root.");
}
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\DevTool.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,88 @@
using System.Text.Json;
using Sdt.Config;
using Xunit;
namespace DevTool.Tests;
public sealed class LegacyModeTests
{
[Fact]
public void ConfigLoader_StrictMode_TargetsOnly_FailsAndWritesPreview()
{
var root = CreateTempDir();
WriteLegacyTargetsOnlyConfig(root);
Environment.SetEnvironmentVariable("SDT_LEGACY_MODE", null);
var ex = Assert.Throws<InvalidOperationException>(() => ConfigLoader.FindAndLoad(root));
Assert.Contains("Strict mode requires workflows", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.True(File.Exists(Path.Combine(root, "devtool.generated.workflows.json")));
}
[Fact]
public void ConfigLoader_CompatMode_TargetsOnly_Loads()
{
var root = CreateTempDir();
WriteLegacyTargetsOnlyConfig(root);
Environment.SetEnvironmentVariable("SDT_LEGACY_MODE", "compat");
try
{
var loaded = ConfigLoader.FindAndLoad(root);
Assert.NotNull(loaded);
Assert.Contains(loaded!.Warnings, w => w.Contains("legacy 'targets'", StringComparison.OrdinalIgnoreCase));
}
finally
{
Environment.SetEnvironmentVariable("SDT_LEGACY_MODE", null);
}
}
[Fact]
public void ApplyLegacyTargetMigration_RewritesConfigAndCreatesBackup()
{
var root = CreateTempDir();
WriteLegacyTargetsOnlyConfig(root);
var path = Path.Combine(root, "devtool.json");
var result = ConfigLoader.ApplyLegacyTargetMigration(path, createBackup: true);
Assert.True(result.Success);
Assert.True(File.Exists(path));
Assert.False(string.IsNullOrWhiteSpace(result.BackupPath));
Assert.True(File.Exists(result.BackupPath!));
var loaded = ConfigLoader.FindAndLoad(root);
Assert.NotNull(loaded);
Assert.NotEmpty(loaded!.Config.Workflows);
Assert.Empty(loaded.Config.Targets);
}
private static void WriteLegacyTargetsOnlyConfig(string root)
{
var cfg = new DevToolConfig
{
Name = "legacy",
Version = "0.1.0",
Targets =
[
new BuildTarget
{
Id = "build",
Label = "Build",
Command = "dotnet",
Args = ["build"]
}
],
Workflows = []
};
var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true });
File.WriteAllText(Path.Combine(root, "devtool.json"), json);
}
private static string CreateTempDir()
{
var path = Path.Combine(Path.GetTempPath(), "sdt-legacy-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
}

View File

@ -0,0 +1,99 @@
using Sdt.Config;
using Sdt.Core;
using Xunit;
namespace DevTool.Tests;
public sealed class PrereqInstallerServiceTests
{
[Fact]
public async Task PreferredInstallCommands_AreUsedFirst()
{
var svc = new PrereqInstallerService();
var cfg = new DevToolConfig
{
Tooling = new ToolingConfig
{
Tools =
[
new ToolInstallDefinition
{
Tool = "dotnet",
PreferredInstallCommands =
[
"echo install dotnet",
"dotnet --info"
]
}
]
}
};
var plan = await svc.GetInstallPlanAsync("dotnet", Directory.GetCurrentDirectory(), cfg);
Assert.True(plan.Supported);
Assert.Equal("dotnet", plan.Tool);
Assert.Equal(2, plan.Commands.Count);
Assert.Equal("echo", plan.Commands[0].Command);
Assert.Equal("dotnet", plan.Commands[1].Command);
}
[Fact]
public async Task DiagInstallPlanFailure_FallsBackToTemplatePlan()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-prereq-" + Guid.NewGuid().ToString("N"));
var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(scripts);
await File.WriteAllTextAsync(Path.Combine(scripts, "diag.py"), "import sys\nsys.exit(2)\n");
var svc = new PrereqInstallerService();
var plan = await svc.GetInstallPlanAsync("npm", root, new DevToolConfig());
Assert.True(plan.Supported);
Assert.NotEmpty(plan.Commands);
Assert.Contains("fallback", plan.Summary, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task DiagInstallPlanInvalidJson_FallsBackToTemplatePlan()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-prereq-" + Guid.NewGuid().ToString("N"));
var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(scripts);
await File.WriteAllTextAsync(Path.Combine(scripts, "diag.py"), "print('not-json')\n");
var svc = new PrereqInstallerService();
var plan = await svc.GetInstallPlanAsync("dotnet", root, new DevToolConfig());
Assert.True(plan.Supported);
Assert.NotEmpty(plan.Commands);
Assert.Contains("fallback", plan.Summary, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task TauriFallbackPlan_IsMultiStepAndClear()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-prereq-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var svc = new PrereqInstallerService();
var plan = await svc.GetInstallPlanAsync("tauri", root, new DevToolConfig());
Assert.True(plan.Supported);
Assert.True(plan.Commands.Count >= 3);
Assert.Contains("tauri", plan.Summary, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task TauriFallbackPlan_IncludesRustAndCliCommands()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-prereq-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var svc = new PrereqInstallerService();
var plan = await svc.GetInstallPlanAsync("tauri", root, new DevToolConfig());
Assert.Contains(plan.Commands, c => c.Args.Any(a => a.Contains("rustup", StringComparison.OrdinalIgnoreCase)));
Assert.Contains(plan.Commands, c => c.Args.Any(a => a.Contains("@tauri-apps/cli", StringComparison.OrdinalIgnoreCase)));
}
}

View File

@ -0,0 +1,44 @@
using Sdt.Config;
using Sdt.Core;
using Xunit;
namespace DevTool.Tests;
public sealed class RequirementResolverTests
{
private readonly RequirementResolver _resolver = new();
[Fact]
public void TauriBuildAction_RequiresNodeNpmCargo_NotGlobalTauri()
{
var step = new WorkflowStep
{
Id = "tauri",
Label = "tauri",
Action = "tauri-build",
};
var tools = _resolver.Resolve(step).Select(r => r.Tool).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Contains("node", tools);
Assert.Contains("npm", tools);
Assert.Contains("cargo", tools);
Assert.DoesNotContain("tauri", tools);
}
[Fact]
public void LegacyPwshTarget_InferenceMatchesExpected()
{
var target = new BuildTarget
{
Id = "web",
Label = "Web",
Command = "pwsh",
Args = ["-NoProfile", "-File", "scripts/publish-app.ps1", "-Target", "web"],
};
var tools = _resolver.Resolve(target).Select(r => r.Tool).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Contains("python", tools);
Assert.Contains("node", tools);
Assert.Contains("npm", tools);
}
}

View File

@ -0,0 +1,31 @@
using System.Text.Json;
using Sdt.Core;
using Xunit;
namespace DevTool.Tests;
public sealed class RunEventJsonlRecorderTests
{
[Fact]
public void Recorder_WritesJsonlEvents()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-events-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
string path;
using (var recorder = RunEventJsonlRecorder.Create(root, "workflow"))
{
path = recorder.FilePath;
recorder.Write(new RunEvent("workflow", RunEventType.WorkflowStarted, "started", WorkflowId: "build"));
recorder.Write(new RunEvent("workflow", RunEventType.WorkflowCompleted, "done", WorkflowId: "build", Success: true));
}
Assert.True(File.Exists(path));
var lines = File.ReadAllLines(path);
Assert.Equal(2, lines.Length);
using var doc = JsonDocument.Parse(lines[0]);
Assert.Equal("workflow", doc.RootElement.GetProperty("category").GetString());
Assert.Equal("WorkflowStarted", doc.RootElement.GetProperty("type").GetString());
}
}

View File

@ -0,0 +1,48 @@
using Sdt.Core;
using Xunit;
namespace DevTool.Tests;
public sealed class RunEventLogReaderTests
{
[Fact]
public void ReadEvents_ParsesValidJsonlLines()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-logreader-" + Guid.NewGuid().ToString("N"));
var dir = Path.Combine(root, ".sdt", "events");
Directory.CreateDirectory(dir);
var file = Path.Combine(dir, "workflow-test.jsonl");
File.WriteAllLines(file,
[
"""{"category":"workflow","type":"WorkflowStarted","message":"start","workflowId":"build","occurredAt":"2026-03-01T10:00:00Z"}""",
"""{"category":"workflow","type":"WorkflowCompleted","message":"done","workflowId":"build","success":true,"exitCode":0,"occurredAt":"2026-03-01T10:00:01Z"}"""
]);
var reader = new RunEventLogReader();
var events = reader.ReadEvents(file);
Assert.Equal(2, events.Count);
Assert.Equal(RunEventType.WorkflowStarted, events[0].Type);
Assert.Equal(RunEventType.WorkflowCompleted, events[1].Type);
Assert.True(events[1].Success);
}
[Fact]
public void ListEventFiles_ReturnsNewestFirst()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-logreader-" + Guid.NewGuid().ToString("N"));
var dir = Path.Combine(root, ".sdt", "events");
Directory.CreateDirectory(dir);
var older = Path.Combine(dir, "older.jsonl");
var newer = Path.Combine(dir, "newer.jsonl");
File.WriteAllText(older, "{}");
Thread.Sleep(20);
File.WriteAllText(newer, "{}");
var reader = new RunEventLogReader();
var files = reader.ListEventFiles(root);
Assert.True(files.Count >= 2);
Assert.Equal("newer.jsonl", files[0].Name);
}
}

View File

@ -0,0 +1,166 @@
using System.Diagnostics;
using Xunit;
namespace DevTool.Tests;
public sealed class ScriptCommonTests
{
[Fact]
public async Task ResolveRepoRoot_UsesGlobRootHints()
{
var root = CreateTempDir("sdt-script-root-");
var nested = Path.Combine(root, "src", "app");
Directory.CreateDirectory(nested);
await File.WriteAllTextAsync(Path.Combine(root, "sample.sln"), "");
await File.WriteAllTextAsync(Path.Combine(root, "devtool.json"), """
{
"name": "demo",
"version": "0.1.0",
"workflows": [],
"project": {
"rootHints": ["*.sln"]
}
}
""");
var output = await RunPythonAsync(nested, "import script_common; print(script_common.resolve_repo_root(r'" + Escape(nested) + "'))");
Assert.Equal(Path.GetFullPath(root), output.Trim());
}
[Fact]
public async Task ResolveRepoRoot_UsesDirectoryMarkerHints()
{
var root = CreateTempDir("sdt-script-root-");
var nested = Path.Combine(root, "child", "leaf");
Directory.CreateDirectory(nested);
Directory.CreateDirectory(Path.Combine(root, ".git"));
await File.WriteAllTextAsync(Path.Combine(root, "devtool.json"), """
{
"name": "demo",
"version": "0.1.0",
"workflows": [],
"project": {
"rootHints": [".git", "package.json"]
}
}
""");
var output = await RunPythonAsync(nested, "import script_common; print(script_common.resolve_repo_root(r'" + Escape(nested) + "'))");
Assert.Equal(Path.GetFullPath(root), output.Trim());
}
[Fact]
public async Task ResolveCommand_ExpandsWindowsPathTokens()
{
if (!OperatingSystem.IsWindows())
return;
var root = CreateTempDir("sdt-script-cmd-");
var shimDir = Path.Combine(root, "nodejs");
Directory.CreateDirectory(shimDir);
await File.WriteAllTextAsync(Path.Combine(shimDir, "npm.cmd"), "@echo off");
var output = await RunPythonAsync(
root,
"import script_common; print(script_common.resolve_command('npm'))",
new Dictionary<string, string?>
{
["NVM_HOME"] = root,
["NVM_SYMLINK"] = shimDir,
["PATH"] = "%NVM_HOME%;%NVM_SYMLINK%",
});
Assert.EndsWith("npm.cmd", output.Trim(), StringComparison.OrdinalIgnoreCase);
}
private static async Task<string> RunPythonAsync(
string workingDir,
string script,
IReadOnlyDictionary<string, string?>? env = null)
{
var python = ResolvePython();
var psi = new ProcessStartInfo
{
FileName = python,
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-c");
psi.ArgumentList.Add($"import sys; sys.path.insert(0, r'{Escape(Path.Combine(ProjectRepoRoot(), "scripts"))}'); {script}");
if (env is not null)
{
foreach (var pair in env)
psi.Environment[pair.Key] = pair.Value ?? string.Empty;
}
using var process = new Process { StartInfo = psi };
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
throw new InvalidOperationException($"Python exited {process.ExitCode}: {stderr}");
return stdout;
}
private static string ResolvePython()
{
var candidates = OperatingSystem.IsWindows() ? new[] { "python", "py" } : new[] { "python3", "python" };
foreach (var candidate in candidates)
{
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = candidate,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
}
};
process.StartInfo.ArgumentList.Add("--version");
process.Start();
process.WaitForExit(2000);
if (process.ExitCode == 0)
return candidate;
}
catch
{
}
}
throw new InvalidOperationException("Python executable not found.");
}
private static string CreateTempDir(string prefix)
{
var path = Path.Combine(Path.GetTempPath(), prefix + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("'", "\\'");
private static string ProjectRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "devtool.json")) &&
File.Exists(Path.Combine(dir.FullName, "scripts", "script_common.py")))
{
return dir.FullName;
}
dir = dir.Parent;
}
throw new InvalidOperationException("Could not locate project repo root.");
}
}

View File

@ -0,0 +1,179 @@
using System.Diagnostics;
using System.Text.Json;
using Xunit;
namespace DevTool.Tests;
public sealed class ScriptSmokeTests
{
[Fact]
public async Task DiagProbe_JsonContract_IsValid()
{
var python = ResolvePython();
var result = await RunAsync(
python,
["scripts/diag.py", "probe", "--tool", "python", "--json"]);
Assert.Equal(0, result.ExitCode);
using var doc = JsonDocument.Parse(result.StdOut);
Assert.True(doc.RootElement.TryGetProperty("tool", out _));
Assert.True(doc.RootElement.TryGetProperty("available", out _));
}
[Fact]
public async Task BuildAction_InvalidRequirements_PropagatesNonZeroAndJson()
{
var python = ResolvePython();
var missingReq = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt");
var result = await RunAsync(
python,
[
"scripts/build.py",
"python-pip-install",
"--project-root",
RepoRoot(),
"--requirements",
missingReq,
"--json"
]);
Assert.NotEqual(0, result.ExitCode);
var jsonText = ExtractLastJsonObject(result.StdOut);
using var doc = JsonDocument.Parse(jsonText);
Assert.True(doc.RootElement.TryGetProperty("exit_code", out var code));
Assert.NotEqual(0, code.GetInt32());
Assert.True(doc.RootElement.TryGetProperty("failure_reason", out _));
}
[Fact]
public async Task BuildAction_DotnetRestore_CommandNotFoundStillReturnsJson()
{
var python = ResolvePython();
var result = await RunAsync(
python,
[
"scripts/build.py",
"dotnet-restore",
"--project-root",
RepoRoot(),
"--json"
]);
var jsonText = ExtractLastJsonObject(result.StdOut);
using var doc = JsonDocument.Parse(jsonText);
Assert.True(doc.RootElement.TryGetProperty("exit_code", out _));
Assert.True(doc.RootElement.TryGetProperty("status", out _));
}
[Fact]
public async Task BuildAction_DotnetBuild_AutoSelectsSlnTarget_WhenSingleSlnFound()
{
var python = ResolvePython();
var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-dotnet-target-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var sln = Path.Combine(tempRoot, "sample.sln");
await File.WriteAllTextAsync(sln, "Microsoft Visual Studio Solution File, Format Version 12.00");
var result = await RunAsync(
python,
[
"scripts/build.py",
"dotnet-build",
"--project-root",
tempRoot,
"--working-dir",
".",
"--json"
]);
var jsonText = ExtractLastJsonObject(result.StdOut);
using var doc = JsonDocument.Parse(jsonText);
Assert.True(doc.RootElement.TryGetProperty("args", out var args));
Assert.Contains(args.EnumerateArray().Select(x => x.GetString()), x => string.Equals(x, sln, StringComparison.OrdinalIgnoreCase));
}
private static string ResolvePython()
{
var candidates = OperatingSystem.IsWindows()
? new[] { "python", "py" }
: new[] { "python3", "python" };
foreach (var candidate in candidates)
{
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = candidate,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
}
};
process.StartInfo.ArgumentList.Add("--version");
process.Start();
process.WaitForExit(2000);
if (process.ExitCode == 0)
return candidate;
}
catch
{
}
}
throw new InvalidOperationException("Python executable not found for script smoke tests.");
}
private static async Task<(int ExitCode, string StdOut, string StdErr)> RunAsync(string command, IReadOnlyList<string> args)
{
var psi = new ProcessStartInfo
{
FileName = command,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = RepoRoot(),
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
using var process = new Process { StartInfo = psi };
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode, stdout, stderr);
}
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "devtool.json")))
return dir.FullName;
dir = dir.Parent;
}
throw new InvalidOperationException("Could not locate repo root (devtool.json not found).");
}
private static string ExtractLastJsonObject(string text)
{
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
for (var i = lines.Length - 1; i >= 0; i--)
{
var line = lines[i];
if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal))
return line;
}
throw new InvalidOperationException("No JSON object line found in script output.");
}
}

View File

@ -0,0 +1,311 @@
using Sdt.Config;
using Sdt.Core;
using Sdt.Runner;
using Xunit;
namespace DevTool.Tests;
public sealed class WorkflowExecutorTests
{
[Fact]
public async Task MissingPrereq_UserDeclines_ReturnsUserDeclined()
{
var executor = new WorkflowExecutor(
new WorkflowPlanner(),
new FakeProbeService(isAvailable: false),
new FakeInstallerService(success: true),
new FakeActionRunner(success: true),
new RequirementResolver());
var cfg = new DevToolConfig();
var wf = BuildSingleStepWorkflow("w", "dotnet");
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
var result = await executor.ExecuteAsync(
wf, map, cfg, ".", (_, _) => Task.FromResult(false), (_, _) => { });
Assert.False(result.Success);
Assert.Equal(ExecutionStopReason.UserDeclined, result.StopReason);
}
[Fact]
public async Task MissingPrereq_InstallFails_ReturnsInstallFailed()
{
var executor = new WorkflowExecutor(
new WorkflowPlanner(),
new FakeProbeService(isAvailable: false),
new FakeInstallerService(success: false),
new FakeActionRunner(success: true),
new RequirementResolver());
var cfg = new DevToolConfig();
var wf = BuildSingleStepWorkflow("w", "dotnet");
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
var result = await executor.ExecuteAsync(
wf, map, cfg, ".", (_, _) => Task.FromResult(true), (_, _) => { });
Assert.False(result.Success);
Assert.Equal(ExecutionStopReason.InstallFailed, result.StopReason);
}
[Fact]
public async Task StepFailure_StopsImmediately()
{
var executor = new WorkflowExecutor(
new WorkflowPlanner(),
new FakeProbeService(isAvailable: true),
new FakeInstallerService(success: true),
new FakeActionRunner(success: false),
new RequirementResolver());
var wf = new WorkflowDefinition
{
Id = "w",
Label = "W",
Steps =
[
new WorkflowStep { Id = "s1", Label = "S1", Command = "dotnet", Args = ["build"] },
new WorkflowStep { Id = "s2", Label = "S2", Command = "dotnet", Args = ["build"] },
]
};
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
var result = await executor.ExecuteAsync(
wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(true), (_, _) => { });
Assert.False(result.Success);
Assert.Equal(ExecutionStopReason.CommandFailed, result.StopReason);
Assert.Single(result.Steps);
}
[Fact]
public async Task LegacyPwshScriptStep_MissingPrereq_PromptsBeforeRun()
{
var executor = new WorkflowExecutor(
new WorkflowPlanner(),
new FakeProbeService(isAvailable: false),
new FakeInstallerService(success: true),
new FakeActionRunner(success: true),
new RequirementResolver());
var wf = new WorkflowDefinition
{
Id = "w",
Label = "W",
Steps =
[
new WorkflowStep
{
Id = "ps1",
Label = "Legacy PS1",
Command = "pwsh",
Args = ["-NoProfile", "-File", "scripts/publish-app.ps1", "-Target", "web"],
}
]
};
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
var result = await executor.ExecuteAsync(
wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(false), (_, _) => { });
Assert.False(result.Success);
Assert.Equal(ExecutionStopReason.UserDeclined, result.StopReason);
}
[Fact]
public async Task TauriBuild_DoesNotRequireGlobalTauri_WhenNodeNpmCargoAvailable()
{
var executor = new WorkflowExecutor(
new WorkflowPlanner(),
new ConditionalProbeService(),
new FakeInstallerService(success: true),
new FakeActionRunner(success: true),
new RequirementResolver());
var wf = new WorkflowDefinition
{
Id = "w",
Label = "W",
Steps =
[
new WorkflowStep
{
Id = "tauri",
Label = "Tauri Build",
Action = "tauri-build",
}
]
};
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
var result = await executor.ExecuteAsync(
wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(true), (_, _) => { });
Assert.True(result.Success);
Assert.Null(result.StopReason);
Assert.Single(result.Steps);
}
[Fact]
public async Task MissingPrereq_EmitsProbeDiagnosticsToOutput()
{
var executor = new WorkflowExecutor(
new WorkflowPlanner(),
new DetailedProbeService(),
new FakeInstallerService(success: true),
new FakeActionRunner(success: true),
new RequirementResolver());
var wf = BuildSingleStepWorkflow("w", "dotnet");
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
var lines = new List<string>();
var result = await executor.ExecuteAsync(
wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(false), (line, _) => lines.Add(line));
Assert.False(result.Success);
Assert.Contains(lines, l => l.Contains("Probe detail [dotnet]", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task ExecuteAsync_EmitsRunEvents_ForStepLifecycle()
{
var executor = new WorkflowExecutor(
new WorkflowPlanner(),
new FakeProbeService(isAvailable: true),
new FakeInstallerService(success: true),
new FakeActionRunner(success: true),
new RequirementResolver());
var wf = BuildSingleStepWorkflow("w", "dotnet");
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
var events = new List<RunEvent>();
var result = await executor.ExecuteAsync(
wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(true), (_, _) => { }, events.Add);
Assert.True(result.Success);
Assert.Contains(events, e => e.Type == RunEventType.WorkflowStarted);
Assert.Contains(events, e => e.Type == RunEventType.WorkflowStepStarted);
Assert.Contains(events, e => e.Type == RunEventType.WorkflowStepCompleted);
Assert.Contains(events, e => e.Type == RunEventType.WorkflowCompleted && e.Success == true);
}
[Fact]
public void AggregatorWorkflow_ExecutesDependenciesOnly()
{
var planner = new WorkflowPlanner();
var dep = new WorkflowDefinition
{
Id = "dep",
Label = "Dependency",
Steps = [new WorkflowStep { Id = "s", Label = "S", Command = "dotnet", Args = ["build"] }]
};
var agg = new WorkflowDefinition
{
Id = "agg",
Label = "Aggregator",
DependsOn = ["dep"],
Steps = []
};
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase)
{
[dep.Id] = dep,
[agg.Id] = agg
};
var plan = planner.ResolvePlan(agg, map);
Assert.Single(plan);
Assert.Equal("dep", plan[0].Id);
}
private static WorkflowDefinition BuildSingleStepWorkflow(string id, string tool)
{
return new WorkflowDefinition
{
Id = id,
Label = id,
Steps =
[
new WorkflowStep
{
Id = "step",
Label = "step",
Command = tool,
Args = ["--version"],
Requires = [new ToolRequirement { Tool = tool, InstallPolicy = InstallPolicy.Prompt }],
}
]
};
}
private sealed class FakeProbeService(bool isAvailable) : IToolProbe
{
public Task<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new ProbeResult(tool, isAvailable, Version: isAvailable ? "1.0.0" : null));
}
private sealed class FakeInstallerService(bool success) : IPrereqInstaller
{
public Task<InstallPlan> GetInstallPlanAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new InstallPlan(tool, Supported: true, "test", [new InstallCommand("echo", ["ok"])]));
public Task<RunResult> RunInstallAsync(
InstallCommand command,
string projectRoot,
Action<string, bool> onOutput,
CancellationToken cancellationToken = default)
=> Task.FromResult(new RunResult(success ? 0 : 1, TimeSpan.FromMilliseconds(5)));
}
private sealed class FakeActionRunner(bool success) : IActionRunner
{
public Task<RunResult> RunStepAsync(
WorkflowStep step,
string projectRoot,
Action<string, bool> onOutput,
CancellationToken cancellationToken = default)
=> Task.FromResult(new RunResult(success ? 0 : 2, TimeSpan.FromMilliseconds(10)));
}
private sealed class ConditionalProbeService : IToolProbe
{
public Task<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default)
{
var available = tool.ToLowerInvariant() switch
{
"node" => true,
"npm" => true,
"cargo" => true,
"tauri" => false,
_ => true
};
return Task.FromResult(new ProbeResult(tool, available, Version: available ? "1.0.0" : null));
}
}
private sealed class DetailedProbeService : IToolProbe
{
public Task<ProbeResult> ProbeAsync(
string tool,
string projectRoot,
DevToolConfig? config = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(new ProbeResult(tool, false, Details: "Fallback: unresolved command"));
}
}

View File

@ -0,0 +1,149 @@
using Sdt.Config;
using Xunit;
namespace DevTool.Tests;
public sealed class WorkflowModelBuilderTests
{
[Fact]
public void TargetsOnly_Strict_ThrowsMigrationError()
{
var cfg = new DevToolConfig
{
Targets =
[
new BuildTarget
{
Id = "build",
Label = "Build",
Command = "dotnet",
Args = ["build"],
}
]
};
var ex = Assert.Throws<InvalidOperationException>(() => WorkflowModelBuilder.Normalize(cfg, LegacyMode.Strict));
Assert.Contains("Legacy 'targets' are not allowed in strict mode", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void TargetsOnly_Compat_ProducesWarningAndConvertedWorkflow()
{
var cfg = new DevToolConfig
{
Targets =
[
new BuildTarget
{
Id = "build",
Label = "Build",
Command = "dotnet",
Args = ["build"],
}
]
};
var result = WorkflowModelBuilder.Normalize(cfg, LegacyMode.Compat);
Assert.Single(result.Workflows);
Assert.Contains(result.Warnings, w => w.Contains("legacy 'targets'", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void WorkflowsOnly_HasNoLegacyWarning()
{
var cfg = new DevToolConfig
{
Workflows =
[
new WorkflowDefinition
{
Id = "build",
Label = "Build",
Steps =
[
new WorkflowStep
{
Id = "run",
Label = "Run",
Command = "dotnet",
Args = ["build"],
}
]
}
]
};
var result = WorkflowModelBuilder.Normalize(cfg, LegacyMode.Strict);
Assert.Single(result.Workflows);
Assert.DoesNotContain(result.Warnings, w => w.Contains("legacy 'targets'", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Mixed_PrefersWorkflowsDeterministically()
{
var cfg = new DevToolConfig
{
Targets =
[
new BuildTarget
{
Id = "legacy",
Label = "Legacy",
Command = "dotnet",
Args = ["build"],
}
],
Workflows =
[
new WorkflowDefinition
{
Id = "new",
Label = "New",
Steps =
[
new WorkflowStep
{
Id = "step",
Label = "Step",
Command = "dotnet",
Args = ["build"],
}
]
}
]
};
var result = WorkflowModelBuilder.Normalize(cfg, LegacyMode.Strict);
Assert.Single(result.Workflows);
Assert.Equal("new", result.Workflows[0].Id);
Assert.Contains(result.Warnings, w => w.Contains("Both 'workflows' and legacy 'targets'", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void LegacyPwshTarget_InfersToolRequirements_FromScript()
{
var cfg = new DevToolConfig
{
Targets =
[
new BuildTarget
{
Id = "web",
Label = "Web",
Command = "pwsh",
Args = ["-NoProfile", "-File", "scripts/publish-app.ps1", "-Target", "web"],
}
]
};
var result = WorkflowModelBuilder.Normalize(cfg, LegacyMode.Compat);
var step = Assert.Single(result.Workflows).Steps.Single();
Assert.Contains(step.Requires, r => r.Tool == "python");
Assert.Contains(step.Requires, r => r.Tool == "node");
Assert.Contains(step.Requires, r => r.Tool == "npm");
}
}

View File

@ -0,0 +1,141 @@
using Sdt.Config;
using Xunit;
namespace DevTool.Tests;
public sealed class WorkspaceDefaultsTests
{
[Fact]
public void ConfigLoader_AppliesWorkspaceDefaults_FromAncestorDirectory()
{
var workspaceRoot = CreateTempDir("sdt-ws-defaults-");
var projectRoot = Path.Combine(workspaceRoot, "proj-a");
Directory.CreateDirectory(projectRoot);
File.WriteAllText(Path.Combine(workspaceRoot, WorkspaceLoader.FileName), """
{
"name": "Test Workspace",
"projects": []
}
""");
File.WriteAllText(Path.Combine(workspaceRoot, ConfigLoader.WorkspaceDefaultsFileName), """
{
"toolchains": {
"node": {
"packageManager": "pnpm",
"workingDir": "frontend"
}
},
"env": [
{ "key": "DOTNET_ENVIRONMENT", "description": "default env", "default": "Development", "options": ["Development", "Production"] }
]
}
""");
File.WriteAllText(Path.Combine(projectRoot, "devtool.json"), """
{
"name": "Project A",
"version": "1.0.0",
"workflows": [
{
"id": "build",
"label": "Build",
"description": "Build app",
"group": "Build",
"dependsOn": [],
"steps": [
{ "id": "build-step", "label": "dotnet build", "command": "dotnet", "args": ["build"], "workingDir": "." }
]
}
]
}
""");
var loaded = ConfigLoader.FindAndLoad(projectRoot);
Assert.NotNull(loaded);
Assert.Equal("pnpm", loaded!.Config.Toolchains?.Node?.PackageManager);
Assert.Equal("frontend", loaded.Config.Toolchains?.Node?.WorkingDir);
Assert.Single(loaded.Config.Env);
Assert.Contains(loaded.Warnings, w => w.Contains("Applied workspace defaults", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ConfigLoader_ProjectValuesOverrideWorkspaceDefaults()
{
var workspaceRoot = CreateTempDir("sdt-ws-override-");
var projectRoot = Path.Combine(workspaceRoot, "proj-b");
Directory.CreateDirectory(projectRoot);
File.WriteAllText(Path.Combine(workspaceRoot, WorkspaceLoader.FileName), """
{
"name": "Test Workspace",
"projects": []
}
""");
File.WriteAllText(Path.Combine(workspaceRoot, ConfigLoader.WorkspaceDefaultsFileName), """
{
"name": "Workspace Defaults",
"workflows": [
{
"id": "from-defaults",
"label": "Defaults Workflow",
"description": "",
"group": "General",
"dependsOn": [],
"steps": []
}
],
"debug": {
"diagnostics": {
"enabled": true,
"outputDir": ".sdt/workspace-debug",
"includeAllEnv": true,
"bundleOnFailure": true
}
}
}
""");
File.WriteAllText(Path.Combine(projectRoot, "devtool.json"), """
{
"name": "Project B",
"version": "1.0.0",
"workflows": [
{
"id": "project-workflow",
"label": "Project Workflow",
"description": "Only this one should remain",
"group": "Build",
"dependsOn": [],
"steps": []
}
],
"debug": {
"diagnostics": {
"enabled": false
}
}
}
""");
var loaded = ConfigLoader.FindAndLoad(projectRoot);
Assert.NotNull(loaded);
Assert.Equal("Project B", loaded!.Config.Name);
Assert.Single(loaded.Config.Workflows);
Assert.Equal("project-workflow", loaded.Config.Workflows[0].Id);
Assert.NotNull(loaded.Config.Debug);
Assert.NotNull(loaded.Config.Debug!.Diagnostics);
Assert.False(loaded.Config.Debug.Diagnostics.Enabled);
Assert.Equal(".sdt/workspace-debug", loaded.Config.Debug.Diagnostics.OutputDir);
Assert.True(loaded.Config.Debug.Diagnostics.IncludeAllEnv);
}
private static string CreateTempDir(string prefix)
{
var path = Path.Combine(Path.GetTempPath(), prefix + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
}

View File

@ -0,0 +1,56 @@
using Sdt.Config;
using Xunit;
namespace DevTool.Tests;
public sealed class WorkspaceLoaderTests
{
[Fact]
public void FindAndLoad_DoesNotThrow_WhenAutoDiscoverHitsStrictLegacyProject()
{
var root = Path.Combine(Path.GetTempPath(), "sdt-workspace-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
File.WriteAllText(Path.Combine(root, "devtool.json"), """
{
"name": "legacy",
"version": "0.1.0",
"targets": [
{
"id": "build",
"label": "Build",
"group": "Build",
"command": "dotnet",
"args": ["build"],
"workingDir": ".",
"dependsOn": []
}
],
"workflows": []
}
""");
Environment.SetEnvironmentVariable("SDT_LEGACY_MODE", null);
var result = WorkspaceLoader.FindAndLoad(root);
Assert.Null(result);
}
[Fact]
public void ResolveProjectRoot_AcceptsAbsolutePaths()
{
var root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ws-" + Guid.NewGuid().ToString("N")));
var abs = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "proj-" + Guid.NewGuid().ToString("N")));
var project = new WorkspaceProject { Path = abs };
var resolved = WorkspaceLoader.ResolveProjectRoot(root, project);
Assert.Equal(abs, resolved, ignoreCase: OperatingSystem.IsWindows());
}
[Fact]
public void WorkspaceProject_AdditionalFields_DefaultsAreSafe()
{
var project = new WorkspaceProject();
Assert.Empty(project.Tags);
Assert.Empty(project.ToolFamilies);
Assert.False(project.Disabled);
}
}

File diff suppressed because it is too large Load Diff

105
justfile
View File

@ -1,105 +0,0 @@
# SDT — Project Journal Justfile
# Install just: https://just.systems/man/en/packages.html
set windows-shell := ["pwsh", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"]
set shell := ["pwsh", "-c"]
# Detect runtime from OS
runtime := if os() == "windows" { "win-x64" } else { "linux-x64" }
# ── Default: list available recipes ────────────────────────────────────────────
default:
@just --list
# ── Build ──────────────────────────────────────────────────────────────────────
# Build Journal.Sidecar as self-contained single-file exe
sidecar:
& ./scripts/publish-sidecar.ps1 -Configuration Release -Runtime {{runtime}}
# Build SvelteKit web bundle (output: Journal.App/build/)
web:
& ./scripts/publish-app.ps1 -Target web
# Publish WebGateway with embedded web UI (depends: web)
webgateway: web
& ./scripts/publish-webgateway.ps1 -Configuration Release -Runtime {{runtime}}
# Build Tauri desktop exe — no installer (depends: sidecar)
tauri: sidecar
& ./scripts/publish-app.ps1 -Target tauri -TauriBundles none
# Build Tauri with NSIS installer (depends: sidecar)
tauri-nsis: sidecar
& ./scripts/publish-app.ps1 -Target tauri -TauriBundles nsis
# Build Tauri with MSI installer (depends: sidecar)
tauri-msi: sidecar
& ./scripts/publish-app.ps1 -Target tauri -TauriBundles msi
# Full release build — everything in correct order
all: sidecar web webgateway tauri
# ── Dev ────────────────────────────────────────────────────────────────────────
# Run WebGateway dev server (http://localhost:5180)
run:
& ./scripts/run-webgateway.ps1
# Run WebGateway with pinned project root (avoids multi-clone ambiguity)
run-pinned:
& ./scripts/run-webgateway.ps1 -ProjectRoot {{justfile_directory()}} -Urls http://0.0.0.0:5180
# SvelteKit dev server only (http://localhost:1420)
dev-app:
Set-Location Journal.App; npm run dev
# Tauri dev mode (desktop window + hot reload)
dev-tauri: sidecar
Set-Location Journal.App; npm run tauri dev
# ── Test ───────────────────────────────────────────────────────────────────────
# Run all smoke tests (~80 integration tests)
test:
dotnet run --project Journal.SmokeTests/Journal.SmokeTests.csproj
# Full migration gate (build + smoke + parity)
gate:
& ./scripts/migration-gate.ps1
# Migration gate — skip smoke tests
gate-fast:
& ./scripts/migration-gate.ps1 -SkipSmoke
# Migration gate — skip API contract tests
gate-no-api:
& ./scripts/migration-gate.ps1 -SkipApi
# ── .NET ───────────────────────────────────────────────────────────────────────
# dotnet build all projects
build:
dotnet build
# dotnet build with resilient NuGet defaults (use in restricted environments)
build-safe:
& ./scripts/dotnet-min.ps1 build Journal.Core/Journal.Core.csproj
& ./scripts/dotnet-min.ps1 build Journal.Sidecar/Journal.Sidecar.csproj
& ./scripts/dotnet-min.ps1 build Journal.WebGateway/Journal.WebGateway.csproj
# ── NuGet Cache ────────────────────────────────────────────────────────────────
# Export NuGet cache to zip for offline/transfer use
nuget-export zip="nuget-cache-export.zip":
& ./scripts/nuget-export-cache.ps1 -OutputZip {{zip}}
# Import NuGet cache zip and validate restore
nuget-import zip="nuget-cache-export.zip":
& ./scripts/nuget-import-cache.ps1 -InputZip {{zip}}
# ── SDT ────────────────────────────────────────────────────────────────────────
# Launch SDT dev tool TUI
sdt:
dotnet run --project Journal.DevTool/Journal.DevTool.csproj

View File

@ -1,328 +0,0 @@
# Scripts Reference
This folder contains PowerShell wrappers for repeatable local development, build, publish, and cache operations.
## Scope
These scripts target the repository rooted at `E:\stansshit\csharp\journal-master\journal`.
## Requirements
- PowerShell 7+ (`pwsh` recommended)
- .NET SDK (current repo uses `net10.0` targets)
- Python (for `pip-min.ps1`, `migration-gate.ps1`)
- Node.js + npm (for `publish-app.ps1`)
## Execution Policy
If script execution is blocked on Windows, run commands with one of:
```powershell
pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\<script>.ps1
```
or in-session:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
```
## Script Overview
| Script | Purpose |
|---|---|
| `dev-shell.ps1` | Initializes current shell with repo-local .NET/pip/HuggingFace cache env vars. |
| `script-common.ps1` | Shared helper functions used by most scripts. |
| `dotnet-min.ps1` | Minimal `dotnet` wrapper with local NuGet cache and safe defaults. |
| `pip-min.ps1` | Minimal `pip` wrapper with repo-local target/cache and Windows compatibility mapping. |
| `pip_safe.py` | Python wrapper used by `pip-min.ps1` to patch temporary-directory behavior. |
| `publish-app.ps1` | Builds frontend web bundle or Tauri desktop app. |
| `publish-sidecar.ps1` | Publishes `Journal.Sidecar` single-file executable to `output/`. |
| `publish-webgateway.ps1` | Publishes `Journal.WebGateway` and optionally copies web assets into `wwwroot`. |
| `run-webgateway.ps1` | Runs `Journal.WebGateway` with configurable URLs and project root. |
| `migration-gate.ps1` | End-to-end migration/parity gate (build + smoke/parity/API checks). |
| `nuget-export-cache.ps1` | Primes and exports `.nuget` cache to zip for transfer. |
| `nuget-import-cache.ps1` | Imports exported cache zip and validates restore. |
## Common Patterns Used Across Scripts
- Proxy cleanup: `Clear-JournalProxyEnv`
- Repo root detection: `Resolve-JournalRepoRoot`
- Repo-local caches:
- `.dotnet_home`
- `.nuget\packages`
- `.nuget\http-cache`
- `.pip\cache`
- `.tmp\*`
- `.npm\cache`
## Detailed Reference
### `dev-shell.ps1`
Dot-source this script to configure your *current shell*.
```powershell
. .\scripts\dev-shell.ps1
```
What it does:
- loads `script-common.ps1`
- resolves repo root
- clears proxy env vars
- initializes repo-local .NET, pip, and HuggingFace env vars
Use this once per shell session before running development commands manually.
### `script-common.ps1`
Shared helper functions:
- `Clear-JournalProxyEnv`
- `Resolve-JournalRepoRoot`
- `Initialize-JournalDotnetEnv`
- `Initialize-JournalPipEnv`
- `Initialize-JournalHuggingFaceEnv`
- `Resolve-JournalSidecarProjectPath`
- `Resolve-JournalAppRoot`
- `Resolve-JournalWebGatewayProjectPath`
Also supports optional override:
- `JOURNAL_REPO_ROOT`
### `dotnet-min.ps1`
Wrapper around `dotnet` with resilient restore/build defaults.
Usage:
```powershell
.\scripts\dotnet-min.ps1 <dotnet args>
```
Examples:
```powershell
.\scripts\dotnet-min.ps1 build Journal.Sidecar/Journal.Sidecar.csproj
.\scripts\dotnet-min.ps1 restore Journal.WebGateway/Journal.WebGateway.csproj
.\scripts\dotnet-min.ps1 run --project Journal.Sidecar/Journal.Sidecar.csproj
```
Behavior:
- sets repo-local `DOTNET_CLI_HOME` and NuGet cache paths
- clears proxy env vars
- adds for common commands (`restore`, `build`, `run`, `test`, `publish`, `pack`):
- `-p:RestoreIgnoreFailedSources=true`
- `-p:NuGetAudit=false`
- for `restore`, also adds `--ignore-failed-sources`
### `pip-min.ps1`
Wrapper around `python -m pip` focused on constrained hosts.
Usage:
```powershell
.\scripts\pip-min.ps1 <pip args>
```
Examples:
```powershell
.\scripts\pip-min.ps1 install requests
.\scripts\pip-min.ps1 install --index-url https://pypi.org/simple faster-whisper
```
Behavior:
- initializes repo-local pip cache/temp paths
- clears proxy env vars
- for `install` without target/prefix:
- strips `--user`
- installs into `.pydeps\py314`
- on Windows, maps `pyaudio` to `pyaudiowpatch`
- uses `pip_safe.py` if present
### `pip_safe.py`
Compatibility wrapper for pip on some Windows + Python 3.14 setups.
Behavior:
- monkey-patches `tempfile.mkdtemp` to create writable temp directories (`0o777`)
- then forwards to pip internal CLI
### `publish-app.ps1`
Builds the frontend (`web`) or Tauri desktop app (`tauri`).
Parameters:
- `-Target web|tauri` (default `web`)
- `-Configuration Release|Debug` (default `Release`)
- `-TauriBundles none|nsis|msi` (default `none`)
- `-InstallDeps`
- `-SkipInstall`
- `-DryRun`
Examples:
```powershell
.\scripts\publish-app.ps1 -Target web
.\scripts\publish-app.ps1 -Target tauri -TauriBundles none
.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis
```
Notes:
- uses repo-local npm cache/temp paths
- `-TauriBundles none` maps to `tauri build --no-bundle` (raw exe build)
- expected web output: `Journal.App\build`
- expected tauri exe location: `Journal.App\src-tauri\target\release\journalapp.exe` (Release)
### `publish-sidecar.ps1`
Publishes `Journal.Sidecar` as a self-contained single-file executable.
Parameters:
- `-Configuration` (default `Release`)
- `-Runtime` (default `win-x64`)
Example:
```powershell
.\scripts\publish-sidecar.ps1 -Configuration Release -Runtime win-x64
```
Output:
- `output\Journal.Sidecar.exe` (for Windows runtime)
### `publish-webgateway.ps1`
Publishes `Journal.WebGateway` and optionally embeds built web assets.
Parameters:
- `-Configuration Release|Debug` (default `Release`)
- `-Runtime` (default `win-x64`)
- `-SelfContained` (switch)
- `-SkipWebAssets` (switch)
Example:
```powershell
.\scripts\publish-app.ps1 -Target web
.\scripts\publish-webgateway.ps1 -Configuration Release -Runtime win-x64
```
Output:
- `output\webgateway\`
- optional `output\webgateway\wwwroot\` from `Journal.App\build`
### `run-webgateway.ps1`
Runs `Journal.WebGateway` directly with controlled environment.
Parameters:
- `-Configuration Release|Debug` (default `Release`)
- `-Urls` (default `http://0.0.0.0:5180`)
- `-ProjectRoot` (optional; if omitted, repo root)
Examples:
```powershell
.\scripts\run-webgateway.ps1
.\scripts\run-webgateway.ps1 -Urls http://127.0.0.1:5180
.\scripts\run-webgateway.ps1 -ProjectRoot E:\stansshit\csharp\journal-master\journal
```
Notes:
- sets `JOURNAL_PROJECT_ROOT` for the gateway process
- useful when multiple repo clones exist and you need deterministic data/vault paths
### `migration-gate.ps1`
Runs migration quality gate.
Parameters:
- `-SkipSmoke`
- `-SkipApi`
Current behavior:
1. builds `Journal.Sidecar`
2. optionally runs `Journal.SmokeTests`
3. runs Python parity harness test discovery
4. optionally runs API contract test discovery
If `tests/` is absent, Python parity and API contract steps are skipped automatically with a clear message — the sidecar build and smoke tests still run.
### `nuget-export-cache.ps1`
Primes and exports NuGet cache to zip.
Parameters:
- `-OutputZip` (default `nuget-cache-export.zip`)
- `-IncludeDotnetHome`
Behavior:
- runs restore for selected projects via `dotnet-min.ps1`
- copies `.nuget` (and optional `.dotnet_home`) to staging
- writes `nuget-cache-manifest.txt`
- outputs zip
Primes restore for `Journal.Sidecar`, `Journal.WebGateway`, and `Journal.SmokeTests`.
### `nuget-import-cache.ps1`
Imports exported cache zip and validates by restore.
Parameters:
- `-InputZip` (default `nuget-cache-export.zip`)
Behavior:
- extracts zip into repo root
- runs restore for selected projects via `dotnet-min.ps1`
Validates restore for `Journal.Sidecar`, `Journal.WebGateway`, and `Journal.SmokeTests`.
## Environment Variables Used
- `JOURNAL_REPO_ROOT` (optional override for repo detection)
- `JOURNAL_PROJECT_ROOT` (runtime project root for gateway/sidecar config)
- `DOTNET_CLI_HOME`
- `NUGET_PACKAGES`
- `NUGET_HTTP_CACHE_PATH`
- `NUGET_CERT_REVOCATION_MODE`
- `PIP_CACHE_DIR`
- `HF_HOME`
- `HUGGINGFACE_HUB_CACHE`
## Output and Cache Directories
- `output/`
- `output/webgateway/`
- `.dotnet_home/`
- `.nuget/packages/`
- `.nuget/http-cache/`
- `.npm/cache/`
- `.pip/cache/`
- `.tmp/`
- `.pydeps/`
## See Also
- [`WORKFLOWS.md`](./WORKFLOWS.md) for copy/paste command recipes.

View File

@ -1,140 +0,0 @@
# Script Workflows
Practical command recipes for common tasks.
## 1) Start a Clean Dev Shell
```powershell
cd E:\stansshit\csharp\journal-master\journal
. .\scripts\dev-shell.ps1
```
Use this when you want the current shell configured with repo-local cache paths.
## 2) Build .NET Projects With Safe Defaults
```powershell
.\scripts\dotnet-min.ps1 build Journal.Sidecar/Journal.Sidecar.csproj
.\scripts\dotnet-min.ps1 build Journal.WebGateway/Journal.WebGateway.csproj
```
## 3) Build Frontend or Desktop App
Web build:
```powershell
.\scripts\publish-app.ps1 -Target web
```
Tauri raw exe (no installer):
```powershell
.\scripts\publish-app.ps1 -Target tauri -TauriBundles none
```
Tauri installer bundles:
```powershell
.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis
.\scripts\publish-app.ps1 -Target tauri -TauriBundles msi
```
## 4) Publish Sidecar
```powershell
.\scripts\publish-sidecar.ps1 -Configuration Release -Runtime win-x64
```
Expected output under `output/`.
## 5) Publish WebGateway Package
```powershell
.\scripts\publish-app.ps1 -Target web
.\scripts\publish-webgateway.ps1 -Configuration Release -Runtime win-x64
```
If you only want gateway binary refresh:
```powershell
.\scripts\publish-webgateway.ps1 -SkipWebAssets
```
## 6) Run WebGateway Against Explicit Root
When multiple clones exist, always pin project root:
```powershell
.\scripts\run-webgateway.ps1 -ProjectRoot E:\stansshit\csharp\journal-master\journal -Urls http://0.0.0.0:5180
```
Quick health check:
```powershell
Invoke-RestMethod http://127.0.0.1:5180/api/health
```
Inspect active backend config:
```powershell
$body = @{ action = 'config.get' } | ConvertTo-Json
Invoke-RestMethod -Uri http://127.0.0.1:5180/api/command -Method Post -ContentType 'application/json' -Body $body
```
## 7) Python Package Installs in Repo-Local Target
```powershell
.\scripts\pip-min.ps1 install requests
```
Packages go to `.pydeps\py314` unless you pass your own `--target`/`--prefix`.
## 8) Migration Gate (When Full Test Assets Exist)
```powershell
.\scripts\migration-gate.ps1
```
Partial run:
```powershell
.\scripts\migration-gate.ps1 -SkipSmoke -SkipApi
```
If `tests/` is absent, Python parity and API contract steps are skipped with a clear message.
## 9) NuGet Cache Export/Import (Offline-ish Restore)
Export:
```powershell
.\scripts\nuget-export-cache.ps1 -OutputZip .\nuget-cache-export.zip
```
Import:
```powershell
.\scripts\nuget-import-cache.ps1 -InputZip .\nuget-cache-export.zip
```
Validates restore for `Journal.Sidecar`, `Journal.WebGateway`, and `Journal.SmokeTests`.
## 10) Quick Troubleshooting
Script blocked by execution policy:
```powershell
pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\publish-app.ps1 -Target web
```
Unexpected data/vault mismatch in WebGateway:
1. Run with explicit `-ProjectRoot`.
2. Verify with `config.get` via `/api/command`.
3. Verify sidecar root via `/api/sidecar/root`.
NuGet restore flakiness:
1. Use `dotnet-min.ps1` wrappers.
2. Confirm proxy vars are cleared (`HTTP_PROXY`, `HTTPS_PROXY`, etc.).
3. Ensure `.nuget` directories are writable.

Some files were not shown because too many files have changed in this diff Show More