Set up monorepo: centralize .NET packages, npm workspaces, remove Journal.DevTool
- Add Directory.Build.props with shared TargetFramework/Nullable/ImplicitUsings - Add Directory.Packages.props for centralized NuGet version management - Strip duplicated properties and Version attributes from all .csproj files - Fix Directory.Build.props TFM from net10 to net10.0 - Configure npm workspaces in root package.json (Journal.App) - Hoist node_modules to repo root with single lockfile - Add node_modules/ to .gitignore - Remove Journal.DevTool contents, keep as empty folder with .gitkeep - Remove Journal.DevTool from solution and npm workspaces Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
parent
5685460e08
commit
a05c4b0209
6
.gitignore
vendored
6
.gitignore
vendored
@ -45,13 +45,15 @@ logs/
|
|||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# OTHER
|
# OTHER
|
||||||
.just/
|
.just/
|
||||||
journalapp.exe
|
journalapp.exe
|
||||||
Journal.App/node_modules.old/@rollup/.rollup-win32-x64-msvc-IjiZshxL/rollup.win32-x64-msvc.node
|
Journal.App/node_modules.old/@rollup/.rollup-win32-x64-msvc-IjiZshxL/rollup.win32-x64-msvc.node
|
||||||
journalapp(1).exe
|
journalapp(1).exe
|
||||||
.cache/
|
.cache/
|
||||||
Journal.DevTool/node_modules/
|
|
||||||
scripts/__pycache__/
|
scripts/__pycache__/
|
||||||
.sdt/
|
.sdt/
|
||||||
devtool.backup.json
|
devtool.backup.json
|
||||||
@ -61,5 +63,3 @@ sdt.exe
|
|||||||
sdt.pdb
|
sdt.pdb
|
||||||
sdt.runtimeconfig.json
|
sdt.runtimeconfig.json
|
||||||
Spectre.Console.dll
|
Spectre.Console.dll
|
||||||
Journal.DevTool/devtool.generated.workflows.json
|
|
||||||
Journal.DevTool/sdt-workspace.json
|
|
||||||
|
|||||||
7
Directory.Build.props
Normal file
7
Directory.Build.props
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
16
Directory.Packages.props
Normal file
16
Directory.Packages.props
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.11" />
|
||||||
|
<PackageVersion Include="NAudio" Version="2.2.1" />
|
||||||
|
<PackageVersion Include="Whisper.net" Version="1.9.0" />
|
||||||
|
<PackageVersion Include="Whisper.net.Runtime" Version="1.9.0" />
|
||||||
|
<PackageVersion Include="LLamaSharp" Version="0.26.0" />
|
||||||
|
<PackageVersion Include="LLamaSharp.Backend.Cpu" Version="0.26.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@ -1,14 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="LLamaSharp" Version="0.26.0" />
|
<PackageReference Include="LLamaSharp" />
|
||||||
<PackageReference Include="LLamaSharp.Backend.Cpu" Version="0.26.0" />
|
<PackageReference Include="LLamaSharp.Backend.Cpu" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
1926
Journal.App/package-lock.json
generated
1926
Journal.App/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,9 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="10.0.3" />
|
<PackageReference Include="Microsoft.Data.Sqlite.Core" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.11" />
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlcipher" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
15
Journal.DevTool/.gitignore
vendored
15
Journal.DevTool/.gitignore
vendored
@ -1,15 +0,0 @@
|
|||||||
bin/
|
|
||||||
obj/
|
|
||||||
__pycache__/
|
|
||||||
.cache/
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
.vs/
|
|
||||||
.git/
|
|
||||||
.pip
|
|
||||||
.tmp
|
|
||||||
.venv
|
|
||||||
.dotnet_home
|
|
||||||
.nuget
|
|
||||||
publish-test/
|
|
||||||
|
|
||||||
0
Journal.DevTool/.gitkeep
Normal file
0
Journal.DevTool/.gitkeep
Normal file
@ -1,626 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
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,
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
AllowTrailingCommas = true,
|
|
||||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Walks up from <paramref name="startDir"/> (or CWD) until it finds devtool.json.
|
|
||||||
/// Returns null if not found.
|
|
||||||
/// </summary>
|
|
||||||
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))
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Sdt.Config;
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
public string Id { get; init; } = "";
|
|
||||||
public string Label { get; init; } = "";
|
|
||||||
public string Description { get; init; } = "";
|
|
||||||
public string Group { get; init; } = "General";
|
|
||||||
|
|
||||||
/// <summary>Executable name. Null = virtual aggregator (runs DependsOn only).</summary>
|
|
||||||
public string? Command { get; init; }
|
|
||||||
|
|
||||||
public List<string> Args { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>Working directory relative to project root.</summary>
|
|
||||||
public string WorkingDir { get; init; } = ".";
|
|
||||||
|
|
||||||
public List<string> DependsOn { get; init; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class EnvVarDef
|
|
||||||
{
|
|
||||||
public string Key { get; init; } = "";
|
|
||||||
public string Description { get; init; } = "";
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("default")]
|
|
||||||
public string DefaultValue { get; init; } = "";
|
|
||||||
|
|
||||||
/// <summary>If non-empty, shown as a dropdown. Otherwise free-text input.</summary>
|
|
||||||
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
|
|
||||||
{
|
|
||||||
public PythonToolchain? Python { get; init; }
|
|
||||||
public NodeToolchain? Node { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PythonToolchain
|
|
||||||
{
|
|
||||||
/// <summary>Python executable (e.g. "python3.14", "python").</summary>
|
|
||||||
public string Executable { get; init; } = "python";
|
|
||||||
|
|
||||||
/// <summary>Windows-specific override (e.g. "py" when using the launcher).</summary>
|
|
||||||
public string? WindowsExecutable { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Optional version flag to pass (e.g. "-3.14" for py launcher).</summary>
|
|
||||||
public string? LauncherVersion { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Venv directory relative to project root.</summary>
|
|
||||||
public string VenvDir { get; init; } = ".venv";
|
|
||||||
|
|
||||||
public List<PythonProfile> Profiles { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>Optional path to a pip wrapper script (relative to project root).</summary>
|
|
||||||
public string? PipScript { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PythonProfile
|
|
||||||
{
|
|
||||||
public string Id { get; init; } = "";
|
|
||||||
public string Label { get; init; } = "";
|
|
||||||
public string RequirementsFile { get; init; } = "";
|
|
||||||
public string? ExtraIndexUrl { get; init; }
|
|
||||||
public List<string> PostInstallCommands { get; init; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class NodeToolchain
|
|
||||||
{
|
|
||||||
/// <summary>Package manager: "npm", "pnpm", or "yarn".</summary>
|
|
||||||
public string PackageManager { get; init; } = "npm";
|
|
||||||
|
|
||||||
/// <summary>Working directory for the frontend (relative to project root).</summary>
|
|
||||||
public string WorkingDir { get; init; } = ".";
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
namespace Sdt.Config;
|
|
||||||
|
|
||||||
public sealed class WorkspaceConfig
|
|
||||||
{
|
|
||||||
public string Name { get; init; } = "SDT Workspace";
|
|
||||||
public List<WorkspaceProject> Projects { get; init; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class WorkspaceProject
|
|
||||||
{
|
|
||||||
public string Name { get; init; } = "";
|
|
||||||
public string Description { get; init; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Sdt.Config;
|
|
||||||
|
|
||||||
public static class WorkspaceLoader
|
|
||||||
{
|
|
||||||
public const string FileName = "sdt-workspace.json";
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true,
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
AllowTrailingCommas = true,
|
|
||||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Walks up from <paramref name="startDir"/> (or CWD) to find sdt-workspace.json.
|
|
||||||
/// Returns null if not found.
|
|
||||||
/// </summary>
|
|
||||||
public static (WorkspaceConfig Config, string WorkspaceRoot)? FindAndLoad(string? startDir = null)
|
|
||||||
{
|
|
||||||
var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory());
|
|
||||||
while (dir is not null)
|
|
||||||
{
|
|
||||||
var candidate = Path.Combine(dir.FullName, FileName);
|
|
||||||
if (File.Exists(candidate))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = File.ReadAllText(candidate);
|
|
||||||
var config = JsonSerializer.Deserialize<WorkspaceConfig>(json, JsonOptions)
|
|
||||||
?? throw new InvalidOperationException($"{FileName} deserialized to null.");
|
|
||||||
return (config, dir.FullName);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Failed to parse {FileName} at {candidate}: {ex.Message}", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dir = dir.Parent!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,270 +0,0 @@
|
|||||||
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."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
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 }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,303 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
using Sdt.Config;
|
|
||||||
using Sdt.Tui;
|
|
||||||
using Spectre.Console;
|
|
||||||
|
|
||||||
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(currentLoaded.Config, currentLoaded.ProjectRoot, currentLoaded.Warnings, workspace, workspaceRoot);
|
|
||||||
var result = await app.RunAsync();
|
|
||||||
|
|
||||||
if (result.Reason == AppExitReason.Quit)
|
|
||||||
break;
|
|
||||||
|
|
||||||
// User switched projects — reload config from new root
|
|
||||||
if (result.Reason == AppExitReason.SwitchProject && result.NewProjectRoot is not null)
|
|
||||||
{
|
|
||||||
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}"));
|
|
||||||
AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
|
|
||||||
Console.ReadKey(intercept: true);
|
|
||||||
continue; // go back to current app
|
|
||||||
}
|
|
||||||
|
|
||||||
currentLoaded = loaded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
# SDT (Stan's Dev Tools)
|
|
||||||
|
|
||||||
Cross-platform terminal orchestrator for project workflows, toolchain checks, and prerequisite gating.
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet run --project DevTool.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
dotnet run --project DevTool.csproj -- init
|
|
||||||
```
|
|
||||||
|
|
||||||
Bootstrap detects common stacks (`dotnet`, `npm/node`, `python`, `cargo/tauri`, `git`, `docker`) and generates:
|
|
||||||
|
|
||||||
- default workflows
|
|
||||||
- toolchain/tooling defaults
|
|
||||||
- debug profiles + diagnostics defaults
|
|
||||||
|
|
||||||
## Config Model
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
"id": "build",
|
|
||||||
"label": "Build",
|
|
||||||
"description": "Build project",
|
|
||||||
"group": "Build",
|
|
||||||
"dependsOn": [],
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"id": "dotnet-build",
|
|
||||||
"label": "dotnet build",
|
|
||||||
"action": "dotnet-build",
|
|
||||||
"actionArgs": [],
|
|
||||||
"workingDir": ".",
|
|
||||||
"requires": [
|
|
||||||
{ "tool": "dotnet", "installPolicy": "Prompt" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Extra sections
|
|
||||||
|
|
||||||
- `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
|
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# bash/zsh
|
|
||||||
source ./scripts/dev-shell.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Underlying implementation is `scripts/dev_shell.py`:
|
|
||||||
|
|
||||||
- `python scripts/dev_shell.py export --shell pwsh --json`
|
|
||||||
- `python scripts/dev_shell.py doctor`
|
|
||||||
|
|
||||||
## Legacy PowerShell Compatibility
|
|
||||||
|
|
||||||
Legacy `.ps1` scripts remain for migration compatibility only. New functionality is implemented in Python scripts first. `script-common.ps1` is legacy-only.
|
|
||||||
|
|
||||||
Legacy runtime behavior in v1.2:
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
Deprecation target:
|
|
||||||
|
|
||||||
- v1.x: compatibility only (no new behavior guarantees)
|
|
||||||
- v2.0: remove legacy `.ps1` scripts from default SDT workflows and docs
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run unit/integration tests:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test tests/DevTool.Tests/DevTool.Tests.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
Run Python script smoke checks:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python -m py_compile scripts/*.py
|
|
||||||
```
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
|
|
||||||
namespace Sdt.Runner;
|
|
||||||
|
|
||||||
public sealed record RunResult(int ExitCode, TimeSpan Elapsed)
|
|
||||||
{
|
|
||||||
public bool Success => ExitCode == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class ProcessRunner
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Runs a command with the given args, streaming stdout/stderr via <paramref name="onOutput"/>.
|
|
||||||
/// onOutput receives (line, isStderr).
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<RunResult> RunAsync(
|
|
||||||
string command,
|
|
||||||
IEnumerable<string> args,
|
|
||||||
string workingDir,
|
|
||||||
Action<string, bool> onOutput,
|
|
||||||
IReadOnlyDictionary<string, string>? envOverrides = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var psi = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = Core.CommandResolver.Resolve(command),
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
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);
|
|
||||||
|
|
||||||
var sw = Stopwatch.StartNew();
|
|
||||||
|
|
||||||
using var process = new Process { StartInfo = psi };
|
|
||||||
|
|
||||||
void OnCancel(object? sender, ConsoleCancelEventArgs e)
|
|
||||||
{
|
|
||||||
e.Cancel = true; // Prevent SDT from exiting immediately
|
|
||||||
try { process.Kill(entireProcessTree: true); } catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.CancelKeyPress += OnCancel;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
process.Start();
|
|
||||||
|
|
||||||
var stdoutTask = DrainAsync(process.StandardOutput, line => onOutput(line, false), cancellationToken);
|
|
||||||
var stderrTask = DrainAsync(process.StandardError, line => onOutput(line, true), cancellationToken);
|
|
||||||
|
|
||||||
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
|
|
||||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Console.CancelKeyPress -= OnCancel;
|
|
||||||
}
|
|
||||||
|
|
||||||
sw.Stop();
|
|
||||||
return new RunResult(process.ExitCode, sw.Elapsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task DrainAsync(StreamReader reader, Action<string> emit, CancellationToken ct)
|
|
||||||
{
|
|
||||||
string? line;
|
|
||||||
while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) is not null
|
|
||||||
&& !ct.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
emit(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
using Sdt.Config;
|
|
||||||
|
|
||||||
namespace Sdt.Runner;
|
|
||||||
|
|
||||||
public static class TargetRunner
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the ordered list of real (non-virtual) steps needed to execute <paramref name="target"/>,
|
|
||||||
/// respecting DependsOn chains. Each step appears at most once.
|
|
||||||
/// </summary>
|
|
||||||
public static List<BuildTarget> ResolvePlan(
|
|
||||||
BuildTarget target,
|
|
||||||
IReadOnlyDictionary<string, BuildTarget> allTargets)
|
|
||||||
{
|
|
||||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
var plan = new List<BuildTarget>();
|
|
||||||
Visit(target, allTargets, visited, plan);
|
|
||||||
return plan;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Visit(
|
|
||||||
BuildTarget target,
|
|
||||||
IReadOnlyDictionary<string, BuildTarget> allTargets,
|
|
||||||
HashSet<string> visited,
|
|
||||||
List<BuildTarget> plan)
|
|
||||||
{
|
|
||||||
if (!visited.Add(target.Id))
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Recurse into dependencies first (topological order)
|
|
||||||
foreach (var depId in target.DependsOn)
|
|
||||||
{
|
|
||||||
if (allTargets.TryGetValue(depId, out var dep))
|
|
||||||
Visit(dep, allTargets, visited, plan);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Virtual aggregator targets (null Command) are just dependency collectors
|
|
||||||
if (target.Command is not null)
|
|
||||||
plan.Add(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,800 +0,0 @@
|
|||||||
using Sdt.Config;
|
|
||||||
using Sdt.Core;
|
|
||||||
using Sdt.Core.Debug;
|
|
||||||
using Spectre.Console;
|
|
||||||
|
|
||||||
namespace Sdt.Tui;
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
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 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()
|
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
AnsiConsole.Clear();
|
|
||||||
RenderBanner();
|
|
||||||
|
|
||||||
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__":
|
|
||||||
EditEnvironment();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "__toolchains__":
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
var switcher = new WorkspaceScreen(_workspace, _workspaceRoot, _projectRoot);
|
|
||||||
var newRoot = switcher.SelectProject();
|
|
||||||
if (newRoot is not null)
|
|
||||||
return new AppResult(AppExitReason.SwitchProject, newRoot);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "__migrate_legacy__":
|
|
||||||
ApplyLegacyMigration();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "__quit__":
|
|
||||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Later.") + "\n");
|
|
||||||
return new AppResult(AppExitReason.Quit);
|
|
||||||
|
|
||||||
default:
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (choice != "__quit__")
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to return to the menu..."));
|
|
||||||
Console.ReadKey(intercept: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderBanner()
|
|
||||||
{
|
|
||||||
AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor));
|
|
||||||
var wsInfo = _workspace is not null
|
|
||||||
? $" [{Theme.GreenDim}]∙ {Markup.Escape(_workspace.Name)}[/]"
|
|
||||||
: string.Empty;
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ShowMainMenu()
|
|
||||||
{
|
|
||||||
var prompt = new SelectionPrompt<MenuItem>()
|
|
||||||
.Title($"[{Theme.Green}]What would you like to do?[/]")
|
|
||||||
.PageSize(28)
|
|
||||||
.MoreChoicesText(Theme.Faint("(scroll to see more)"))
|
|
||||||
.UseConverter(m => m.Display);
|
|
||||||
|
|
||||||
var groups = _workflows
|
|
||||||
.Where(t => !string.IsNullOrWhiteSpace(t.Label))
|
|
||||||
.GroupBy(t => string.IsNullOrWhiteSpace(t.Group) ? "General" : t.Group);
|
|
||||||
|
|
||||||
foreach (var group in groups)
|
|
||||||
{
|
|
||||||
var header = new MenuItem(
|
|
||||||
$"[bold {Theme.Amber}]{Markup.Escape(group.Key.ToUpperInvariant())}[/]",
|
|
||||||
"__group__");
|
|
||||||
|
|
||||||
var items = group.Select(t => new MenuItem(
|
|
||||||
$"[{Theme.Green}]{Markup.Escape(t.Label)}[/] [{Theme.GreenDim}]{Markup.Escape(t.Description)}[/]",
|
|
||||||
t.Id)).ToList();
|
|
||||||
|
|
||||||
prompt.AddChoiceGroup(header, items);
|
|
||||||
}
|
|
||||||
|
|
||||||
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__"),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (_config.Toolchains is not null)
|
|
||||||
systemItems.Insert(0, new MenuItem(
|
|
||||||
$"[{Theme.Green}]⬡ Toolchain management[/] [{Theme.GreenDim}]python / node[/]",
|
|
||||||
"__toolchains__"));
|
|
||||||
|
|
||||||
if (_workspace is not null)
|
|
||||||
systemItems.Insert(0, new MenuItem(
|
|
||||||
$"[{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(
|
|
||||||
new MenuItem($"[bold {Theme.Amber}]SYSTEM[/]", "__group__"),
|
|
||||||
systemItems);
|
|
||||||
|
|
||||||
return AnsiConsole.Prompt(prompt).Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunWorkflowAsync(
|
|
||||||
WorkflowDefinition workflow,
|
|
||||||
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap)
|
|
||||||
{
|
|
||||||
AnsiConsole.Clear();
|
|
||||||
AnsiConsole.Write(Theme.SectionRule(workflow.Label));
|
|
||||||
|
|
||||||
var plan = new WorkflowPlanner().ResolvePlan(workflow, workflowMap);
|
|
||||||
if (plan.Count == 0)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(Theme.Warn("This workflow has no executable steps."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plan.Count > 1)
|
|
||||||
{
|
|
||||||
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 outputLines = new List<string>();
|
|
||||||
using var eventRecorder = RunEventJsonlRecorder.Create(_projectRoot, "workflow");
|
|
||||||
|
|
||||||
var result = await _executor.ExecuteAsync(
|
|
||||||
workflow,
|
|
||||||
workflowMap,
|
|
||||||
_config,
|
|
||||||
_projectRoot,
|
|
||||||
confirmInstallAsync: ConfirmInstallAsync,
|
|
||||||
onOutput: (line, isErr) =>
|
|
||||||
{
|
|
||||||
outputLines.Add((isErr ? "ERR: " : "OUT: ") + line);
|
|
||||||
var escaped = Markup.Escape(line);
|
|
||||||
AnsiConsole.MarkupLine(isErr
|
|
||||||
? $"[{Theme.Amber}]{escaped}[/]"
|
|
||||||
: $"[{Theme.Green}]{escaped}[/]");
|
|
||||||
},
|
|
||||||
onEvent: evt =>
|
|
||||||
{
|
|
||||||
eventRecorder.Write(evt);
|
|
||||||
RenderRunEvent(evt);
|
|
||||||
});
|
|
||||||
|
|
||||||
AnsiConsole.Write(Theme.SectionRule());
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
{
|
|
||||||
AnsiConsole.Clear();
|
|
||||||
|
|
||||||
if (_config.Env.Count == 0)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(Theme.Warn("No environment variables defined in devtool.json."));
|
|
||||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
|
|
||||||
Console.ReadKey(intercept: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
AnsiConsole.Write(Theme.SectionRule("ENVIRONMENT"));
|
|
||||||
|
|
||||||
var table = new Table()
|
|
||||||
.Border(TableBorder.Rounded)
|
|
||||||
.BorderStyle(Theme.DimStyle)
|
|
||||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Variable[/]"))
|
|
||||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Current Value[/]"))
|
|
||||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Description[/]"));
|
|
||||||
|
|
||||||
foreach (var def in _config.Env)
|
|
||||||
{
|
|
||||||
var val = Environment.GetEnvironmentVariable(def.Key) ?? def.DefaultValue;
|
|
||||||
table.AddRow(
|
|
||||||
Theme.Warn(def.Key),
|
|
||||||
Theme.Bold(val.Length > 0 ? val : "(not set)"),
|
|
||||||
Theme.Faint(def.Description));
|
|
||||||
}
|
|
||||||
|
|
||||||
AnsiConsole.Write(table);
|
|
||||||
AnsiConsole.MarkupLine(Theme.Faint("Changes apply to this SDT session only.\n"));
|
|
||||||
|
|
||||||
var choices = _config.Env
|
|
||||||
.Select(e =>
|
|
||||||
{
|
|
||||||
var curr = Environment.GetEnvironmentVariable(e.Key) ?? e.DefaultValue;
|
|
||||||
return new MenuItem(
|
|
||||||
$"[{Theme.Amber}]{Markup.Escape(e.Key)}[/] [{Theme.GreenDim}]= {Markup.Escape(curr)}[/]",
|
|
||||||
e.Key);
|
|
||||||
})
|
|
||||||
.Append(new MenuItem(Theme.Faint("← Back"), "__back__"))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var selected = AnsiConsole.Prompt(
|
|
||||||
new SelectionPrompt<MenuItem>()
|
|
||||||
.Title($"[{Theme.Green}]Select a variable to edit:[/]")
|
|
||||||
.UseConverter(m => m.Display)
|
|
||||||
.AddChoices(choices));
|
|
||||||
|
|
||||||
if (selected.Value == "__back__") break;
|
|
||||||
|
|
||||||
var envDef = _config.Env.First(e => e.Key == selected.Value);
|
|
||||||
var current = Environment.GetEnvironmentVariable(envDef.Key) ?? envDef.DefaultValue;
|
|
||||||
|
|
||||||
string newVal;
|
|
||||||
if (envDef.Options.Count > 0)
|
|
||||||
{
|
|
||||||
newVal = AnsiConsole.Prompt(
|
|
||||||
new SelectionPrompt<string>()
|
|
||||||
.Title($"[{Theme.Amber}]{Markup.Escape(envDef.Key)}[/] [{Theme.GreenDim}]current: {Markup.Escape(current)}[/]")
|
|
||||||
.AddChoices(envDef.Options));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
newVal = AnsiConsole.Ask(
|
|
||||||
$"[{Theme.Amber}]{Markup.Escape(envDef.Key)}[/]", current);
|
|
||||||
}
|
|
||||||
|
|
||||||
Environment.SetEnvironmentVariable(envDef.Key, newVal);
|
|
||||||
AnsiConsole.MarkupLine("\n" + Theme.Ok($"{envDef.Key} = {newVal}") + "\n");
|
|
||||||
Thread.Sleep(500);
|
|
||||||
AnsiConsole.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
using Spectre.Console;
|
|
||||||
|
|
||||||
namespace Sdt.Tui;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SDT phosphor-green colour palette.
|
|
||||||
/// Primary text is classic terminal phosphor (#00FF41).
|
|
||||||
/// Modern accent colours are kept for highlights and status.
|
|
||||||
/// </summary>
|
|
||||||
internal static class Theme
|
|
||||||
{
|
|
||||||
// ── Hex colour constants (use in Spectre markup strings) ─────────────────
|
|
||||||
public const string Green = "#00ff41"; // primary phosphor — all normal text
|
|
||||||
public const string GreenDim = "#005c1b"; // muted — borders, secondary info
|
|
||||||
public const string GreenBold = "#a8ff90"; // bright — selections, emphasis
|
|
||||||
public const string Amber = "#ffb300"; // warnings / group titles
|
|
||||||
public const string Red = "#ff4040"; // errors
|
|
||||||
public const string Ghost = "#003d12"; // near-invisible — decorative scanlines
|
|
||||||
|
|
||||||
// ── Spectre Color instances (for FigletText, Rule styles, etc.) ──────────
|
|
||||||
public static readonly Color GreenColor = new(0, 255, 65);
|
|
||||||
public static readonly Color GreenDimColor = new(0, 92, 27);
|
|
||||||
public static readonly Color GreenBoldColor = new(168, 255, 144);
|
|
||||||
public static readonly Color AmberColor = new(255, 179, 0);
|
|
||||||
public static readonly Color RedColor = new(255, 64, 64);
|
|
||||||
|
|
||||||
// ── Pre-built Style objects ───────────────────────────────────────────────
|
|
||||||
public static readonly Style PrimaryStyle = new(GreenColor);
|
|
||||||
public static readonly Style DimStyle = new(GreenDimColor);
|
|
||||||
public static readonly Style BrightStyle = new(GreenBoldColor, decoration: Decoration.Bold);
|
|
||||||
public static readonly Style AmberStyle = new(AmberColor);
|
|
||||||
public static readonly Style RedStyle = new(RedColor, decoration: Decoration.Bold);
|
|
||||||
|
|
||||||
// ── Markup helper methods (auto-escape user content) ─────────────────────
|
|
||||||
public static string G(string t) => $"[{Green}]{Markup.Escape(t)}[/]";
|
|
||||||
public static string Faint(string t) => $"[{GreenDim}]{Markup.Escape(t)}[/]";
|
|
||||||
public static string Bold(string t) => $"[bold {GreenBold}]{Markup.Escape(t)}[/]";
|
|
||||||
public static string Warn(string t) => $"[{Amber}]{Markup.Escape(t)}[/]";
|
|
||||||
public static string Err(string t) => $"[bold {Red}]{Markup.Escape(t)}[/]";
|
|
||||||
public static string Ok(string t) => $"[bold {Green}]✓ {Markup.Escape(t)}[/]";
|
|
||||||
public static string Fail(string t) => $"[bold {Red}]✗ {Markup.Escape(t)}[/]";
|
|
||||||
|
|
||||||
// ── Shared UI components ──────────────────────────────────────────────────
|
|
||||||
public static Rule SectionRule(string? title = null) => title is null
|
|
||||||
? new Rule().RuleStyle(DimStyle)
|
|
||||||
: new Rule($"[bold {GreenBold}]{Markup.Escape(title)}[/]").RuleStyle(DimStyle);
|
|
||||||
|
|
||||||
public static Rule DimRule() => new Rule().RuleStyle(new Style(new Color(0, 40, 12)));
|
|
||||||
|
|
||||||
public static Panel StatusPanel(string markup) =>
|
|
||||||
new Panel(markup)
|
|
||||||
.BorderStyle(DimStyle)
|
|
||||||
.Padding(1, 0);
|
|
||||||
}
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
using Sdt.Config;
|
|
||||||
using Sdt.Core;
|
|
||||||
using Sdt.Runner;
|
|
||||||
using Sdt.Tui;
|
|
||||||
using Spectre.Console;
|
|
||||||
|
|
||||||
namespace Sdt.Tui;
|
|
||||||
|
|
||||||
public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
|
|
||||||
{
|
|
||||||
private readonly DevToolConfig _config = config;
|
|
||||||
private readonly string _projectRoot = projectRoot;
|
|
||||||
|
|
||||||
public async Task RunAsync()
|
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
AnsiConsole.Clear();
|
|
||||||
AnsiConsole.Write(Theme.SectionRule("TOOLCHAINS"));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
var tc = _config.Toolchains;
|
|
||||||
if (tc is null)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(Theme.Warn("No toolchains configured in devtool.json."));
|
|
||||||
AnsiConsole.MarkupLine(Theme.Faint("Add a \"toolchains\" section with \"python\" and/or \"node\" entries."));
|
|
||||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
|
|
||||||
Console.ReadKey(intercept: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build menu from available toolchains
|
|
||||||
var choices = new List<MenuItem>();
|
|
||||||
|
|
||||||
if (tc.Python is not null)
|
|
||||||
{
|
|
||||||
choices.Add(new MenuItem($"[bold {Theme.GreenBold}]PYTHON[/]", "__group__"));
|
|
||||||
choices.Add(new MenuItem($"{Theme.G("Check environment")} {Theme.Faint("detect python, venv, pip")}", "py:check"));
|
|
||||||
choices.Add(new MenuItem($"{Theme.G("Create / recreate venv")} {Theme.Faint($"python -m venv {tc.Python.VenvDir}")}", "py:venv"));
|
|
||||||
if (tc.Python.Profiles.Count > 0)
|
|
||||||
choices.Add(new MenuItem($"{Theme.G("Install requirements profile")} {Theme.Faint("select cpu / gpu / nlp...")}", "py:install"));
|
|
||||||
choices.Add(new MenuItem($"{Theme.G("Upgrade pip")} {Theme.Faint("pip install --upgrade pip")}", "py:upgradepip"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tc.Node is not null)
|
|
||||||
{
|
|
||||||
choices.Add(new MenuItem($"[bold {Theme.GreenBold}]NODE / NPM[/]", "__group__"));
|
|
||||||
choices.Add(new MenuItem($"{Theme.G("Check environment")} {Theme.Faint("detect node, npm, node_modules")}", "node:check"));
|
|
||||||
choices.Add(new MenuItem($"{Theme.G($"{tc.Node.PackageManager} install")} {Theme.Faint($"in {tc.Node.WorkingDir}")}", "node:install"));
|
|
||||||
}
|
|
||||||
|
|
||||||
choices.Add(new MenuItem($"[bold {Theme.GreenBold}]──[/]", "__group__"));
|
|
||||||
choices.Add(new MenuItem(Theme.Faint("← Back"), "__back__"));
|
|
||||||
|
|
||||||
var prompt = new SelectionPrompt<MenuItem>()
|
|
||||||
.Title($"[{Theme.Green}]Select a toolchain action:[/]")
|
|
||||||
.PageSize(20)
|
|
||||||
.UseConverter(m => m.Display)
|
|
||||||
.AddChoices(choices);
|
|
||||||
|
|
||||||
var selected = AnsiConsole.Prompt(prompt);
|
|
||||||
if (selected.Value == "__back__" || selected.Value == "__group__") return;
|
|
||||||
|
|
||||||
AnsiConsole.Clear();
|
|
||||||
AnsiConsole.Write(Theme.SectionRule(selected.Value.Split(':')[0].ToUpperInvariant() + " › " + selected.Value.Split(':')[1]));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
await HandleActionAsync(selected.Value, tc);
|
|
||||||
|
|
||||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to continue..."));
|
|
||||||
Console.ReadKey(intercept: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async Task HandleActionAsync(string action, ToolchainConfig tc)
|
|
||||||
{
|
|
||||||
switch (action)
|
|
||||||
{
|
|
||||||
case "py:check": await CheckPythonAsync(tc.Python!); break;
|
|
||||||
case "py:venv": await CreateVenvAsync(tc.Python!); break;
|
|
||||||
case "py:install": await InstallProfileAsync(tc.Python!); break;
|
|
||||||
case "py:upgradepip": await UpgradePipAsync(tc.Python!); break;
|
|
||||||
case "node:check": await CheckNodeAsync(tc.Node!); break;
|
|
||||||
case "node:install": await NodeInstallAsync(tc.Node!); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Python ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async Task CheckPythonAsync(PythonToolchain py)
|
|
||||||
{
|
|
||||||
var exe = ResolvePythonExe(py);
|
|
||||||
var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, py.VenvDir));
|
|
||||||
var venvPython = GetVenvPython(venvPath);
|
|
||||||
|
|
||||||
var table = new Table()
|
|
||||||
.Border(TableBorder.Rounded)
|
|
||||||
.BorderStyle(Theme.DimStyle)
|
|
||||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Check[/]").Width(24))
|
|
||||||
.AddColumn(new TableColumn($"[{Theme.GreenBold}]Result[/]"));
|
|
||||||
|
|
||||||
// System Python
|
|
||||||
var pyVersion = await ProbeAsync(exe, "--version");
|
|
||||||
table.AddRow(Theme.G("System Python"), pyVersion is not null
|
|
||||||
? Theme.Ok(pyVersion.Trim())
|
|
||||||
: Theme.Fail($"{exe} not found"));
|
|
||||||
|
|
||||||
// Venv exists?
|
|
||||||
table.AddRow(Theme.G($"Venv ({py.VenvDir})"), Directory.Exists(venvPath)
|
|
||||||
? Theme.Ok("exists " + venvPath)
|
|
||||||
: Theme.Warn("not found — use 'Create venv'"));
|
|
||||||
|
|
||||||
// Venv Python
|
|
||||||
if (File.Exists(venvPython))
|
|
||||||
{
|
|
||||||
var venvVersion = await ProbeAsync(venvPython, "--version");
|
|
||||||
table.AddRow(Theme.G("Venv Python"), venvVersion is not null
|
|
||||||
? Theme.Ok(venvVersion.Trim())
|
|
||||||
: Theme.Fail("could not launch"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pip in venv
|
|
||||||
if (File.Exists(venvPython))
|
|
||||||
{
|
|
||||||
var pipVersion = await ProbeAsync(venvPython, "-m", "pip", "--version");
|
|
||||||
table.AddRow(Theme.G("Pip (venv)"), pipVersion is not null
|
|
||||||
? Theme.Ok(pipVersion.Trim())
|
|
||||||
: Theme.Fail("pip not available"));
|
|
||||||
}
|
|
||||||
|
|
||||||
AnsiConsole.Write(table);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CreateVenvAsync(PythonToolchain py)
|
|
||||||
{
|
|
||||||
var exe = ResolvePythonExe(py);
|
|
||||||
var venvDir = py.VenvDir;
|
|
||||||
var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, venvDir));
|
|
||||||
|
|
||||||
if (Directory.Exists(venvPath))
|
|
||||||
{
|
|
||||||
var overwrite = AnsiConsole.Confirm(
|
|
||||||
$"[{Theme.Amber}]Venv already exists at {venvDir}. Recreate it?[/]", defaultValue: false);
|
|
||||||
if (!overwrite) return;
|
|
||||||
Directory.Delete(venvPath, recursive: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
AnsiConsole.MarkupLine(Theme.G($"Creating venv: {exe} -m venv {venvDir}"));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
|
|
||||||
await RunLiveAsync(exe, ["-m", "venv", venvDir], _projectRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task InstallProfileAsync(PythonToolchain py)
|
|
||||||
{
|
|
||||||
var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, py.VenvDir));
|
|
||||||
var venvPy = GetVenvPython(venvPath);
|
|
||||||
|
|
||||||
if (!File.Exists(venvPy))
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(Theme.Warn("Venv not found. Create it first."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var profile = AnsiConsole.Prompt(
|
|
||||||
new SelectionPrompt<PythonProfile>()
|
|
||||||
.Title($"[{Theme.Green}]Select requirements profile:[/]")
|
|
||||||
.UseConverter(p => $"{Theme.Bold(p.Label)} {Theme.Faint(p.RequirementsFile)}")
|
|
||||||
.AddChoices(py.Profiles));
|
|
||||||
|
|
||||||
var reqFile = Path.GetFullPath(Path.Combine(_projectRoot, profile.RequirementsFile));
|
|
||||||
if (!File.Exists(reqFile))
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(Theme.Fail($"Requirements file not found: {reqFile}"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upgrade pip first
|
|
||||||
AnsiConsole.MarkupLine(Theme.Faint("Upgrading pip..."));
|
|
||||||
await RunPipAsync(py, venvPy, ["install", "--upgrade", "pip"]);
|
|
||||||
|
|
||||||
// Build install args
|
|
||||||
var installArgs = new List<string> { "-m", "pip", "install" };
|
|
||||||
if (!string.IsNullOrWhiteSpace(profile.ExtraIndexUrl))
|
|
||||||
{
|
|
||||||
installArgs.Add("--extra-index-url");
|
|
||||||
installArgs.Add(profile.ExtraIndexUrl);
|
|
||||||
}
|
|
||||||
installArgs.Add("-r");
|
|
||||||
installArgs.Add(reqFile);
|
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
AnsiConsole.MarkupLine(Theme.G($"Installing {profile.Label}..."));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
await RunPipAsync(py, venvPy, installArgs.Skip(2)); // strip leading "-m pip"
|
|
||||||
|
|
||||||
// Post-install commands
|
|
||||||
foreach (var cmd in profile.PostInstallCommands)
|
|
||||||
{
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
AnsiConsole.MarkupLine(Theme.Faint($"Post-install: {cmd}"));
|
|
||||||
var parts = cmd.Split(' ', 2);
|
|
||||||
var postArgs = parts.Length > 1 ? parts[1].Split(' ') : Array.Empty<string>();
|
|
||||||
await RunLiveAsync(venvPy, ["-m", .. postArgs], _projectRoot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpgradePipAsync(PythonToolchain py)
|
|
||||||
{
|
|
||||||
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 RunPipAsync(py, exe, ["install", "--upgrade", "pip"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Node ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async Task CheckNodeAsync(NodeToolchain node)
|
|
||||||
{
|
|
||||||
var nodeModules = Path.GetFullPath(
|
|
||||||
Path.Combine(_projectRoot, node.WorkingDir, "node_modules"));
|
|
||||||
|
|
||||||
var table = new Table()
|
|
||||||
.Border(TableBorder.Rounded)
|
|
||||||
.BorderStyle(Theme.DimStyle)
|
|
||||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Check[/]").Width(24))
|
|
||||||
.AddColumn(new TableColumn($"[{Theme.GreenBold}]Result[/]"));
|
|
||||||
|
|
||||||
var nodeVersion = await ProbeAsync("node", "--version");
|
|
||||||
table.AddRow(Theme.G("Node.js"), nodeVersion is not null
|
|
||||||
? Theme.Ok(nodeVersion.Trim())
|
|
||||||
: Theme.Fail("node not found in PATH"));
|
|
||||||
|
|
||||||
var npmVersion = await ProbeAsync(node.PackageManager, "--version");
|
|
||||||
table.AddRow(Theme.G(node.PackageManager), npmVersion is not null
|
|
||||||
? Theme.Ok(npmVersion.Trim())
|
|
||||||
: Theme.Fail($"{node.PackageManager} not found in PATH"));
|
|
||||||
|
|
||||||
table.AddRow(Theme.G("node_modules"), Directory.Exists(nodeModules)
|
|
||||||
? Theme.Ok("exists")
|
|
||||||
: Theme.Warn($"not found — run {node.PackageManager} install"));
|
|
||||||
|
|
||||||
AnsiConsole.Write(table);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task NodeInstallAsync(NodeToolchain node)
|
|
||||||
{
|
|
||||||
var workDir = Path.GetFullPath(Path.Combine(_projectRoot, node.WorkingDir));
|
|
||||||
AnsiConsole.MarkupLine(Theme.G($"{node.PackageManager} install ({workDir})"));
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
await RunLiveAsync(node.PackageManager, ["install"], workDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static string ResolvePythonExe(PythonToolchain py)
|
|
||||||
{
|
|
||||||
if (OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(py.WindowsExecutable))
|
|
||||||
return py.WindowsExecutable;
|
|
||||||
return py.Executable;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetVenvPython(string venvPath)
|
|
||||||
{
|
|
||||||
// Windows: .venv\Scripts\python.exe | Linux/Mac: .venv/bin/python
|
|
||||||
return OperatingSystem.IsWindows()
|
|
||||||
? Path.Combine(venvPath, "Scripts", "python.exe")
|
|
||||||
: Path.Combine(venvPath, "bin", "python");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string?> ProbeAsync(string command, params string[] args)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var psi = new System.Diagnostics.ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = CommandResolver.Resolve(command),
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
};
|
|
||||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
|
||||||
|
|
||||||
using var p = new System.Diagnostics.Process { StartInfo = psi };
|
|
||||||
p.Start();
|
|
||||||
var output = await p.StandardOutput.ReadToEndAsync();
|
|
||||||
var err = await p.StandardError.ReadToEndAsync();
|
|
||||||
await p.WaitForExitAsync();
|
|
||||||
return p.ExitCode == 0 ? (output + err) : null;
|
|
||||||
}
|
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task RunLiveAsync(string command, IEnumerable<string> args, string workingDir)
|
|
||||||
{
|
|
||||||
var result = await ProcessRunner.RunAsync(
|
|
||||||
command, args, workingDir,
|
|
||||||
(line, isErr) => AnsiConsole.MarkupLine(
|
|
||||||
isErr
|
|
||||||
? $"[{Theme.Amber}]{Markup.Escape(line)}[/]"
|
|
||||||
: $"[{Theme.Green}]{Markup.Escape(line)}[/]"));
|
|
||||||
|
|
||||||
AnsiConsole.WriteLine();
|
|
||||||
AnsiConsole.MarkupLine(result.Success
|
|
||||||
? 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
using Sdt.Config;
|
|
||||||
using Spectre.Console;
|
|
||||||
|
|
||||||
namespace Sdt.Tui;
|
|
||||||
|
|
||||||
public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceRoot, string currentProjectRoot)
|
|
||||||
{
|
|
||||||
private readonly WorkspaceConfig _workspace = workspace;
|
|
||||||
private readonly string _workspaceRoot = workspaceRoot;
|
|
||||||
private readonly string _currentProjectRoot = currentProjectRoot;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shows the project switcher. Returns the absolute path to the selected project root,
|
|
||||||
/// or null if the user cancelled.
|
|
||||||
/// </summary>
|
|
||||||
public string? SelectProject()
|
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
var configPath = Path.Combine(absolutePath, "devtool.json");
|
|
||||||
if (!File.Exists(configPath))
|
|
||||||
{
|
|
||||||
var create = AnsiConsole.Confirm(
|
|
||||||
$"[{Theme.Amber}]No devtool.json found. Create a minimal template?[/]",
|
|
||||||
defaultValue: true);
|
|
||||||
if (!create)
|
|
||||||
return;
|
|
||||||
|
|
||||||
File.WriteAllText(configPath, "{\n \"name\": \"SDT Project\",\n \"version\": \"0.1.0\",\n \"workflows\": []\n}\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_workspace.Projects.Any(p =>
|
|
||||||
string.Equals(WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, p), absolutePath, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine(Theme.Warn("Project already exists in workspace."));
|
|
||||||
Thread.Sleep(700);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var relativePath = Path.GetRelativePath(_workspaceRoot, absolutePath);
|
|
||||||
var useRelative = !relativePath.StartsWith("..", StringComparison.OrdinalIgnoreCase) && !Path.IsPathRooted(relativePath);
|
|
||||||
var projectEntry = new WorkspaceProject
|
|
||||||
{
|
|
||||||
Name = new DirectoryInfo(absolutePath).Name,
|
|
||||||
Description = $"External project at {absolutePath}",
|
|
||||||
Path = useRelative ? relativePath : absolutePath,
|
|
||||||
Disabled = false,
|
|
||||||
};
|
|
||||||
|
|
||||||
_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
31
Journal.DevTool/package-lock.json
generated
@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"tauri-plugin-mic-recorder-api": "^2.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,419 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
@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.
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
# 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."
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
param(
|
|
||||||
[Parameter(ValueFromRemainingArguments = $true)]
|
|
||||||
[string[]]$DotnetArgs
|
|
||||||
)
|
|
||||||
|
|
||||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
|
||||||
|
|
||||||
# Keep dotnet and NuGet artifacts local to the repo for easy cleanup.
|
|
||||||
$env:DOTNET_CLI_HOME = Join-Path $repoRoot ".dotnet_home"
|
|
||||||
$env:NUGET_PACKAGES = Join-Path $repoRoot ".nuget\packages"
|
|
||||||
$env:NUGET_HTTP_CACHE_PATH = Join-Path $repoRoot ".nuget\http-cache"
|
|
||||||
|
|
||||||
# Keep setup minimal and non-interactive.
|
|
||||||
$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"
|
|
||||||
|
|
||||||
# Clear proxy env vars for this process. The host machine currently points them
|
|
||||||
# to 127.0.0.1:9, which breaks NuGet restore.
|
|
||||||
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
|
|
||||||
|
|
||||||
# Prefer offline cert revocation checks to reduce flaky TLS behavior on constrained hosts.
|
|
||||||
$env:NUGET_CERT_REVOCATION_MODE = "offline"
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path $env:DOTNET_CLI_HOME | Out-Null
|
|
||||||
New-Item -ItemType Directory -Force -Path $env:NUGET_PACKAGES | Out-Null
|
|
||||||
New-Item -ItemType Directory -Force -Path $env:NUGET_HTTP_CACHE_PATH | Out-Null
|
|
||||||
|
|
||||||
if (-not $DotnetArgs -or $DotnetArgs.Count -eq 0) {
|
|
||||||
Write-Host "Usage: ./scripts/dotnet-min.ps1 <dotnet args>"
|
|
||||||
Write-Host "Example: ./scripts/dotnet-min.ps1 build Journal.Sidecar/Journal.Sidecar.csproj"
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
|
|
||||||
$firstArg = $DotnetArgs[0].ToLowerInvariant()
|
|
||||||
$effectiveArgs = @($DotnetArgs)
|
|
||||||
|
|
||||||
if ($firstArg -in @("restore", "build", "run", "test", "publish", "pack")) {
|
|
||||||
if (-not ($effectiveArgs -contains "-p:RestoreIgnoreFailedSources=true")) {
|
|
||||||
$effectiveArgs += "-p:RestoreIgnoreFailedSources=true"
|
|
||||||
}
|
|
||||||
if (-not ($effectiveArgs -contains "-p:NuGetAudit=false")) {
|
|
||||||
$effectiveArgs += "-p:NuGetAudit=false"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($firstArg -eq "restore") {
|
|
||||||
if (-not ($effectiveArgs -contains "--ignore-failed-sources")) {
|
|
||||||
$effectiveArgs += "--ignore-failed-sources"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& dotnet @effectiveArgs
|
|
||||||
exit $LASTEXITCODE
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
param(
|
|
||||||
[switch]$SkipSmoke,
|
|
||||||
[switch]$SkipApi
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
||||||
$parityReport = Join-Path $repoRoot "logs\parity_harness_results.json"
|
|
||||||
|
|
||||||
Write-Host "migration-gate: repo root = $repoRoot"
|
|
||||||
|
|
||||||
Push-Location $repoRoot
|
|
||||||
try {
|
|
||||||
Write-Host "migration-gate: building sidecar binary..."
|
|
||||||
& "$repoRoot\scripts\dotnet-min.ps1" build Journal.Sidecar/Journal.Sidecar.csproj
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
|
|
||||||
if (-not $SkipSmoke) {
|
|
||||||
Write-Host "migration-gate: running csharp smoke tests..."
|
|
||||||
& "$repoRoot\scripts\dotnet-min.ps1" run --project Journal.SmokeTests/Journal.SmokeTests.csproj
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "migration-gate: skipping smoke tests (--SkipSmoke)."
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "migration-gate: running parity harness + fixture matrix..."
|
|
||||||
$testsDir = Join-Path $repoRoot "tests"
|
|
||||||
if (Test-Path $testsDir) {
|
|
||||||
$env:PARITY_HARNESS_REPORT = $parityReport
|
|
||||||
& python -m unittest discover -s tests -p "test_parity_harness.py" -v
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "migration-gate: skipping parity harness — tests/ directory not found."
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $SkipApi) {
|
|
||||||
Write-Host "migration-gate: running API contract tests..."
|
|
||||||
if (Test-Path $testsDir) {
|
|
||||||
& python -m unittest discover -s tests -p "test_api_contract.py" -v
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "migration-gate: skipping API contract tests — tests/ directory not found."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "migration-gate: skipping API contract tests (--SkipApi)."
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "migration-gate: PASS"
|
|
||||||
Write-Host "migration-gate: parity report => $parityReport"
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
Pop-Location
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
param(
|
|
||||||
[switch]$RemoveLockfile,
|
|
||||||
[switch]$Force
|
|
||||||
)
|
|
||||||
|
|
||||||
$commonScript = Join-Path $PSScriptRoot "script-common.ps1"
|
|
||||||
if (-not (Test-Path $commonScript)) {
|
|
||||||
throw "Missing helper script: $commonScript"
|
|
||||||
}
|
|
||||||
. $commonScript
|
|
||||||
|
|
||||||
$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot
|
|
||||||
$appRoot = Resolve-JournalAppRoot -RepoRoot $repoRoot
|
|
||||||
|
|
||||||
Write-Host "Cleaning npm artifacts for Journal.App" -ForegroundColor Cyan
|
|
||||||
Write-Host "Using app root: $appRoot" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
$processNames = @("node", "journalapp", "tauri")
|
|
||||||
Get-Process -ErrorAction SilentlyContinue |
|
|
||||||
Where-Object { $processNames -contains $_.ProcessName } |
|
|
||||||
ForEach-Object {
|
|
||||||
try {
|
|
||||||
Stop-Process -Id $_.Id -Force -ErrorAction Stop
|
|
||||||
Write-Host "Stopped process: $($_.ProcessName) ($($_.Id))" -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "Failed to stop process $($_.ProcessName) ($($_.Id)): $($_.Exception.Message)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Push-Location $appRoot
|
|
||||||
try {
|
|
||||||
$nodeModulesPath = Join-Path $appRoot "node_modules"
|
|
||||||
$lockfilePath = Join-Path $appRoot "package-lock.json"
|
|
||||||
|
|
||||||
if (Test-Path $nodeModulesPath) {
|
|
||||||
if (-not $Force) {
|
|
||||||
Write-Host "Removing node_modules (use -Force to suppress prompt)..." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
Remove-Item -Recurse -Force $nodeModulesPath
|
|
||||||
Write-Host "Removed node_modules." -ForegroundColor Green
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "node_modules not found; nothing to remove." -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($RemoveLockfile) {
|
|
||||||
if (Test-Path $lockfilePath) {
|
|
||||||
Remove-Item -Force $lockfilePath
|
|
||||||
Write-Host "Removed package-lock.json." -ForegroundColor Green
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "package-lock.json not found; nothing to remove." -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Keeping package-lock.json (pass -RemoveLockfile to delete)." -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
Pop-Location
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
param(
|
|
||||||
[string]$OutputZip = "nuget-cache-export.zip",
|
|
||||||
[switch]$IncludeDotnetHome
|
|
||||||
)
|
|
||||||
|
|
||||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
|
||||||
$outputPath = if ([System.IO.Path]::IsPathRooted($OutputZip)) { $OutputZip } else { Join-Path $repoRoot $OutputZip }
|
|
||||||
$outputDir = Split-Path -Parent $outputPath
|
|
||||||
if (-not (Test-Path $outputDir)) {
|
|
||||||
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Priming restore cache..."
|
|
||||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.Sidecar/Journal.Sidecar.csproj"
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.WebGateway/Journal.WebGateway.csproj"
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.SmokeTests/Journal.SmokeTests.csproj"
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
|
|
||||||
$staging = Join-Path $repoRoot ".nuget-export-staging"
|
|
||||||
if (Test-Path $staging) {
|
|
||||||
Remove-Item -Recurse -Force $staging
|
|
||||||
}
|
|
||||||
New-Item -ItemType Directory -Force -Path $staging | Out-Null
|
|
||||||
|
|
||||||
$nugetRoot = Join-Path $repoRoot ".nuget"
|
|
||||||
if (-not (Test-Path $nugetRoot)) {
|
|
||||||
Write-Error "No .nuget directory found under $repoRoot"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Copy-Item -Recurse -Force -Path $nugetRoot -Destination (Join-Path $staging ".nuget")
|
|
||||||
if ($IncludeDotnetHome) {
|
|
||||||
$dotnetHome = Join-Path $repoRoot ".dotnet_home"
|
|
||||||
if (Test-Path $dotnetHome) {
|
|
||||||
Copy-Item -Recurse -Force -Path $dotnetHome -Destination (Join-Path $staging ".dotnet_home")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$manifest = @(
|
|
||||||
"exported_utc=$([DateTime]::UtcNow.ToString("o"))"
|
|
||||||
"repo_root=$repoRoot"
|
|
||||||
"include_dotnet_home=$($IncludeDotnetHome.IsPresent)"
|
|
||||||
"note=Copy this zip to target machine and run scripts/nuget-import-cache.ps1"
|
|
||||||
)
|
|
||||||
$manifest | Set-Content -Encoding UTF8 -Path (Join-Path $staging "nuget-cache-manifest.txt")
|
|
||||||
|
|
||||||
if (Test-Path $outputPath) {
|
|
||||||
Remove-Item -Force $outputPath
|
|
||||||
}
|
|
||||||
|
|
||||||
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $outputPath -Force
|
|
||||||
Remove-Item -Recurse -Force $staging
|
|
||||||
|
|
||||||
Write-Host "NuGet cache export created at: $outputPath"
|
|
||||||
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
param(
|
|
||||||
[string]$InputZip = "nuget-cache-export.zip"
|
|
||||||
)
|
|
||||||
|
|
||||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
|
||||||
$inputPath = if ([System.IO.Path]::IsPathRooted($InputZip)) { $InputZip } else { Join-Path $repoRoot $InputZip }
|
|
||||||
|
|
||||||
if (-not (Test-Path $inputPath)) {
|
|
||||||
Write-Error "Input zip not found: $inputPath"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Importing cache from: $inputPath"
|
|
||||||
Expand-Archive -Path $inputPath -DestinationPath $repoRoot -Force
|
|
||||||
|
|
||||||
Write-Host "Running restore with local cache..."
|
|
||||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.Sidecar/Journal.Sidecar.csproj"
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.WebGateway/Journal.WebGateway.csproj"
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
& (Join-Path $PSScriptRoot "dotnet-min.ps1") restore "Journal.SmokeTests/Journal.SmokeTests.csproj"
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
|
|
||||||
Write-Host "Cache import complete."
|
|
||||||
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
param(
|
|
||||||
[Parameter(ValueFromRemainingArguments = $true)]
|
|
||||||
[string[]]$PipArgs
|
|
||||||
)
|
|
||||||
|
|
||||||
$commonScript = Join-Path $PSScriptRoot "script-common.ps1"
|
|
||||||
if (-not (Test-Path $commonScript)) {
|
|
||||||
throw "Missing helper script: $commonScript"
|
|
||||||
}
|
|
||||||
. $commonScript
|
|
||||||
|
|
||||||
$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot
|
|
||||||
|
|
||||||
Initialize-JournalPipEnv -RepoRoot $repoRoot
|
|
||||||
Clear-JournalProxyEnv
|
|
||||||
|
|
||||||
if (-not $PipArgs -or $PipArgs.Count -eq 0) {
|
|
||||||
Write-Host "Usage: ./scripts/pip-min.ps1 <pip args>"
|
|
||||||
Write-Host "Example: ./scripts/pip-min.ps1 install --index-url https://pypi.org/simple faster-whisper"
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
|
|
||||||
# Default install target to a repo-local directory so installs do not require
|
|
||||||
# user/site-packages write access on constrained hosts.
|
|
||||||
$effectiveArgs = @($PipArgs)
|
|
||||||
$firstArg = $effectiveArgs[0].ToLowerInvariant()
|
|
||||||
if ($firstArg -eq "install") {
|
|
||||||
# On Windows, map PyAudio to pyaudiowpatch (wheel available for newer CPython),
|
|
||||||
# avoiding source builds that require PortAudio headers/toolchain wiring.
|
|
||||||
for ($i = 0; $i -lt $effectiveArgs.Count; $i++) {
|
|
||||||
$arg = $effectiveArgs[$i]
|
|
||||||
if ($arg -match '^(?i)pyaudio($|[<>=!~].*)') {
|
|
||||||
$suffix = $arg.Substring(7)
|
|
||||||
$effectiveArgs[$i] = "pyaudiowpatch$suffix"
|
|
||||||
Write-Host "pip-min: mapped '$arg' -> '$($effectiveArgs[$i])' on Windows."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$hasTarget = $effectiveArgs -contains "--target" -or $effectiveArgs -contains "-t" -or $effectiveArgs -contains "--prefix"
|
|
||||||
if (-not $hasTarget) {
|
|
||||||
$effectiveArgs = $effectiveArgs | Where-Object { $_ -ne "--user" }
|
|
||||||
$localTarget = Join-Path $repoRoot ".pydeps\py314"
|
|
||||||
New-Item -ItemType Directory -Force -Path $localTarget | Out-Null
|
|
||||||
$effectiveArgs += @("--target", $localTarget)
|
|
||||||
Write-Host "pip-min: using local target $localTarget"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$pipWrapper = Join-Path $PSScriptRoot "pip_safe.py"
|
|
||||||
if (Test-Path $pipWrapper) {
|
|
||||||
& python $pipWrapper @effectiveArgs
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
& python -m pip @effectiveArgs
|
|
||||||
}
|
|
||||||
exit $LASTEXITCODE
|
|
||||||
@ -1,216 +0,0 @@
|
|||||||
param(
|
|
||||||
[ValidateSet("web", "tauri")]
|
|
||||||
[string]$Target = "web",
|
|
||||||
[ValidateSet("Release", "Debug")]
|
|
||||||
[string]$Configuration = "Release",
|
|
||||||
[ValidateSet("none", "nsis", "msi")]
|
|
||||||
[string]$TauriBundles = "none",
|
|
||||||
[switch]$InstallDeps,
|
|
||||||
[switch]$SkipInstall,
|
|
||||||
[switch]$DryRun
|
|
||||||
)
|
|
||||||
|
|
||||||
$commonScript = Join-Path $PSScriptRoot "script-common.ps1"
|
|
||||||
if (-not (Test-Path $commonScript)) {
|
|
||||||
throw "Missing helper script: $commonScript"
|
|
||||||
}
|
|
||||||
. $commonScript
|
|
||||||
|
|
||||||
$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot
|
|
||||||
$appRoot = Resolve-JournalAppRoot -RepoRoot $repoRoot
|
|
||||||
|
|
||||||
Clear-JournalProxyEnv
|
|
||||||
|
|
||||||
# Keep npm cache and temp local to the repo.
|
|
||||||
$npmCacheDir = Join-Path $repoRoot ".npm\cache"
|
|
||||||
$npmTempDir = Join-Path $repoRoot ".tmp\npm-temp"
|
|
||||||
New-Item -ItemType Directory -Force -Path $npmCacheDir, $npmTempDir | Out-Null
|
|
||||||
$env:npm_config_cache = $npmCacheDir
|
|
||||||
$env:npm_config_update_notifier = "false"
|
|
||||||
$env:npm_config_fund = "false"
|
|
||||||
$env:npm_config_audit = "false"
|
|
||||||
$env:npm_config_offline = "false"
|
|
||||||
$env:npm_config_prefer_offline = "false"
|
|
||||||
$env:npm_config_prefer_online = "true"
|
|
||||||
$env:TEMP = $npmTempDir
|
|
||||||
$env:TMP = $npmTempDir
|
|
||||||
|
|
||||||
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
|
|
||||||
throw "npm is required but was not found in PATH."
|
|
||||||
}
|
|
||||||
|
|
||||||
$nodeModulesPath = Join-Path $appRoot "node_modules"
|
|
||||||
$packageJsonPath = Join-Path $appRoot "package.json"
|
|
||||||
$packageLockPath = Join-Path $appRoot "package-lock.json"
|
|
||||||
$depsHashPath = Join-Path $appRoot "node_modules\.journal-deps.sha256"
|
|
||||||
|
|
||||||
function Get-JournalNodeDepsHash {
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[string[]]$Paths
|
|
||||||
)
|
|
||||||
|
|
||||||
$hashLines = foreach ($path in $Paths) {
|
|
||||||
if (-not (Test-Path $path)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
(Get-FileHash -Algorithm SHA256 -Path $path).Hash
|
|
||||||
}
|
|
||||||
return ($hashLines -join "`n").Trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
$hashInputs = @()
|
|
||||||
if (Test-Path $packageJsonPath) {
|
|
||||||
$hashInputs += $packageJsonPath
|
|
||||||
}
|
|
||||||
if (Test-Path $packageLockPath) {
|
|
||||||
$hashInputs += $packageLockPath
|
|
||||||
}
|
|
||||||
if ($hashInputs.Count -eq 0) {
|
|
||||||
throw "package.json not found under $appRoot."
|
|
||||||
}
|
|
||||||
|
|
||||||
$expectedDepsHash = Get-JournalNodeDepsHash -Paths $hashInputs
|
|
||||||
$shouldInstall = $InstallDeps -or (-not (Test-Path $nodeModulesPath))
|
|
||||||
$installReason = $null
|
|
||||||
|
|
||||||
if (-not $shouldInstall -and -not $SkipInstall) {
|
|
||||||
if (-not (Test-Path $depsHashPath)) {
|
|
||||||
$shouldInstall = $true
|
|
||||||
$installReason = "dependency hash missing"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$currentDepsHash = (Get-Content $depsHashPath -Raw).Trim()
|
|
||||||
if ($currentDepsHash -ne $expectedDepsHash) {
|
|
||||||
$shouldInstall = $true
|
|
||||||
$installReason = "package.json/lockfile changed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($SkipInstall) {
|
|
||||||
$shouldInstall = $false
|
|
||||||
if ($installReason) {
|
|
||||||
Write-Host "SkipInstall set; dependencies may be stale ($installReason)." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Building Journal.App target '$Target' ($Configuration)..." -ForegroundColor Cyan
|
|
||||||
Write-Host "Using app root: $appRoot" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
Push-Location $appRoot
|
|
||||||
try {
|
|
||||||
if ($shouldInstall) {
|
|
||||||
$installArgs = if (Test-Path $packageLockPath) {
|
|
||||||
@("ci", "--no-audit", "--fund=false")
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
@("install", "--no-audit", "--fund=false")
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($installReason) {
|
|
||||||
Write-Host "Dependencies changed ($installReason). Installing..." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "> npm $($installArgs -join ' ')" -ForegroundColor DarkGray
|
|
||||||
if (-not $DryRun) {
|
|
||||||
& npm @installArgs
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Dependency install failed with exit code $LASTEXITCODE."
|
|
||||||
}
|
|
||||||
|
|
||||||
$depsDir = Split-Path $depsHashPath -Parent
|
|
||||||
if (-not (Test-Path $depsDir)) {
|
|
||||||
New-Item -ItemType Directory -Force -Path $depsDir | Out-Null
|
|
||||||
}
|
|
||||||
$expectedDepsHash | Set-Content -Path $depsHashPath -NoNewline
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Skipping dependency install (node_modules present and deps unchanged)." -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Target -eq "web") {
|
|
||||||
$buildArgs = @("run", "build")
|
|
||||||
Write-Host "> npm $($buildArgs -join ' ')" -ForegroundColor DarkGray
|
|
||||||
if (-not $DryRun) {
|
|
||||||
& npm @buildArgs
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Frontend build failed with exit code $LASTEXITCODE."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$outputPath = Join-Path $appRoot "build"
|
|
||||||
if ($DryRun) {
|
|
||||||
Write-Host "`nDry run complete (no commands executed)." -ForegroundColor Yellow
|
|
||||||
Write-Host "Expected output: $outputPath" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "`nFrontend build successful." -ForegroundColor Green
|
|
||||||
Write-Host "Output: $outputPath" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$tauriArgs = @("run", "tauri", "build")
|
|
||||||
$tauriCliArgs = @()
|
|
||||||
if ($TauriBundles -eq "none") {
|
|
||||||
$tauriCliArgs += "--no-bundle"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$tauriCliArgs += @("--bundles", $TauriBundles)
|
|
||||||
}
|
|
||||||
if ($Configuration -eq "Debug") {
|
|
||||||
$tauriCliArgs += "--debug"
|
|
||||||
}
|
|
||||||
if ($tauriCliArgs.Count -gt 0) {
|
|
||||||
$tauriArgs += "--"
|
|
||||||
$tauriArgs += $tauriCliArgs
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "> npm $($tauriArgs -join ' ')" -ForegroundColor DarkGray
|
|
||||||
if (-not $DryRun) {
|
|
||||||
& npm @tauriArgs
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Tauri build failed with exit code $LASTEXITCODE."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$targetConfigDir = if ($Configuration -eq "Debug") { "debug" } else { "release" }
|
|
||||||
$tauriTargetPath = Join-Path $appRoot "src-tauri\target"
|
|
||||||
$rawExePath = Join-Path $tauriTargetPath "$targetConfigDir\journalapp.exe"
|
|
||||||
if ($DryRun) {
|
|
||||||
Write-Host "`nDry run complete (no commands executed)." -ForegroundColor Yellow
|
|
||||||
if ($TauriBundles -eq "none") {
|
|
||||||
Write-Host "Expected executable: $rawExePath" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Expected output root: $tauriTargetPath" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "`nTauri build successful." -ForegroundColor Green
|
|
||||||
if ($TauriBundles -eq "none") {
|
|
||||||
if (Test-Path $rawExePath) {
|
|
||||||
Write-Host "Executable location: $rawExePath" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$exeCandidates = Get-ChildItem -Path (Join-Path $tauriTargetPath $targetConfigDir) -File -Filter *.exe -ErrorAction SilentlyContinue |
|
|
||||||
Sort-Object LastWriteTime -Descending
|
|
||||||
if ($exeCandidates -and $exeCandidates.Count -gt 0) {
|
|
||||||
Write-Host "Executable location: $($exeCandidates[0].FullName)" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Output root: $tauriTargetPath" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Output root: $tauriTargetPath" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
Pop-Location
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
param(
|
|
||||||
[ValidateSet("Release", "Debug")]
|
|
||||||
[string]$Configuration = "Release",
|
|
||||||
[string]$Runtime = "win-x64",
|
|
||||||
[switch]$SkipSidecar,
|
|
||||||
[switch]$SkipWeb,
|
|
||||||
[switch]$SkipWebGateway,
|
|
||||||
[switch]$SkipTauri,
|
|
||||||
[switch]$DryRun
|
|
||||||
)
|
|
||||||
|
|
||||||
$commonScript = Join-Path $PSScriptRoot "script-common.ps1"
|
|
||||||
if (-not (Test-Path $commonScript)) {
|
|
||||||
throw "Missing helper script: $commonScript"
|
|
||||||
}
|
|
||||||
. $commonScript
|
|
||||||
|
|
||||||
$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot
|
|
||||||
$appRoot = Resolve-JournalAppRoot -RepoRoot $repoRoot
|
|
||||||
$outputRoot = Join-Path $repoRoot "output"
|
|
||||||
|
|
||||||
$publishSidecar = Join-Path $PSScriptRoot "publish-sidecar.ps1"
|
|
||||||
$publishApp = Join-Path $PSScriptRoot "publish-app.ps1"
|
|
||||||
$publishGateway = Join-Path $PSScriptRoot "publish-webgateway.ps1"
|
|
||||||
|
|
||||||
Write-Host "Publishing all outputs to: $outputRoot" -ForegroundColor Cyan
|
|
||||||
Write-Host "Configuration: $Configuration Runtime: $Runtime" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
if (-not (Test-Path $outputRoot)) {
|
|
||||||
New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
function Invoke-Step {
|
|
||||||
param(
|
|
||||||
[string]$Label,
|
|
||||||
[string]$ScriptPath,
|
|
||||||
[string[]]$Args
|
|
||||||
)
|
|
||||||
|
|
||||||
Write-Host "`n> $Label" -ForegroundColor Cyan
|
|
||||||
Write-Host " $ScriptPath $($Args -join ' ')" -ForegroundColor DarkGray
|
|
||||||
if (-not $DryRun) {
|
|
||||||
& $ScriptPath @Args
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $SkipSidecar) {
|
|
||||||
Invoke-Step "Publish Sidecar" $publishSidecar @(
|
|
||||||
"-Configuration", $Configuration,
|
|
||||||
"-Runtime", $Runtime
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Skipping sidecar publish." -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $SkipWeb) {
|
|
||||||
Invoke-Step "Build Web UI" $publishApp @(
|
|
||||||
"-Target", "web",
|
|
||||||
"-Configuration", $Configuration
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Skipping web build." -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $SkipWebGateway) {
|
|
||||||
Invoke-Step "Publish WebGateway" $publishGateway @(
|
|
||||||
"-Configuration", $Configuration,
|
|
||||||
"-Runtime", $Runtime
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Skipping WebGateway publish." -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $SkipTauri) {
|
|
||||||
Invoke-Step "Build Tauri Desktop App" $publishApp @(
|
|
||||||
"-Target", "tauri",
|
|
||||||
"-Configuration", $Configuration,
|
|
||||||
"-TauriBundles", "none"
|
|
||||||
)
|
|
||||||
|
|
||||||
$targetConfigDir = if ($Configuration -eq "Debug") { "debug" } else { "release" }
|
|
||||||
$tauriExePath = Join-Path $appRoot "src-tauri\\target\\$targetConfigDir\\journalapp.exe"
|
|
||||||
$stagedExePath = Join-Path $outputRoot "journalapp.exe"
|
|
||||||
|
|
||||||
if (Test-Path $tauriExePath) {
|
|
||||||
if ($DryRun) {
|
|
||||||
Write-Host "Would copy: $tauriExePath -> $stagedExePath" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Copy-Item -Force $tauriExePath $stagedExePath
|
|
||||||
Write-Host "Staged desktop exe: $stagedExePath" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Warning "Tauri exe not found at $tauriExePath"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Skipping Tauri build." -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
param(
|
|
||||||
[string]$Configuration = "Release",
|
|
||||||
[string]$Runtime = "win-x64"
|
|
||||||
)
|
|
||||||
|
|
||||||
$commonScript = Join-Path $PSScriptRoot "script-common.ps1"
|
|
||||||
if (-not (Test-Path $commonScript)) {
|
|
||||||
throw "Missing helper script: $commonScript"
|
|
||||||
}
|
|
||||||
. $commonScript
|
|
||||||
|
|
||||||
$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot
|
|
||||||
$csproj = Resolve-JournalSidecarProjectPath -RepoRoot $repoRoot
|
|
||||||
$outputDir = Join-Path $repoRoot "output"
|
|
||||||
|
|
||||||
# Setup local dotnet environment (matches dotnet-min.ps1 logic)
|
|
||||||
Clear-JournalProxyEnv
|
|
||||||
Initialize-JournalDotnetEnv -RepoRoot $repoRoot
|
|
||||||
|
|
||||||
Write-Host "Publishing Journal.Sidecar ($Configuration, $Runtime)..." -ForegroundColor Cyan
|
|
||||||
Write-Host "Using project: $csproj" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
$publishArgs = @(
|
|
||||||
"publish", $csproj,
|
|
||||||
"-c", $Configuration,
|
|
||||||
"-r", $Runtime,
|
|
||||||
"--self-contained",
|
|
||||||
"-p:PublishSingleFile=true",
|
|
||||||
"-p:IncludeNativeLibrariesForSelfExtract=true",
|
|
||||||
"-p:RestoreIgnoreFailedSources=true",
|
|
||||||
"-p:NuGetAudit=false",
|
|
||||||
"-o", $outputDir
|
|
||||||
)
|
|
||||||
|
|
||||||
& dotnet @publishArgs
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
$binaryName = [System.IO.Path]::GetFileNameWithoutExtension($csproj)
|
|
||||||
$isWindowsRuntime = $Runtime -like "win-*"
|
|
||||||
$binaryFile = if ($isWindowsRuntime) { "$binaryName.exe" } else { $binaryName }
|
|
||||||
$binaryPath = Join-Path $outputDir $binaryFile
|
|
||||||
|
|
||||||
Write-Host "`nPublish successful!" -ForegroundColor Green
|
|
||||||
if (Test-Path $binaryPath) {
|
|
||||||
Write-Host "Executable location: $binaryPath" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Output directory: $outputDir" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "`nPublish failed with exit code $LASTEXITCODE" -ForegroundColor Red
|
|
||||||
exit $LASTEXITCODE
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
param(
|
|
||||||
[ValidateSet("Release", "Debug")]
|
|
||||||
[string]$Configuration = "Release",
|
|
||||||
[string]$Runtime = "win-x64",
|
|
||||||
[switch]$SelfContained,
|
|
||||||
[switch]$SkipWebAssets
|
|
||||||
)
|
|
||||||
|
|
||||||
$commonScript = Join-Path $PSScriptRoot "script-common.ps1"
|
|
||||||
if (-not (Test-Path $commonScript)) {
|
|
||||||
throw "Missing helper script: $commonScript"
|
|
||||||
}
|
|
||||||
. $commonScript
|
|
||||||
|
|
||||||
$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot
|
|
||||||
$gatewayProject = Resolve-JournalWebGatewayProjectPath -RepoRoot $repoRoot
|
|
||||||
$outputDir = Join-Path $repoRoot "output\webgateway"
|
|
||||||
$webBuildDir = Join-Path $repoRoot "Journal.App\build"
|
|
||||||
$webOutputDir = Join-Path $outputDir "wwwroot"
|
|
||||||
|
|
||||||
Clear-JournalProxyEnv
|
|
||||||
Initialize-JournalDotnetEnv -RepoRoot $repoRoot
|
|
||||||
|
|
||||||
Write-Host "Publishing Journal.WebGateway ($Configuration, $Runtime)..." -ForegroundColor Cyan
|
|
||||||
Write-Host "Project: $gatewayProject" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
$publishArgs = @(
|
|
||||||
"publish", $gatewayProject,
|
|
||||||
"-c", $Configuration,
|
|
||||||
"-r", $Runtime,
|
|
||||||
"--self-contained", ($SelfContained.IsPresent.ToString().ToLowerInvariant()),
|
|
||||||
"-p:RestoreIgnoreFailedSources=true",
|
|
||||||
"-p:NuGetAudit=false",
|
|
||||||
"-o", $outputDir
|
|
||||||
)
|
|
||||||
|
|
||||||
& dotnet @publishArgs
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Host "`nPublish failed with exit code $LASTEXITCODE" -ForegroundColor Red
|
|
||||||
exit $LASTEXITCODE
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $SkipWebAssets) {
|
|
||||||
if (Test-Path $webBuildDir) {
|
|
||||||
New-Item -ItemType Directory -Force -Path $webOutputDir | Out-Null
|
|
||||||
Copy-Item -Path (Join-Path $webBuildDir "*") -Destination $webOutputDir -Recurse -Force
|
|
||||||
Write-Host "Copied web assets to: $webOutputDir" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Warning "Journal.App build output not found at $webBuildDir. Run ./scripts/publish-app.ps1 -Target web first."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "`nPublish successful." -ForegroundColor Green
|
|
||||||
Write-Host "Output directory: $outputDir" -ForegroundColor Gray
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
param(
|
|
||||||
[ValidateSet("Release", "Debug")]
|
|
||||||
[string]$Configuration = "Release",
|
|
||||||
[string]$Urls = "http://0.0.0.0:5180",
|
|
||||||
[string]$ProjectRoot,
|
|
||||||
[ValidateSet("Dev", "Output")]
|
|
||||||
[string]$Mode = "Dev"
|
|
||||||
)
|
|
||||||
|
|
||||||
$commonScript = Join-Path $PSScriptRoot "script-common.ps1"
|
|
||||||
if (-not (Test-Path $commonScript)) {
|
|
||||||
throw "Missing helper script: $commonScript"
|
|
||||||
}
|
|
||||||
. $commonScript
|
|
||||||
|
|
||||||
$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot
|
|
||||||
$gatewayProject = Resolve-JournalWebGatewayProjectPath -RepoRoot $repoRoot
|
|
||||||
|
|
||||||
$effectiveProjectRoot = if ([string]::IsNullOrWhiteSpace($ProjectRoot)) {
|
|
||||||
$repoRoot
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
[System.IO.Path]::GetFullPath($ProjectRoot)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $effectiveProjectRoot)) {
|
|
||||||
throw "ProjectRoot does not exist: $effectiveProjectRoot"
|
|
||||||
}
|
|
||||||
|
|
||||||
Clear-JournalProxyEnv
|
|
||||||
Initialize-JournalDotnetEnv -RepoRoot $repoRoot
|
|
||||||
$env:JOURNAL_PROJECT_ROOT = $effectiveProjectRoot
|
|
||||||
|
|
||||||
if ($Mode -eq "Output") {
|
|
||||||
$exeName = if ([System.Environment]::OSVersion.Platform -eq "Win32NT") { "Journal.WebGateway.exe" } else { "Journal.WebGateway" }
|
|
||||||
$exePath = Join-Path $repoRoot "output\webgateway\$exeName"
|
|
||||||
|
|
||||||
if (-not (Test-Path $exePath)) {
|
|
||||||
Write-Host "Output executable not found at $exePath" -ForegroundColor Red
|
|
||||||
Write-Host "Please build WebGateway first (e.g. scripts\publish-webgateway.ps1)" -ForegroundColor Yellow
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Running Journal.WebGateway (Published Output)..." -ForegroundColor Cyan
|
|
||||||
Write-Host "Executable: $exePath" -ForegroundColor DarkGray
|
|
||||||
Write-Host "URLs: $Urls" -ForegroundColor DarkGray
|
|
||||||
Write-Host "JOURNAL_PROJECT_ROOT: $effectiveProjectRoot" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
& $exePath --urls $Urls
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Running Journal.WebGateway ($Configuration Dev Server)..." -ForegroundColor Cyan
|
|
||||||
Write-Host "Project: $gatewayProject" -ForegroundColor DarkGray
|
|
||||||
Write-Host "URLs: $Urls" -ForegroundColor DarkGray
|
|
||||||
Write-Host "JOURNAL_PROJECT_ROOT: $effectiveProjectRoot" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
$runArgs = @(
|
|
||||||
"run",
|
|
||||||
"--project", $gatewayProject,
|
|
||||||
"-c", $Configuration,
|
|
||||||
"--no-launch-profile",
|
|
||||||
"--urls", $Urls,
|
|
||||||
"-p:RestoreIgnoreFailedSources=true",
|
|
||||||
"-p:NuGetAudit=false"
|
|
||||||
)
|
|
||||||
|
|
||||||
& dotnet @runArgs
|
|
||||||
}
|
|
||||||
exit $LASTEXITCODE
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
param()
|
|
||||||
|
|
||||||
$commonScript = Join-Path $PSScriptRoot "script-common.ps1"
|
|
||||||
if (-not (Test-Path $commonScript)) {
|
|
||||||
throw "Missing helper script: $commonScript"
|
|
||||||
}
|
|
||||||
. $commonScript
|
|
||||||
|
|
||||||
$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot
|
|
||||||
$outputDir = Join-Path $repoRoot "output"
|
|
||||||
|
|
||||||
# Ensure output exists
|
|
||||||
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
|
|
||||||
|
|
||||||
Write-Host "Syncing all recent built assets to output directory..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# Helper to find the newest compiled binary
|
|
||||||
function Find-NewestBin([string]$SearchPath, [string]$Pattern) {
|
|
||||||
if (-not (Test-Path $SearchPath)) { return $null }
|
|
||||||
$files = Get-ChildItem -Path $SearchPath -Filter $Pattern -Recurse -File -ErrorAction SilentlyContinue |
|
|
||||||
Where-Object { $_.FullName -notmatch '\\obj\\' } |
|
|
||||||
Sort-Object LastWriteTime -Descending
|
|
||||||
if ($files) { return $files[0] }
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
# 1. Front-end Web Assets
|
|
||||||
$webBuildDir = Join-Path $repoRoot "Journal.App\build"
|
|
||||||
if (Test-Path $webBuildDir) {
|
|
||||||
$webOutputDir = Join-Path $outputDir "webgateway\wwwroot"
|
|
||||||
New-Item -ItemType Directory -Force -Path $webOutputDir | Out-Null
|
|
||||||
Copy-Item -Path (Join-Path $webBuildDir "*") -Destination $webOutputDir -Recurse -Force
|
|
||||||
Write-Host "Synced web assets -> $webOutputDir" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Sidecar
|
|
||||||
$sidecarExeName = if ([System.Environment]::OSVersion.Platform -eq "Win32NT") { "Journal.Sidecar.exe" } else { "Journal.Sidecar" }
|
|
||||||
$sidecarExe = Find-NewestBin -SearchPath (Join-Path $repoRoot "Journal.Sidecar\bin") -Pattern $sidecarExeName
|
|
||||||
if ($sidecarExe) {
|
|
||||||
Copy-Item -Path (Join-Path $sidecarExe.DirectoryName "*") -Destination $outputDir -Recurse -Force
|
|
||||||
Write-Host "Synced Journal.Sidecar -> $outputDir" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. WebGateway
|
|
||||||
$gwExeName = if ([System.Environment]::OSVersion.Platform -eq "Win32NT") { "Journal.WebGateway.exe" } else { "Journal.WebGateway" }
|
|
||||||
$gwExe = Find-NewestBin -SearchPath (Join-Path $repoRoot "Journal.WebGateway\bin") -Pattern $gwExeName
|
|
||||||
if ($gwExe) {
|
|
||||||
$gwOutputDir = Join-Path $outputDir "webgateway"
|
|
||||||
New-Item -ItemType Directory -Force -Path $gwOutputDir | Out-Null
|
|
||||||
Copy-Item -Path (Join-Path $gwExe.DirectoryName "*") -Destination $gwOutputDir -Recurse -Force
|
|
||||||
Write-Host "Synced Journal.WebGateway -> $gwOutputDir" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4. Tauri Desktop App
|
|
||||||
$tauriExe = Find-NewestBin -SearchPath (Join-Path $repoRoot "Journal.App\src-tauri\target") -Pattern "*.exe"
|
|
||||||
if ($tauriExe) {
|
|
||||||
# Don't try to copy sidecar.exe again if it ended up in tauri target dir
|
|
||||||
if ($tauriExe.Name -ne "Journal.Sidecar.exe") {
|
|
||||||
Copy-Item -Path $tauriExe.FullName -Destination $outputDir -Force
|
|
||||||
Write-Host "Synced Tauri App ($($tauriExe.Name)) -> $outputDir" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Sync complete!" -ForegroundColor Cyan
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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:]))
|
|
||||||
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,315 +0,0 @@
|
|||||||
#!/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]
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
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 { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user