feat: Introduce Journal.DevTool project with core services, scripts, and tests for development and workflow management.
This commit is contained in:
parent
ee96c05d15
commit
96b9b6d797
14
.gitignore
vendored
14
.gitignore
vendored
@ -50,4 +50,16 @@ logs/
|
||||
journalapp.exe
|
||||
Journal.App/node_modules.old/@rollup/.rollup-win32-x64-msvc-IjiZshxL/rollup.win32-x64-msvc.node
|
||||
journalapp(1).exe
|
||||
.cache/
|
||||
.cache/
|
||||
Journal.DevTool/node_modules/
|
||||
scripts/__pycache__/
|
||||
.sdt/
|
||||
devtool.backup.json
|
||||
sdt.deps.json
|
||||
sdt.dll
|
||||
sdt.exe
|
||||
sdt.pdb
|
||||
sdt.runtimeconfig.json
|
||||
Spectre.Console.dll
|
||||
Journal.DevTool/devtool.generated.workflows.json
|
||||
Journal.DevTool/sdt-workspace.json
|
||||
|
||||
20
Journal.App/package-lock.json
generated
20
Journal.App/package-lock.json
generated
@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"tauri-plugin-mic-recorder-api": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
@ -908,6 +909,7 @@
|
||||
"integrity": "sha512-NXsZLvalgI3HrHG6ogoEVzjyV7bSFQNqQeekfU7nNufQFrRyV3EBDfQKEwxx50peu7spZR42JuC1PFhwxuvBrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
@ -950,6 +952,7 @@
|
||||
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
||||
"debug": "^4.4.1",
|
||||
@ -1256,6 +1259,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -1542,6 +1546,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -1584,6 +1589,7 @@
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@ -1715,6 +1721,7 @@
|
||||
"integrity": "sha512-pRUBr6j6uQDgBi208gHnGRMykw0Rf2Yr1HmLyRucsvcaYgIUxswJkT93WZJflsmezu5s8Lq+q78EoyLv2yaFCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@ -1761,6 +1768,15 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tauri-plugin-mic-recorder-api": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tauri-plugin-mic-recorder-api/-/tauri-plugin-mic-recorder-api-2.0.0.tgz",
|
||||
"integrity": "sha512-04wqYCX4WIlYd6KUY7aS3+W4B5RtnSoVczaQCBSXKpQkEx9XdaaBN05XCee2unxGva0btSXBItFqQSdosnS4jQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@ -1794,6 +1810,7 @@
|
||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -1808,6 +1825,7 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
15
Journal.DevTool/.gitignore
vendored
Normal file
15
Journal.DevTool/.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
bin/
|
||||
obj/
|
||||
__pycache__/
|
||||
.cache/
|
||||
.vscode/
|
||||
.idea/
|
||||
.vs/
|
||||
.git/
|
||||
.pip
|
||||
.tmp
|
||||
.venv
|
||||
.dotnet_home
|
||||
.nuget
|
||||
publish-test/
|
||||
|
||||
626
Journal.DevTool/Config/ConfigBootstrapper.cs
Normal file
626
Journal.DevTool/Config/ConfigBootstrapper.cs
Normal file
@ -0,0 +1,626 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Sdt.Config;
|
||||
|
||||
public sealed record BootstrapScanResult(
|
||||
string ProjectRoot,
|
||||
string ProjectName,
|
||||
IReadOnlyList<string> ToolFamilies,
|
||||
string? NodeWorkingDir,
|
||||
string? PythonRequirementsFile,
|
||||
bool HasDockerCompose,
|
||||
IReadOnlyList<string> RootHints);
|
||||
|
||||
public static class ConfigBootstrapper
|
||||
{
|
||||
private const int MaxScanDepth = 4;
|
||||
|
||||
private static readonly HashSet<string> ExcludedDirectories = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".git",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"bin",
|
||||
"obj",
|
||||
".idea",
|
||||
".vscode",
|
||||
"dist",
|
||||
"build",
|
||||
".sdt",
|
||||
};
|
||||
|
||||
private static readonly string[] RequirementCandidates =
|
||||
[
|
||||
"requirements.txt",
|
||||
"requirements-dev.txt",
|
||||
"requirements_cpu_only.txt",
|
||||
"requirements_gpu.txt",
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static BootstrapScanResult Scan(string startDir)
|
||||
{
|
||||
var root = FindProjectRoot(startDir);
|
||||
var toolFamilies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var rootHints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (Directory.Exists(Path.Combine(root, ".git")))
|
||||
{
|
||||
toolFamilies.Add("git");
|
||||
rootHints.Add(".git");
|
||||
}
|
||||
|
||||
var hasTopLevelSln = Directory.EnumerateFiles(root, "*.sln", SearchOption.TopDirectoryOnly).Any();
|
||||
var hasCsproj = hasTopLevelSln || EnumerateFilesBounded(root, "*.csproj", MaxScanDepth).Any();
|
||||
if (hasCsproj)
|
||||
{
|
||||
toolFamilies.Add("dotnet");
|
||||
rootHints.Add("*.sln");
|
||||
}
|
||||
|
||||
var topLevelPackageJson = Path.Combine(root, "package.json");
|
||||
var packageJson = File.Exists(topLevelPackageJson)
|
||||
? topLevelPackageJson
|
||||
: EnumerateFilesBounded(root, "package.json", MaxScanDepth)
|
||||
.OrderBy(p => p.Length)
|
||||
.FirstOrDefault();
|
||||
string? nodeWorkingDir = null;
|
||||
if (packageJson is not null)
|
||||
{
|
||||
toolFamilies.Add("node");
|
||||
toolFamilies.Add("npm");
|
||||
nodeWorkingDir = Path.GetRelativePath(root, Path.GetDirectoryName(packageJson)!);
|
||||
rootHints.Add("package.json");
|
||||
}
|
||||
|
||||
string? requirements = RequirementCandidates
|
||||
.Select(name => Path.Combine(root, name))
|
||||
.FirstOrDefault(File.Exists);
|
||||
if (requirements is null)
|
||||
{
|
||||
var pyproject = Path.Combine(root, "pyproject.toml");
|
||||
if (File.Exists(pyproject))
|
||||
{
|
||||
toolFamilies.Add("python");
|
||||
rootHints.Add("pyproject.toml");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
toolFamilies.Add("python");
|
||||
rootHints.Add(Path.GetFileName(requirements)!);
|
||||
}
|
||||
|
||||
var hasCargo = File.Exists(Path.Combine(root, "Cargo.toml")) ||
|
||||
EnumerateFilesBounded(root, "Cargo.toml", MaxScanDepth).Any();
|
||||
if (hasCargo)
|
||||
{
|
||||
toolFamilies.Add("cargo");
|
||||
rootHints.Add("Cargo.toml");
|
||||
}
|
||||
|
||||
var hasTauri = File.Exists(Path.Combine(root, "tauri.conf.json")) ||
|
||||
EnumerateFilesBounded(root, "tauri.conf.json", MaxScanDepth).Any();
|
||||
if (hasTauri)
|
||||
{
|
||||
toolFamilies.Add("tauri");
|
||||
toolFamilies.Add("cargo");
|
||||
toolFamilies.Add("node");
|
||||
toolFamilies.Add("npm");
|
||||
rootHints.Add("tauri.conf.json");
|
||||
}
|
||||
|
||||
var hasDockerCompose = File.Exists(Path.Combine(root, "docker-compose.yml")) ||
|
||||
File.Exists(Path.Combine(root, "docker-compose.yaml"));
|
||||
if (hasDockerCompose || File.Exists(Path.Combine(root, "Dockerfile")))
|
||||
{
|
||||
toolFamilies.Add("docker");
|
||||
rootHints.Add(hasDockerCompose ? "docker-compose.yml" : "Dockerfile");
|
||||
}
|
||||
|
||||
var scriptsDir = Path.Combine(root, "scripts");
|
||||
if (Directory.Exists(scriptsDir) &&
|
||||
Directory.EnumerateFiles(scriptsDir, "*.py", SearchOption.TopDirectoryOnly).Any())
|
||||
{
|
||||
toolFamilies.Add("python");
|
||||
rootHints.Add("scripts");
|
||||
}
|
||||
|
||||
if (rootHints.Count == 0)
|
||||
rootHints.Add("devtool.json");
|
||||
|
||||
return new BootstrapScanResult(
|
||||
ProjectRoot: root,
|
||||
ProjectName: new DirectoryInfo(root).Name,
|
||||
ToolFamilies: toolFamilies.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
NodeWorkingDir: nodeWorkingDir,
|
||||
PythonRequirementsFile: requirements is null ? null : Path.GetRelativePath(root, requirements),
|
||||
HasDockerCompose: hasDockerCompose,
|
||||
RootHints: rootHints.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList());
|
||||
}
|
||||
|
||||
public static DevToolConfig BuildDefaultConfig(BootstrapScanResult scan)
|
||||
{
|
||||
var workflows = BuildWorkflows(scan).ToList();
|
||||
|
||||
var toolingTools = scan.ToolFamilies
|
||||
.Select(t => new ToolInstallDefinition { Tool = t, PreferredInstallCommands = [] })
|
||||
.ToList();
|
||||
|
||||
var toolchains = new ToolchainConfig
|
||||
{
|
||||
Python = scan.ToolFamilies.Contains("python", StringComparer.OrdinalIgnoreCase)
|
||||
? new PythonToolchain
|
||||
{
|
||||
Executable = "python",
|
||||
WindowsExecutable = "py",
|
||||
VenvDir = ".venv",
|
||||
Profiles = []
|
||||
}
|
||||
: null,
|
||||
Node = scan.ToolFamilies.Contains("node", StringComparer.OrdinalIgnoreCase)
|
||||
? new NodeToolchain
|
||||
{
|
||||
PackageManager = "npm",
|
||||
WorkingDir = string.IsNullOrWhiteSpace(scan.NodeWorkingDir) ? "." : scan.NodeWorkingDir
|
||||
}
|
||||
: null
|
||||
};
|
||||
|
||||
var debugProfiles = BuildDebugProfiles(scan).ToList();
|
||||
|
||||
return new DevToolConfig
|
||||
{
|
||||
Name = scan.ProjectName,
|
||||
Version = "0.1.0",
|
||||
Project = new ProjectMetadata
|
||||
{
|
||||
Type = "generic",
|
||||
RootHints = scan.RootHints.ToList(),
|
||||
Artifacts = ["bin", "obj", ".sdt/debug"]
|
||||
},
|
||||
Toolchains = toolchains,
|
||||
Tooling = new ToolingConfig { Tools = toolingTools },
|
||||
Workflows = workflows,
|
||||
Debug = new DebugConfig
|
||||
{
|
||||
Profiles = debugProfiles,
|
||||
Diagnostics = new DebugDiagnosticsOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OutputDir = ".sdt/debug",
|
||||
IncludeAllEnv = false,
|
||||
CaptureEnvKeys =
|
||||
[
|
||||
"SDT_LOG_LEVEL",
|
||||
"DOTNET_CLI_HOME",
|
||||
"NUGET_PACKAGES",
|
||||
"PIP_CACHE_DIR",
|
||||
"NVM_HOME",
|
||||
"NVM_SYMLINK",
|
||||
],
|
||||
BundleOnFailure = true
|
||||
}
|
||||
},
|
||||
Env =
|
||||
[
|
||||
new EnvVarDef
|
||||
{
|
||||
Key = "SDT_LOG_LEVEL",
|
||||
Description = "CLI log verbosity",
|
||||
DefaultValue = "information",
|
||||
Options = ["trace", "debug", "information", "warning", "error", "critical"]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToJson(DevToolConfig config) => JsonSerializer.Serialize(config, JsonOptions) + Environment.NewLine;
|
||||
|
||||
public static string WriteDefaultConfig(string projectRoot, DevToolConfig config, bool overwrite = false)
|
||||
{
|
||||
var path = Path.Combine(projectRoot, "devtool.json");
|
||||
if (File.Exists(path) && !overwrite)
|
||||
throw new InvalidOperationException($"devtool.json already exists at {path}");
|
||||
|
||||
File.WriteAllText(path, ToJson(config));
|
||||
return path;
|
||||
}
|
||||
|
||||
private static IEnumerable<WorkflowDefinition> BuildWorkflows(BootstrapScanResult scan)
|
||||
{
|
||||
var has = new Func<string, bool>(tool => scan.ToolFamilies.Contains(tool, StringComparer.OrdinalIgnoreCase));
|
||||
var scripts = DetectScriptHelpers(scan.ProjectRoot);
|
||||
|
||||
if (scripts.Contains("publish-sidecar.py") ||
|
||||
scripts.Contains("publish-app.py") ||
|
||||
scripts.Contains("publish-webgateway.py") ||
|
||||
scripts.Contains("publish-output.py") ||
|
||||
scripts.Contains("sync-output.py") ||
|
||||
scripts.Contains("run-webgateway.py"))
|
||||
{
|
||||
foreach (var workflow in BuildScriptDrivenWorkflows(scripts))
|
||||
yield return workflow;
|
||||
}
|
||||
|
||||
var buildSteps = new List<WorkflowStep>();
|
||||
if (has("dotnet")) buildSteps.Add(StepAction("dotnet-build", "dotnet build", "dotnet-build"));
|
||||
if (has("npm")) buildSteps.Add(StepAction("npm-build", "npm run build", "npm-build", scan.NodeWorkingDir));
|
||||
if (has("cargo")) buildSteps.Add(StepAction("cargo-build", "cargo build", "cargo-build"));
|
||||
if (has("tauri")) buildSteps.Add(StepAction("tauri-build", "tauri build", "tauri-build", scan.NodeWorkingDir ?? "."));
|
||||
if (buildSteps.Count > 0)
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "build",
|
||||
Label = "Build",
|
||||
Description = "Build detected project stacks",
|
||||
Group = "Build",
|
||||
Steps = buildSteps
|
||||
};
|
||||
}
|
||||
|
||||
var depsSteps = new List<WorkflowStep>();
|
||||
if (has("dotnet")) depsSteps.Add(StepAction("dotnet-restore", "dotnet restore", "dotnet-restore"));
|
||||
if (has("npm")) depsSteps.Add(StepAction("npm-ci", "npm ci", "npm-ci", scan.NodeWorkingDir));
|
||||
if (has("python") && !string.IsNullOrWhiteSpace(scan.PythonRequirementsFile))
|
||||
{
|
||||
depsSteps.Add(StepAction("python-pip-sync", "python pip sync", "python-pip-sync", ".", ["--requirements", scan.PythonRequirementsFile!]));
|
||||
}
|
||||
if (depsSteps.Count > 0)
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "deps-refresh",
|
||||
Label = "Refresh Dependencies",
|
||||
Description = "Restore/install dependency stacks",
|
||||
Group = "Deps",
|
||||
Steps = depsSteps
|
||||
};
|
||||
}
|
||||
|
||||
var testSteps = new List<WorkflowStep>();
|
||||
if (has("dotnet")) testSteps.Add(StepAction("dotnet-test", "dotnet test", "dotnet-test"));
|
||||
if (has("npm")) testSteps.Add(StepAction("npm-test", "npm test", "npm-test", scan.NodeWorkingDir));
|
||||
if (has("python")) testSteps.Add(StepAction("python-pytest", "python -m pytest", "python-pytest"));
|
||||
if (has("cargo")) testSteps.Add(StepAction("cargo-test", "cargo test", "cargo-test"));
|
||||
if (testSteps.Count > 0)
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "test",
|
||||
Label = "Run Tests",
|
||||
Description = "Run detected test stacks",
|
||||
Group = "Test",
|
||||
Steps = testSteps
|
||||
};
|
||||
}
|
||||
|
||||
if (has("git"))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "repo-health",
|
||||
Label = "Repo Health",
|
||||
Description = "Check repo status and fetch remotes",
|
||||
Group = "Repo",
|
||||
Steps =
|
||||
[
|
||||
StepAction("git-status", "git status", "git-status"),
|
||||
StepAction("git-fetch", "git fetch", "git-fetch")
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (has("docker"))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "containers",
|
||||
Label = "Containers",
|
||||
Description = scan.HasDockerCompose ? "Manage docker compose stack" : "Build docker image",
|
||||
Group = "Containers",
|
||||
Steps = scan.HasDockerCompose
|
||||
? [StepAction("docker-compose-up", "docker compose up -d", "docker-compose-up")]
|
||||
: [StepAction("docker-build", "docker build .", "docker-build")]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<DebugProfileDefinition> BuildDebugProfiles(BootstrapScanResult scan)
|
||||
{
|
||||
var has = new Func<string, bool>(tool => scan.ToolFamilies.Contains(tool, StringComparer.OrdinalIgnoreCase));
|
||||
if (has("dotnet"))
|
||||
{
|
||||
yield return new DebugProfileDefinition
|
||||
{
|
||||
Id = "dotnet-run",
|
||||
Label = "Run .NET app",
|
||||
Type = "dotnet",
|
||||
Command = "dotnet",
|
||||
Args = ["run"],
|
||||
WorkingDir = ".",
|
||||
Requires = [new ToolRequirement { Tool = "dotnet", InstallPolicy = InstallPolicy.Prompt }],
|
||||
Attach = new DebugAttachConfig
|
||||
{
|
||||
Kind = "manual",
|
||||
Note = "Attach your IDE debugger to the running dotnet process."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (has("npm"))
|
||||
{
|
||||
yield return new DebugProfileDefinition
|
||||
{
|
||||
Id = "npm-dev",
|
||||
Label = "Run npm dev server",
|
||||
Type = "node",
|
||||
Command = "npm",
|
||||
Args = ["run", "dev"],
|
||||
WorkingDir = string.IsNullOrWhiteSpace(scan.NodeWorkingDir) ? "." : scan.NodeWorkingDir!,
|
||||
Requires =
|
||||
[
|
||||
new ToolRequirement { Tool = "node", InstallPolicy = InstallPolicy.Prompt },
|
||||
new ToolRequirement { Tool = "npm", InstallPolicy = InstallPolicy.Prompt }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkflowStep StepAction(
|
||||
string id,
|
||||
string label,
|
||||
string action,
|
||||
string? workingDir = null,
|
||||
IReadOnlyList<string>? actionArgs = null)
|
||||
{
|
||||
return new WorkflowStep
|
||||
{
|
||||
Id = id,
|
||||
Label = label,
|
||||
Action = action,
|
||||
ActionArgs = actionArgs?.ToList() ?? [],
|
||||
WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? "." : workingDir
|
||||
};
|
||||
}
|
||||
|
||||
private static HashSet<string> DetectScriptHelpers(string projectRoot)
|
||||
{
|
||||
var scriptsDir = Path.Combine(projectRoot, "scripts");
|
||||
if (!Directory.Exists(scriptsDir))
|
||||
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return Directory.EnumerateFiles(scriptsDir, "*.py", SearchOption.TopDirectoryOnly)
|
||||
.Select(Path.GetFileName)
|
||||
.Where(f => !string.IsNullOrWhiteSpace(f))
|
||||
.Cast<string>()
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<WorkflowDefinition> BuildScriptDrivenWorkflows(HashSet<string> scripts)
|
||||
{
|
||||
static WorkflowStep ScriptStep(string id, string label, params string[] scriptArgs) => new()
|
||||
{
|
||||
Id = id,
|
||||
Label = label,
|
||||
Command = "python",
|
||||
Args = scriptArgs.ToList(),
|
||||
WorkingDir = ".",
|
||||
Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }]
|
||||
};
|
||||
|
||||
if (scripts.Contains("publish-sidecar.py"))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "sidecar",
|
||||
Label = "Publish Sidecar",
|
||||
Description = "Publish sidecar service",
|
||||
Group = "Build",
|
||||
Steps = [ScriptStep("sidecar:run", "python scripts/publish-sidecar.py", "scripts/publish-sidecar.py")]
|
||||
};
|
||||
}
|
||||
|
||||
if (scripts.Contains("publish-app.py"))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "web",
|
||||
Label = "Build Web UI",
|
||||
Description = "Build frontend assets",
|
||||
Group = "Build",
|
||||
Steps =
|
||||
[
|
||||
ScriptStep("web:run", "python scripts/publish-app.py --target web", "scripts/publish-app.py", "--target", "web")
|
||||
]
|
||||
};
|
||||
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "tauri",
|
||||
Label = "Build Tauri Desktop App",
|
||||
Description = "Build desktop binary",
|
||||
Group = "Build",
|
||||
DependsOn = scripts.Contains("publish-sidecar.py") ? ["sidecar"] : [],
|
||||
Steps =
|
||||
[
|
||||
ScriptStep("tauri:run", "python scripts/publish-app.py --target tauri --tauri-bundles none",
|
||||
"scripts/publish-app.py", "--target", "tauri", "--tauri-bundles", "none")
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (scripts.Contains("publish-webgateway.py"))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "webgateway",
|
||||
Label = "Publish WebGateway",
|
||||
Description = "Publish ASP.NET gateway",
|
||||
Group = "Build",
|
||||
DependsOn = scripts.Contains("publish-app.py") ? ["web"] : [],
|
||||
Steps = [ScriptStep("webgateway:run", "python scripts/publish-webgateway.py", "scripts/publish-webgateway.py")]
|
||||
};
|
||||
}
|
||||
|
||||
if (scripts.Contains("sync-output.py"))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "sync-output",
|
||||
Label = "Sync Output",
|
||||
Description = "Sync newest artifacts to output",
|
||||
Group = "Build",
|
||||
Steps = [ScriptStep("sync-output:run", "python scripts/sync-output.py", "scripts/sync-output.py")]
|
||||
};
|
||||
}
|
||||
|
||||
if (scripts.Contains("publish-output.py"))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "stage-output",
|
||||
Label = "Stage Output Bundle",
|
||||
Description = "Publish and stage distributable output",
|
||||
Group = "Build",
|
||||
Steps = [ScriptStep("stage-output:run", "python scripts/publish-output.py", "scripts/publish-output.py")]
|
||||
};
|
||||
}
|
||||
|
||||
if (scripts.Contains("run-webgateway.py"))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
{
|
||||
Id = "run-gateway-dev",
|
||||
Label = "Run WebGateway Server (Dev)",
|
||||
Description = "Run gateway in development mode",
|
||||
Group = "Dev",
|
||||
Steps =
|
||||
[
|
||||
ScriptStep("run-gateway-dev:run", "python scripts/run-webgateway.py --mode Dev",
|
||||
"scripts/run-webgateway.py", "--mode", "Dev")
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindProjectRoot(string startDir)
|
||||
{
|
||||
var start = Path.GetFullPath(startDir);
|
||||
var gitRoot = TryGetGitRoot(start);
|
||||
if (!string.IsNullOrWhiteSpace(gitRoot))
|
||||
return gitRoot!;
|
||||
|
||||
var best = start;
|
||||
var bestScore = ScoreRoot(start);
|
||||
var cursor = new DirectoryInfo(start);
|
||||
while (cursor.Parent is not null)
|
||||
{
|
||||
cursor = cursor.Parent;
|
||||
var score = ScoreRoot(cursor.FullName);
|
||||
if (score > bestScore)
|
||||
{
|
||||
best = cursor.FullName;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static int ScoreRoot(string path)
|
||||
{
|
||||
var score = 0;
|
||||
if (File.Exists(Path.Combine(path, "package.json"))) score += 2;
|
||||
if (File.Exists(Path.Combine(path, "pyproject.toml"))) score += 2;
|
||||
if (File.Exists(Path.Combine(path, "Cargo.toml"))) score += 2;
|
||||
if (File.Exists(Path.Combine(path, "docker-compose.yml")) ||
|
||||
File.Exists(Path.Combine(path, "docker-compose.yaml")) ||
|
||||
File.Exists(Path.Combine(path, "Dockerfile"))) score += 1;
|
||||
if (Directory.EnumerateFiles(path, "*.sln", SearchOption.TopDirectoryOnly).Any()) score += 3;
|
||||
if (Directory.Exists(Path.Combine(path, ".git"))) score += 1;
|
||||
return score;
|
||||
}
|
||||
|
||||
private static string? TryGetGitRoot(string start)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "git",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = start
|
||||
};
|
||||
psi.ArgumentList.Add("rev-parse");
|
||||
psi.ArgumentList.Add("--show-toplevel");
|
||||
using var process = System.Diagnostics.Process.Start(psi);
|
||||
if (process is null)
|
||||
return null;
|
||||
var stdout = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(2000);
|
||||
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
|
||||
return stdout.Trim();
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateFilesBounded(string root, string pattern, int maxDepth)
|
||||
{
|
||||
var queue = new Queue<(string Dir, int Depth)>();
|
||||
queue.Enqueue((root, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (dir, depth) = queue.Dequeue();
|
||||
IEnumerable<string> files = [];
|
||||
try
|
||||
{
|
||||
files = Directory.EnumerateFiles(dir, pattern, SearchOption.TopDirectoryOnly);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unreadable directories.
|
||||
}
|
||||
|
||||
foreach (var file in files)
|
||||
yield return file;
|
||||
|
||||
if (depth >= maxDepth)
|
||||
continue;
|
||||
|
||||
IEnumerable<string> subdirs = [];
|
||||
try
|
||||
{
|
||||
subdirs = Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unreadable directories.
|
||||
}
|
||||
|
||||
foreach (var subdir in subdirs)
|
||||
{
|
||||
var name = Path.GetFileName(subdir);
|
||||
if (ExcludedDirectories.Contains(name))
|
||||
continue;
|
||||
|
||||
queue.Enqueue((subdir, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Sdt.Core;
|
||||
|
||||
namespace Sdt.Config;
|
||||
|
||||
public sealed record LoadedProjectConfig(
|
||||
DevToolConfig Config,
|
||||
string ProjectRoot,
|
||||
IReadOnlyList<string> Warnings);
|
||||
|
||||
public sealed record LegacyMigrationApplyResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? BackupPath = null,
|
||||
string? ConfigPath = null);
|
||||
|
||||
public static class ConfigLoader
|
||||
{
|
||||
public const string WorkspaceDefaultsFileName = "sdt-defaults.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@ -16,29 +31,193 @@ public static class ConfigLoader
|
||||
/// Walks up from <paramref name="startDir"/> (or CWD) until it finds devtool.json.
|
||||
/// Returns null if not found.
|
||||
/// </summary>
|
||||
public static (DevToolConfig Config, string ProjectRoot)? FindAndLoad(string? startDir = null)
|
||||
public static string? FindConfigPath(string? startDir = null)
|
||||
{
|
||||
var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory());
|
||||
while (dir is not null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "devtool.json");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(candidate);
|
||||
var config = JsonSerializer.Deserialize<DevToolConfig>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("devtool.json deserialized to null.");
|
||||
return (config, dir.FullName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to parse devtool.json at {candidate}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
return candidate;
|
||||
dir = dir.Parent!;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static LoadedProjectConfig? FindAndLoad(string? startDir = null)
|
||||
{
|
||||
var configPath = FindConfigPath(startDir);
|
||||
if (configPath is null)
|
||||
return null;
|
||||
|
||||
var projectRoot = Path.GetDirectoryName(configPath)
|
||||
?? throw new InvalidOperationException($"Could not resolve project root from {configPath}");
|
||||
|
||||
try
|
||||
{
|
||||
var effectiveConfig = LoadEffectiveConfig(projectRoot, configPath, out var defaultsPath);
|
||||
var warnings = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(defaultsPath))
|
||||
warnings.Add($"Applied workspace defaults from {defaultsPath}.");
|
||||
|
||||
var legacyMode = ResolveLegacyMode();
|
||||
if (legacyMode == LegacyMode.Strict && effectiveConfig.Workflows.Count == 0 && effectiveConfig.Targets.Count > 0)
|
||||
{
|
||||
var previewPath = Path.Combine(projectRoot, "devtool.generated.workflows.json");
|
||||
try
|
||||
{
|
||||
var previewConfig = WorkflowModelBuilder.BuildMigrationPreviewConfig(effectiveConfig, new RequirementResolver());
|
||||
File.WriteAllText(previewPath, ConfigBootstrapper.ToJson(previewConfig));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep strict failure even if preview generation fails.
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Legacy targets-only config detected at {configPath}. Strict mode requires workflows. " +
|
||||
"Use migration preview file 'devtool.generated.workflows.json' and migrate devtool.json. " +
|
||||
"Temporary rollback: set SDT_LEGACY_MODE=compat.");
|
||||
}
|
||||
|
||||
var normalized = WorkflowModelBuilder.Normalize(effectiveConfig, legacyMode, new RequirementResolver());
|
||||
warnings.AddRange(normalized.Warnings);
|
||||
return new LoadedProjectConfig(effectiveConfig, projectRoot, warnings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to parse devtool.json at {configPath}: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static LegacyMigrationApplyResult ApplyLegacyTargetMigration(
|
||||
string configPath,
|
||||
bool createBackup = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(configPath))
|
||||
return new LegacyMigrationApplyResult(false, $"Config file not found: {configPath}");
|
||||
|
||||
var json = File.ReadAllText(configPath);
|
||||
var config = JsonSerializer.Deserialize<DevToolConfig>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("devtool.json deserialized to null.");
|
||||
|
||||
if (config.Targets.Count == 0)
|
||||
return new LegacyMigrationApplyResult(false, "No legacy targets found to migrate.", ConfigPath: configPath);
|
||||
|
||||
var migrated = WorkflowModelBuilder.BuildMigrationPreviewConfig(config, new RequirementResolver());
|
||||
var backupPath = (string?)null;
|
||||
if (createBackup)
|
||||
{
|
||||
backupPath = configPath + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}";
|
||||
File.Copy(configPath, backupPath, overwrite: false);
|
||||
}
|
||||
|
||||
File.WriteAllText(configPath, ConfigBootstrapper.ToJson(migrated));
|
||||
return new LegacyMigrationApplyResult(
|
||||
true,
|
||||
"Legacy targets migrated to workflows.",
|
||||
BackupPath: backupPath,
|
||||
ConfigPath: configPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LegacyMigrationApplyResult(false, ex.Message, ConfigPath: configPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static LegacyMode ResolveLegacyMode()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
|
||||
return string.Equals(raw, "compat", StringComparison.OrdinalIgnoreCase)
|
||||
? LegacyMode.Compat
|
||||
: LegacyMode.Strict;
|
||||
}
|
||||
|
||||
private static DevToolConfig LoadEffectiveConfig(
|
||||
string projectRoot,
|
||||
string projectConfigPath,
|
||||
out string? defaultsPath)
|
||||
{
|
||||
defaultsPath = FindWorkspaceDefaultsPath(projectRoot);
|
||||
var projectObj = LoadJsonObject(projectConfigPath, "project config");
|
||||
if (string.IsNullOrWhiteSpace(defaultsPath))
|
||||
return DeserializeConfig(projectObj, projectConfigPath);
|
||||
|
||||
var defaultsObj = LoadJsonObject(defaultsPath!, "workspace defaults");
|
||||
var merged = MergeObjects(defaultsObj, projectObj);
|
||||
return DeserializeConfig(merged, projectConfigPath);
|
||||
}
|
||||
|
||||
private static string? FindWorkspaceDefaultsPath(string startDir)
|
||||
{
|
||||
var workspaceBoundary = FindWorkspaceBoundary(startDir);
|
||||
var dir = new DirectoryInfo(startDir);
|
||||
while (dir is not null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, WorkspaceDefaultsFileName);
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
if (workspaceBoundary is not null &&
|
||||
string.Equals(dir.FullName, workspaceBoundary, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (workspaceBoundary is null)
|
||||
break;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FindWorkspaceBoundary(string startDir)
|
||||
{
|
||||
var dir = new DirectoryInfo(startDir);
|
||||
while (dir is not null)
|
||||
{
|
||||
var workspacePath = Path.Combine(dir.FullName, WorkspaceLoader.FileName);
|
||||
if (File.Exists(workspacePath))
|
||||
return dir.FullName;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonObject LoadJsonObject(string path, string label)
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var node = JsonNode.Parse(json)
|
||||
?? throw new InvalidOperationException($"{label} at {path} deserialized to null.");
|
||||
if (node is not JsonObject obj)
|
||||
throw new InvalidOperationException($"{label} at {path} must be a JSON object.");
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static DevToolConfig DeserializeConfig(JsonObject obj, string sourcePath)
|
||||
{
|
||||
return obj.Deserialize<DevToolConfig>(JsonOptions)
|
||||
?? throw new InvalidOperationException($"devtool.json at {sourcePath} deserialized to null.");
|
||||
}
|
||||
|
||||
private static JsonObject MergeObjects(JsonObject baseObj, JsonObject overlayObj)
|
||||
{
|
||||
var result = (JsonObject)baseObj.DeepClone();
|
||||
foreach (var kv in overlayObj)
|
||||
{
|
||||
if (kv.Value is JsonObject overlayChild &&
|
||||
result[kv.Key] is JsonObject baseChild)
|
||||
{
|
||||
result[kv.Key] = MergeObjects(baseChild, overlayChild);
|
||||
continue;
|
||||
}
|
||||
|
||||
result[kv.Key] = kv.Value?.DeepClone();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Sdt.Config;
|
||||
|
||||
public sealed class DevToolConfig
|
||||
@ -5,8 +7,12 @@ public sealed class DevToolConfig
|
||||
public string Name { get; init; } = "SDT Project";
|
||||
public string Version { get; init; } = "0.1.0";
|
||||
public List<BuildTarget> Targets { get; init; } = [];
|
||||
public List<WorkflowDefinition> Workflows { get; init; } = [];
|
||||
public List<EnvVarDef> Env { get; init; } = [];
|
||||
public ToolchainConfig? Toolchains { get; init; }
|
||||
public ToolingConfig? Tooling { get; init; }
|
||||
public ProjectMetadata? Project { get; init; }
|
||||
public DebugConfig? Debug { get; init; }
|
||||
}
|
||||
|
||||
public sealed class BuildTarget
|
||||
@ -39,6 +45,99 @@ public sealed class EnvVarDef
|
||||
public List<string> Options { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class WorkflowDefinition
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Label { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
public string Group { get; init; } = "General";
|
||||
public List<string> DependsOn { get; init; } = [];
|
||||
public List<WorkflowStep> Steps { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class WorkflowStep
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Label { get; init; } = "";
|
||||
public string? Command { get; init; }
|
||||
public List<string> Args { get; init; } = [];
|
||||
public string WorkingDir { get; init; } = ".";
|
||||
public string? Action { get; init; }
|
||||
public List<string> ActionArgs { get; init; } = [];
|
||||
public List<ToolRequirement> Requires { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ToolRequirement
|
||||
{
|
||||
public string Tool { get; init; } = "";
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public InstallPolicy InstallPolicy { get; init; } = InstallPolicy.Prompt;
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum InstallPolicy
|
||||
{
|
||||
Prompt,
|
||||
Auto,
|
||||
Never,
|
||||
}
|
||||
|
||||
public sealed class ToolingConfig
|
||||
{
|
||||
public List<ToolInstallDefinition> Tools { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ToolInstallDefinition
|
||||
{
|
||||
public string Tool { get; init; } = "";
|
||||
public List<string> PreferredInstallCommands { get; init; } = [];
|
||||
public List<string> Executables { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ProjectMetadata
|
||||
{
|
||||
public string Type { get; init; } = "";
|
||||
public List<string> RootHints { get; init; } = [];
|
||||
public List<string> Artifacts { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class DebugConfig
|
||||
{
|
||||
public List<DebugProfileDefinition> Profiles { get; init; } = [];
|
||||
public DebugDiagnosticsOptions Diagnostics { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class DebugProfileDefinition
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Label { get; init; } = "";
|
||||
public string Type { get; init; } = "generic";
|
||||
public string Command { get; init; } = "";
|
||||
public List<string> Args { get; init; } = [];
|
||||
public string WorkingDir { get; init; } = ".";
|
||||
public Dictionary<string, string> Env { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<ToolRequirement> Requires { get; init; } = [];
|
||||
public DebugAttachConfig? Attach { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DebugAttachConfig
|
||||
{
|
||||
public string Kind { get; init; } = "";
|
||||
public int? Port { get; init; }
|
||||
public string? ProcessName { get; init; }
|
||||
public string? Note { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DebugDiagnosticsOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public string OutputDir { get; init; } = ".sdt/debug";
|
||||
public bool IncludeAllEnv { get; init; } = false;
|
||||
public List<string> CaptureEnvKeys { get; init; } = [];
|
||||
public bool BundleOnFailure { get; init; } = true;
|
||||
}
|
||||
|
||||
// ── Toolchain config ──────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class ToolchainConfig
|
||||
|
||||
100
Journal.DevTool/Config/WorkflowModelBuilder.cs
Normal file
100
Journal.DevTool/Config/WorkflowModelBuilder.cs
Normal file
@ -0,0 +1,100 @@
|
||||
using Sdt.Core;
|
||||
|
||||
namespace Sdt.Config;
|
||||
|
||||
public enum LegacyMode
|
||||
{
|
||||
Strict,
|
||||
Compat,
|
||||
}
|
||||
|
||||
public sealed record WorkflowNormalizationResult(
|
||||
IReadOnlyList<WorkflowDefinition> Workflows,
|
||||
IReadOnlyList<string> Warnings);
|
||||
|
||||
public static class WorkflowModelBuilder
|
||||
{
|
||||
public static WorkflowNormalizationResult Normalize(
|
||||
DevToolConfig config,
|
||||
LegacyMode legacyMode = LegacyMode.Strict,
|
||||
IRequirementResolver? requirementResolver = null)
|
||||
{
|
||||
requirementResolver ??= new RequirementResolver();
|
||||
var warnings = new List<string>();
|
||||
|
||||
if (config.Workflows.Count > 0)
|
||||
{
|
||||
if (config.Targets.Count > 0)
|
||||
{
|
||||
warnings.Add("Both 'workflows' and legacy 'targets' are present. SDT will use 'workflows'.");
|
||||
}
|
||||
|
||||
return new WorkflowNormalizationResult(config.Workflows, warnings);
|
||||
}
|
||||
|
||||
if (config.Targets.Count == 0)
|
||||
{
|
||||
warnings.Add("No 'workflows' or legacy 'targets' were found.");
|
||||
return new WorkflowNormalizationResult([], warnings);
|
||||
}
|
||||
|
||||
if (legacyMode == LegacyMode.Strict)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Legacy 'targets' are not allowed in strict mode. Migrate to 'workflows' or set SDT_LEGACY_MODE=compat temporarily.");
|
||||
}
|
||||
|
||||
warnings.Add("Using legacy 'targets' schema. Migrate to 'workflows' for v1+ features.");
|
||||
return new WorkflowNormalizationResult(ConvertLegacyTargets(config.Targets, requirementResolver), warnings);
|
||||
}
|
||||
|
||||
public static DevToolConfig BuildMigrationPreviewConfig(DevToolConfig config, IRequirementResolver? requirementResolver = null)
|
||||
{
|
||||
requirementResolver ??= new RequirementResolver();
|
||||
return new DevToolConfig
|
||||
{
|
||||
Name = config.Name,
|
||||
Version = config.Version,
|
||||
Targets = [],
|
||||
Workflows = ConvertLegacyTargets(config.Targets, requirementResolver),
|
||||
Env = config.Env,
|
||||
Toolchains = config.Toolchains,
|
||||
Tooling = config.Tooling,
|
||||
Project = config.Project,
|
||||
Debug = config.Debug,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<WorkflowDefinition> ConvertLegacyTargets(
|
||||
IReadOnlyList<BuildTarget> targets,
|
||||
IRequirementResolver requirementResolver)
|
||||
{
|
||||
var workflows = new List<WorkflowDefinition>(targets.Count);
|
||||
foreach (var target in targets)
|
||||
{
|
||||
var step = target.Command is null
|
||||
? null
|
||||
: new WorkflowStep
|
||||
{
|
||||
Id = $"{target.Id}:run",
|
||||
Label = string.IsNullOrWhiteSpace(target.Label) ? target.Id : target.Label,
|
||||
Command = target.Command,
|
||||
Args = target.Args,
|
||||
WorkingDir = target.WorkingDir,
|
||||
Requires = requirementResolver.Resolve(target),
|
||||
};
|
||||
|
||||
workflows.Add(new WorkflowDefinition
|
||||
{
|
||||
Id = target.Id,
|
||||
Label = target.Label,
|
||||
Description = target.Description,
|
||||
Group = target.Group,
|
||||
DependsOn = target.DependsOn,
|
||||
Steps = step is null ? [] : [step],
|
||||
});
|
||||
}
|
||||
|
||||
return workflows;
|
||||
}
|
||||
}
|
||||
@ -12,8 +12,11 @@ public sealed class WorkspaceProject
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Relative path from the sdt-workspace.json directory to the project root
|
||||
/// Relative or absolute path to the project root
|
||||
/// (the directory containing devtool.json).
|
||||
/// </summary>
|
||||
public string Path { get; init; } = "";
|
||||
public List<string> Tags { get; init; } = [];
|
||||
public List<string> ToolFamilies { get; init; } = [];
|
||||
public bool Disabled { get; init; } = false;
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ namespace Sdt.Config;
|
||||
|
||||
public static class WorkspaceLoader
|
||||
{
|
||||
private const string FileName = "sdt-workspace.json";
|
||||
public const string FileName = "sdt-workspace.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@ -41,12 +41,130 @@ public static class WorkspaceLoader
|
||||
}
|
||||
dir = dir.Parent!;
|
||||
}
|
||||
return null;
|
||||
|
||||
// No workspace file found; synthesize one by scanning nearby project roots.
|
||||
return TryAutoDiscover(startDir ?? Directory.GetCurrentDirectory());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the absolute project root for a workspace project entry.
|
||||
/// </summary>
|
||||
public static string ResolveProjectRoot(string workspaceRoot, WorkspaceProject project)
|
||||
=> Path.GetFullPath(Path.Combine(workspaceRoot, project.Path));
|
||||
=> Path.GetFullPath(Path.IsPathRooted(project.Path)
|
||||
? project.Path
|
||||
: Path.Combine(workspaceRoot, project.Path));
|
||||
|
||||
public static string GetWorkspaceFilePath(string workspaceRoot)
|
||||
=> Path.Combine(workspaceRoot, FileName);
|
||||
|
||||
public static void Save(string workspaceRoot, WorkspaceConfig workspace)
|
||||
{
|
||||
var path = GetWorkspaceFilePath(workspaceRoot);
|
||||
var saveOptions = new JsonSerializerOptions(JsonOptions)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
var json = JsonSerializer.Serialize(workspace, saveOptions);
|
||||
File.WriteAllText(path, json + Environment.NewLine);
|
||||
}
|
||||
|
||||
private static (WorkspaceConfig Config, string WorkspaceRoot)? TryAutoDiscover(string startDir)
|
||||
{
|
||||
LoadedProjectConfig? loaded;
|
||||
try
|
||||
{
|
||||
loaded = ConfigLoader.FindAndLoad(startDir);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loaded is null)
|
||||
return null;
|
||||
|
||||
var currentRoot = loaded.ProjectRoot;
|
||||
var parent = Directory.GetParent(currentRoot);
|
||||
var workspaceRoot = parent?.FullName ?? currentRoot;
|
||||
|
||||
var roots = DiscoverProjectRoots(workspaceRoot, currentRoot);
|
||||
if (roots.Count == 0)
|
||||
return null;
|
||||
|
||||
var projects = new List<WorkspaceProject>();
|
||||
foreach (var root in roots)
|
||||
{
|
||||
LoadedProjectConfig? cfg;
|
||||
try
|
||||
{
|
||||
cfg = ConfigLoader.FindAndLoad(root);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = cfg?.Config.Name;
|
||||
projects.Add(new WorkspaceProject
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(name) ? new DirectoryInfo(root).Name : name!,
|
||||
Description = $"Auto-discovered at {root}",
|
||||
Path = Path.GetRelativePath(workspaceRoot, root),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
new WorkspaceConfig
|
||||
{
|
||||
Name = "SDT Auto Workspace",
|
||||
Projects = projects
|
||||
},
|
||||
workspaceRoot);
|
||||
}
|
||||
|
||||
private static List<string> DiscoverProjectRoots(string workspaceRoot, string currentRoot)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var excluded = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"bin",
|
||||
"obj",
|
||||
".git",
|
||||
".venv",
|
||||
"node_modules",
|
||||
};
|
||||
|
||||
void AddIfProject(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
if (File.Exists(Path.Combine(full, "devtool.json")))
|
||||
set.Add(full);
|
||||
}
|
||||
|
||||
AddIfProject(currentRoot);
|
||||
AddIfProject(workspaceRoot);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(workspaceRoot))
|
||||
{
|
||||
if (excluded.Contains(Path.GetFileName(dir)))
|
||||
continue;
|
||||
|
||||
AddIfProject(dir);
|
||||
foreach (var sub in Directory.EnumerateDirectories(dir))
|
||||
{
|
||||
if (excluded.Contains(Path.GetFileName(sub)))
|
||||
continue;
|
||||
AddIfProject(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore inaccessible directories during auto-discovery.
|
||||
}
|
||||
|
||||
return set.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
207
Journal.DevTool/Core/ActionRunner.cs
Normal file
207
Journal.DevTool/Core/ActionRunner.cs
Normal file
@ -0,0 +1,207 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Runner;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed class ActionRunner : IActionRunner
|
||||
{
|
||||
public async Task<RunResult> RunStepAsync(
|
||||
WorkflowStep step,
|
||||
string projectRoot,
|
||||
Action<string, bool> onOutput,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(step.Action))
|
||||
{
|
||||
var scriptPath = ScriptLocator.FindHelperScript(projectRoot, "build.py");
|
||||
if (scriptPath is null)
|
||||
throw new InvalidOperationException("build.py not found in bundled scripts or project scripts directory.");
|
||||
|
||||
var actionArgs = new List<string>
|
||||
{
|
||||
scriptPath,
|
||||
step.Action,
|
||||
"--project-root",
|
||||
projectRoot,
|
||||
};
|
||||
actionArgs.AddRange(step.ActionArgs);
|
||||
|
||||
return await ProcessRunner.RunAsync(
|
||||
PythonResolver.ResolveExecutable(),
|
||||
actionArgs,
|
||||
projectRoot,
|
||||
onOutput,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(step.Command))
|
||||
return new RunResult(0, TimeSpan.Zero);
|
||||
|
||||
var workingDir = Path.GetFullPath(Path.Combine(projectRoot, step.WorkingDir));
|
||||
|
||||
var pwshReroute = await TryRunLegacyPwshScriptViaPythonAsync(
|
||||
step,
|
||||
projectRoot,
|
||||
workingDir,
|
||||
onOutput,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (pwshReroute is not null)
|
||||
return pwshReroute;
|
||||
|
||||
return await ProcessRunner.RunAsync(
|
||||
step.Command,
|
||||
step.Args,
|
||||
workingDir,
|
||||
onOutput,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<RunResult?> TryRunLegacyPwshScriptViaPythonAsync(
|
||||
WorkflowStep step,
|
||||
string projectRoot,
|
||||
string workingDir,
|
||||
Action<string, bool> onOutput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsPowerShellCommand(step.Command))
|
||||
return null;
|
||||
|
||||
var args = step.Args;
|
||||
var fileIndex = FindArgIndex(args, "-File");
|
||||
if (fileIndex < 0 || fileIndex + 1 >= args.Count)
|
||||
return null;
|
||||
|
||||
var psScriptArg = args[fileIndex + 1];
|
||||
if (!psScriptArg.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var pyScriptPath = ResolvePythonScriptPath(projectRoot, workingDir, psScriptArg);
|
||||
if (pyScriptPath is null)
|
||||
return null;
|
||||
|
||||
var translated = TranslatePowerShellArgsToPython(args.Skip(fileIndex + 2));
|
||||
var pythonArgs = new List<string> { pyScriptPath };
|
||||
pythonArgs.AddRange(translated);
|
||||
|
||||
onOutput($"Legacy PowerShell target detected. Trying Python script first: {Path.GetFileName(pyScriptPath)}", false);
|
||||
|
||||
var pyRun = await ProcessRunner.RunAsync(
|
||||
PythonResolver.ResolveExecutable(),
|
||||
pythonArgs,
|
||||
workingDir,
|
||||
onOutput,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (pyRun.Success)
|
||||
return pyRun;
|
||||
|
||||
var psScriptPath = ResolveScriptPath(workingDir, psScriptArg);
|
||||
if (psScriptPath is null || !File.Exists(psScriptPath))
|
||||
return pyRun;
|
||||
|
||||
onOutput(
|
||||
$"Python script failed (exit {pyRun.ExitCode}). Falling back to legacy PowerShell script: {psScriptArg}",
|
||||
true);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ResolvePythonScriptPath(string projectRoot, string workingDir, string psScriptArg)
|
||||
{
|
||||
var pyArg = Path.ChangeExtension(psScriptArg, ".py");
|
||||
var candidate = ResolveScriptPath(workingDir, pyArg);
|
||||
if (candidate is not null && File.Exists(candidate))
|
||||
return candidate;
|
||||
|
||||
var fileName = Path.GetFileName(pyArg);
|
||||
return ScriptLocator.FindHelperScript(projectRoot, fileName);
|
||||
}
|
||||
|
||||
private static string? ResolveScriptPath(string workingDir, string scriptArg)
|
||||
{
|
||||
if (Path.IsPathRooted(scriptArg))
|
||||
return scriptArg;
|
||||
return Path.GetFullPath(Path.Combine(workingDir, scriptArg));
|
||||
}
|
||||
|
||||
private static bool IsPowerShellCommand(string? command)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return false;
|
||||
|
||||
var normalized = Path.GetFileNameWithoutExtension(command).ToLowerInvariant();
|
||||
return normalized is "pwsh" or "powershell";
|
||||
}
|
||||
|
||||
private static int FindArgIndex(IReadOnlyList<string> args, string name)
|
||||
{
|
||||
for (var i = 0; i < args.Count; i++)
|
||||
{
|
||||
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static List<string> TranslatePowerShellArgsToPython(IEnumerable<string> inputArgs)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var list = inputArgs.ToList();
|
||||
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
var token = list[i];
|
||||
if (!token.StartsWith("-", StringComparison.Ordinal) || token == "-")
|
||||
{
|
||||
result.Add(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = token.TrimStart('-');
|
||||
if (key.Length == 0)
|
||||
continue;
|
||||
|
||||
var mapped = MapPowerShellParameter(key);
|
||||
var nextIsValue = (i + 1) < list.Count && !list[i + 1].StartsWith("-", StringComparison.Ordinal);
|
||||
result.Add(mapped);
|
||||
if (nextIsValue)
|
||||
{
|
||||
result.Add(list[i + 1]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string MapPowerShellParameter(string key)
|
||||
{
|
||||
return key.ToLowerInvariant() switch
|
||||
{
|
||||
"tauribundles" => "--tauri-bundles",
|
||||
"projectroot" => "--project-root",
|
||||
"reporoot" => "--repo-root",
|
||||
"outputzip" => "--output-zip",
|
||||
"inputzip" => "--input-zip",
|
||||
"workingdir" => "--working-dir",
|
||||
"outputdir" => "--output-dir",
|
||||
_ => "--" + ToKebabCase(key)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToKebabCase(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return value.ToLowerInvariant();
|
||||
|
||||
var chars = new List<char>(value.Length + 4);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var c = value[i];
|
||||
if (char.IsUpper(c) && i > 0 && value[i - 1] != '-')
|
||||
chars.Add('-');
|
||||
chars.Add(char.ToLowerInvariant(c));
|
||||
}
|
||||
|
||||
return new string(chars.ToArray());
|
||||
}
|
||||
}
|
||||
173
Journal.DevTool/Core/CommandResolver.cs
Normal file
173
Journal.DevTool/Core/CommandResolver.cs
Normal file
@ -0,0 +1,173 @@
|
||||
namespace Sdt.Core;
|
||||
|
||||
public enum CommandResolutionSource
|
||||
{
|
||||
Exact,
|
||||
Path,
|
||||
Shim,
|
||||
NodeAdjacentShim,
|
||||
ConfiguredOverride,
|
||||
Fallback,
|
||||
}
|
||||
|
||||
public sealed record CommandResolutionResult(
|
||||
string Requested,
|
||||
string Resolved,
|
||||
CommandResolutionSource Source);
|
||||
|
||||
public static class CommandResolver
|
||||
{
|
||||
public static CommandResolutionResult ResolveWithTrace(string command, Config.DevToolConfig? config = null, string? tool = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
|
||||
|
||||
if (command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar))
|
||||
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
|
||||
|
||||
var normalized = command.ToLowerInvariant();
|
||||
if (Path.HasExtension(command))
|
||||
{
|
||||
var extensionResolved = ResolveFromPath(command);
|
||||
return extensionResolved is null
|
||||
? new CommandResolutionResult(command, command, CommandResolutionSource.Fallback)
|
||||
: new CommandResolutionResult(command, extensionResolved, CommandResolutionSource.Path);
|
||||
}
|
||||
|
||||
var overrideTool = string.IsNullOrWhiteSpace(tool) ? normalized : tool.ToLowerInvariant();
|
||||
var configuredCandidates = config?.Tooling?.Tools
|
||||
.FirstOrDefault(t => string.Equals(t.Tool, overrideTool, StringComparison.OrdinalIgnoreCase))
|
||||
?.Executables
|
||||
?.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.ToList();
|
||||
|
||||
if (configuredCandidates is not null)
|
||||
{
|
||||
foreach (var configured in configuredCandidates)
|
||||
{
|
||||
var resolvedConfigured = ResolveFromPath(configured!) ?? configured!;
|
||||
if (IsUsableExecutable(resolvedConfigured))
|
||||
return new CommandResolutionResult(command, resolvedConfigured, CommandResolutionSource.ConfiguredOverride);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in BuildWindowsCandidates(command, normalized))
|
||||
{
|
||||
var resolved = ResolveFromPath(candidate);
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
var source = candidate.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) ||
|
||||
candidate.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ||
|
||||
candidate.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)
|
||||
? CommandResolutionSource.Shim
|
||||
: CommandResolutionSource.Path;
|
||||
return new CommandResolutionResult(command, resolved!, source);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized is "npm" or "npx" or "pnpm" or "yarn")
|
||||
{
|
||||
var nodePath = ResolveFromPath("node.exe") ?? ResolveFromPath("node");
|
||||
if (!string.IsNullOrWhiteSpace(nodePath))
|
||||
{
|
||||
var nodeDir = Path.GetDirectoryName(nodePath);
|
||||
if (!string.IsNullOrWhiteSpace(nodeDir))
|
||||
{
|
||||
var shim = Path.Combine(nodeDir, normalized + ".cmd");
|
||||
if (File.Exists(shim))
|
||||
return new CommandResolutionResult(command, shim, CommandResolutionSource.NodeAdjacentShim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fallback = BuildWindowsCandidates(command, normalized).LastOrDefault() ?? command;
|
||||
return new CommandResolutionResult(command, fallback, CommandResolutionSource.Fallback);
|
||||
}
|
||||
|
||||
public static string Resolve(string command)
|
||||
{
|
||||
return ResolveWithTrace(command).Resolved;
|
||||
}
|
||||
|
||||
private static string? ResolveFromPath(string executable)
|
||||
{
|
||||
var pathValue = Environment.GetEnvironmentVariable("PATH");
|
||||
if (string.IsNullOrWhiteSpace(pathValue))
|
||||
return null;
|
||||
|
||||
foreach (var segment in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
try
|
||||
{
|
||||
var expandedSegment = ExpandWindowsPathTokens(segment.Trim());
|
||||
var candidate = Path.Combine(expandedSegment, executable);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
// If PATH lookup hit extensionless npm but npm.cmd exists beside it, prefer npm.cmd.
|
||||
var fileName = Path.GetFileName(candidate).ToLowerInvariant();
|
||||
if (fileName is "npm" or "npx" or "pnpm" or "yarn" or "tauri")
|
||||
{
|
||||
var shim = candidate + ".cmd";
|
||||
if (File.Exists(shim))
|
||||
return shim;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore malformed PATH segments.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ExpandWindowsPathTokens(string segment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment) || !OperatingSystem.IsWindows())
|
||||
return segment;
|
||||
|
||||
var expanded = segment;
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var next = Environment.ExpandEnvironmentVariables(expanded);
|
||||
if (string.Equals(next, expanded, StringComparison.Ordinal))
|
||||
break;
|
||||
expanded = next;
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
private static List<string> BuildWindowsCandidates(string command, string normalized)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
if (normalized is "npm" or "npx" or "pnpm" or "yarn" or "tauri")
|
||||
{
|
||||
candidates.Add(command + ".cmd");
|
||||
candidates.Add(command + ".exe");
|
||||
candidates.Add(command + ".bat");
|
||||
candidates.Add(command);
|
||||
}
|
||||
else
|
||||
{
|
||||
candidates.Add(command);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static bool IsUsableExecutable(string resolved)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resolved))
|
||||
return false;
|
||||
|
||||
if (Path.IsPathRooted(resolved))
|
||||
return File.Exists(resolved);
|
||||
|
||||
return ResolveFromPath(resolved) is not null;
|
||||
}
|
||||
}
|
||||
67
Journal.DevTool/Core/ConfigDoctorAutoFixService.cs
Normal file
67
Journal.DevTool/Core/ConfigDoctorAutoFixService.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using Sdt.Config;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed record DoctorAutoFixResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
int CreatedDirectories = 0,
|
||||
string? BackupPath = null);
|
||||
|
||||
public sealed class ConfigDoctorAutoFixService
|
||||
{
|
||||
public IReadOnlyList<string> FindMissingWorkingDirectories(DevToolConfig config, string projectRoot)
|
||||
{
|
||||
var missing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var workflow in config.Workflows)
|
||||
{
|
||||
foreach (var step in workflow.Steps)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(step.WorkingDir))
|
||||
continue;
|
||||
var path = Path.GetFullPath(Path.Combine(projectRoot, step.WorkingDir));
|
||||
if (!Directory.Exists(path))
|
||||
missing.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return missing.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
public DoctorAutoFixResult CreateMissingWorkingDirectories(IReadOnlyList<string> directories)
|
||||
{
|
||||
var created = 0;
|
||||
try
|
||||
{
|
||||
foreach (var dir in directories)
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
continue;
|
||||
Directory.CreateDirectory(dir);
|
||||
created++;
|
||||
}
|
||||
|
||||
return new DoctorAutoFixResult(
|
||||
Success: true,
|
||||
Message: created == 0 ? "No directories needed creation." : $"Created {created} missing working director{(created == 1 ? "y" : "ies")}.",
|
||||
CreatedDirectories: created);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DoctorAutoFixResult(false, ex.Message, CreatedDirectories: created);
|
||||
}
|
||||
}
|
||||
|
||||
public DoctorAutoFixResult ApplyLegacyMigration(string projectRoot)
|
||||
{
|
||||
var configPath = ConfigLoader.FindConfigPath(projectRoot);
|
||||
if (string.IsNullOrWhiteSpace(configPath))
|
||||
return new DoctorAutoFixResult(false, "Could not find devtool.json for migration.");
|
||||
|
||||
var migration = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
|
||||
return new DoctorAutoFixResult(
|
||||
Success: migration.Success,
|
||||
Message: migration.Message,
|
||||
BackupPath: migration.BackupPath);
|
||||
}
|
||||
}
|
||||
270
Journal.DevTool/Core/ConfigDoctorService.cs
Normal file
270
Journal.DevTool/Core/ConfigDoctorService.cs
Normal file
@ -0,0 +1,270 @@
|
||||
using Sdt.Config;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public enum DoctorStatus
|
||||
{
|
||||
Pass,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
public sealed record DoctorCheck(
|
||||
string Name,
|
||||
DoctorStatus Status,
|
||||
string Detail,
|
||||
string? Fix = null);
|
||||
|
||||
public sealed record DoctorReport(
|
||||
IReadOnlyList<DoctorCheck> Checks)
|
||||
{
|
||||
public bool HasFailures => Checks.Any(c => c.Status == DoctorStatus.Fail);
|
||||
public bool HasWarnings => Checks.Any(c => c.Status == DoctorStatus.Warn);
|
||||
}
|
||||
|
||||
public sealed class ConfigDoctorService(
|
||||
IToolProbe? toolProbe = null,
|
||||
IRequirementResolver? requirementResolver = null)
|
||||
{
|
||||
private readonly IToolProbe _toolProbe = toolProbe ?? new ToolProbeService();
|
||||
private readonly IRequirementResolver _requirementResolver = requirementResolver ?? new RequirementResolver();
|
||||
|
||||
public async Task<DoctorReport> RunAsync(
|
||||
DevToolConfig config,
|
||||
string projectRoot,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var checks = new List<DoctorCheck>();
|
||||
var workflowMap = config.Workflows.ToDictionary(w => w.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AddSchemaChecks(config, checks);
|
||||
AddWorkflowChecks(config.Workflows, workflowMap, projectRoot, checks);
|
||||
AddPathChecks(config, projectRoot, checks);
|
||||
await AddToolProbeChecksAsync(config, projectRoot, checks, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new DoctorReport(checks);
|
||||
}
|
||||
|
||||
private static void AddSchemaChecks(DevToolConfig config, List<DoctorCheck> checks)
|
||||
{
|
||||
if (config.Workflows.Count == 0 && config.Targets.Count == 0)
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
"Config schema",
|
||||
DoctorStatus.Fail,
|
||||
"No workflows or legacy targets found.",
|
||||
"Add workflows or run SDT init/bootstrap."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.Workflows.Count == 0 && config.Targets.Count > 0)
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
"Legacy schema",
|
||||
DoctorStatus.Fail,
|
||||
"Targets-only config detected (strict mode will block execution).",
|
||||
"Use SYSTEM -> Migrate legacy targets -> workflows."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.Targets.Count > 0)
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
"Legacy schema",
|
||||
DoctorStatus.Warn,
|
||||
"Both workflows and legacy targets are present.",
|
||||
"Prefer workflows-only config and remove legacy targets once migrated."));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new DoctorCheck("Config schema", DoctorStatus.Pass, "Workflow-first config detected."));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddWorkflowChecks(
|
||||
IReadOnlyList<WorkflowDefinition> workflows,
|
||||
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap,
|
||||
string projectRoot,
|
||||
List<DoctorCheck> checks)
|
||||
{
|
||||
var duplicateIds = workflows
|
||||
.GroupBy(w => w.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
|
||||
if (duplicateIds.Count > 0)
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
"Workflow IDs",
|
||||
DoctorStatus.Fail,
|
||||
$"Duplicate workflow IDs: {string.Join(", ", duplicateIds)}",
|
||||
"Ensure each workflow has a unique id."));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new DoctorCheck("Workflow IDs", DoctorStatus.Pass, "No duplicate workflow IDs."));
|
||||
}
|
||||
|
||||
var brokenDeps = new List<string>();
|
||||
foreach (var workflow in workflows)
|
||||
{
|
||||
foreach (var dep in workflow.DependsOn)
|
||||
{
|
||||
if (!workflowMap.ContainsKey(dep))
|
||||
brokenDeps.Add($"{workflow.Id} -> {dep}");
|
||||
}
|
||||
}
|
||||
|
||||
if (brokenDeps.Count > 0)
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
"Workflow dependencies",
|
||||
DoctorStatus.Fail,
|
||||
$"Missing dependencies: {string.Join("; ", brokenDeps)}",
|
||||
"Fix dependsOn IDs to reference existing workflows."));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new DoctorCheck("Workflow dependencies", DoctorStatus.Pass, "All workflow dependencies are valid."));
|
||||
}
|
||||
|
||||
var invalidSteps = new List<string>();
|
||||
var missingWorkingDirs = new List<string>();
|
||||
foreach (var workflow in workflows)
|
||||
{
|
||||
foreach (var step in workflow.Steps)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(step.Command) && string.IsNullOrWhiteSpace(step.Action))
|
||||
invalidSteps.Add($"{workflow.Id}/{step.Id}");
|
||||
|
||||
var stepDir = Path.GetFullPath(Path.Combine(projectRoot, step.WorkingDir));
|
||||
if (!Directory.Exists(stepDir))
|
||||
missingWorkingDirs.Add($"{workflow.Id}/{step.Id} -> {step.WorkingDir}");
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidSteps.Count > 0)
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
"Step definitions",
|
||||
DoctorStatus.Fail,
|
||||
$"Steps missing command/action: {string.Join(", ", invalidSteps)}",
|
||||
"Each step must define either action or command."));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new DoctorCheck("Step definitions", DoctorStatus.Pass, "All steps define an action or command."));
|
||||
}
|
||||
|
||||
if (missingWorkingDirs.Count > 0)
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
"Working directories",
|
||||
DoctorStatus.Warn,
|
||||
$"Missing directories: {string.Join("; ", missingWorkingDirs)}",
|
||||
"Create missing directories or fix step workingDir values."));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new DoctorCheck("Working directories", DoctorStatus.Pass, "All referenced working directories exist."));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddPathChecks(DevToolConfig config, string projectRoot, List<DoctorCheck> checks)
|
||||
{
|
||||
var configPath = Path.Combine(projectRoot, "devtool.json");
|
||||
checks.Add(File.Exists(configPath)
|
||||
? new DoctorCheck("Project root", DoctorStatus.Pass, $"Config found at {configPath}")
|
||||
: new DoctorCheck("Project root", DoctorStatus.Fail, $"devtool.json not found at {configPath}", "Run SDT init/bootstrap."));
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var pathValue = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
var unresolvedSegments = pathValue
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(s => s.Contains('%') && Environment.ExpandEnvironmentVariables(s) == s)
|
||||
.Take(4)
|
||||
.ToList();
|
||||
|
||||
if (unresolvedSegments.Count > 0)
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
"PATH expansion",
|
||||
DoctorStatus.Warn,
|
||||
$"Unresolved PATH tokens: {string.Join(" | ", unresolvedSegments)}",
|
||||
"Set referenced env vars or remove invalid PATH segments."));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new DoctorCheck("PATH expansion", DoctorStatus.Pass, "No unresolved PATH token segments detected."));
|
||||
}
|
||||
}
|
||||
|
||||
if (config.Project?.RootHints.Count > 0)
|
||||
{
|
||||
checks.Add(new DoctorCheck("Root hints", DoctorStatus.Pass, $"Configured root hints: {string.Join(", ", config.Project.RootHints)}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
"Root hints",
|
||||
DoctorStatus.Warn,
|
||||
"No project.rootHints configured.",
|
||||
"Add rootHints markers (for example .git, *.sln, package.json)."));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddToolProbeChecksAsync(
|
||||
DevToolConfig config,
|
||||
string projectRoot,
|
||||
List<DoctorCheck> checks,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requiredTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var workflow in config.Workflows)
|
||||
{
|
||||
foreach (var step in workflow.Steps)
|
||||
{
|
||||
foreach (var req in _requirementResolver.Resolve(step))
|
||||
requiredTools.Add(req.Tool);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var profile in config.Debug?.Profiles ?? [])
|
||||
{
|
||||
foreach (var req in profile.Requires)
|
||||
requiredTools.Add(req.Tool);
|
||||
}
|
||||
|
||||
foreach (var toolDef in config.Tooling?.Tools ?? [])
|
||||
requiredTools.Add(toolDef.Tool);
|
||||
|
||||
if (requiredTools.Count == 0)
|
||||
{
|
||||
checks.Add(new DoctorCheck("Tool probes", DoctorStatus.Warn, "No tools discovered from workflows/debug/tooling."));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var tool in requiredTools.OrderBy(t => t, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var probe = await _toolProbe.ProbeAsync(tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
|
||||
if (probe.IsAvailable)
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
$"Tool: {tool}",
|
||||
DoctorStatus.Pass,
|
||||
string.IsNullOrWhiteSpace(probe.Version) ? "available" : probe.Version!,
|
||||
probe.Details));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(new DoctorCheck(
|
||||
$"Tool: {tool}",
|
||||
DoctorStatus.Fail,
|
||||
string.IsNullOrWhiteSpace(probe.Details) ? "not available" : probe.Details!,
|
||||
$"Install/configure {tool} or set tooling.tools[].executables for non-standard paths."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
Journal.DevTool/Core/Contracts.cs
Normal file
87
Journal.DevTool/Core/Contracts.cs
Normal file
@ -0,0 +1,87 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Runner;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed record ProbeResult(
|
||||
string Tool,
|
||||
bool IsAvailable,
|
||||
string? Version = null,
|
||||
string? Details = null);
|
||||
|
||||
public sealed record InstallCommand(
|
||||
string Command,
|
||||
IReadOnlyList<string> Args);
|
||||
|
||||
public sealed record InstallPlan(
|
||||
string Tool,
|
||||
bool Supported,
|
||||
string Summary,
|
||||
IReadOnlyList<InstallCommand> Commands);
|
||||
|
||||
public sealed record WorkflowStepResult(
|
||||
string WorkflowId,
|
||||
string StepId,
|
||||
string StepLabel,
|
||||
RunResult Result);
|
||||
|
||||
public enum ExecutionStopReason
|
||||
{
|
||||
MissingPrereq,
|
||||
InstallFailed,
|
||||
CommandFailed,
|
||||
ValidationFailed,
|
||||
UserDeclined,
|
||||
}
|
||||
|
||||
public sealed record WorkflowExecutionResult(
|
||||
bool Success,
|
||||
ExecutionStopReason? StopReason,
|
||||
string Message,
|
||||
IReadOnlyList<WorkflowStepResult> Steps);
|
||||
|
||||
public interface IToolProbe
|
||||
{
|
||||
Task<ProbeResult> ProbeAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IPrereqInstaller
|
||||
{
|
||||
Task<InstallPlan> GetInstallPlanAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RunResult> RunInstallAsync(
|
||||
InstallCommand command,
|
||||
string projectRoot,
|
||||
Action<string, bool> onOutput,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IActionRunner
|
||||
{
|
||||
Task<RunResult> RunStepAsync(
|
||||
WorkflowStep step,
|
||||
string projectRoot,
|
||||
Action<string, bool> onOutput,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IWorkflowPlanner
|
||||
{
|
||||
List<WorkflowDefinition> ResolvePlan(
|
||||
WorkflowDefinition workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinition> allWorkflows);
|
||||
}
|
||||
|
||||
public interface IRequirementResolver
|
||||
{
|
||||
List<ToolRequirement> Resolve(WorkflowStep step);
|
||||
List<ToolRequirement> Resolve(BuildTarget target);
|
||||
}
|
||||
52
Journal.DevTool/Core/Debug/DebugContracts.cs
Normal file
52
Journal.DevTool/Core/Debug/DebugContracts.cs
Normal file
@ -0,0 +1,52 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Runner;
|
||||
|
||||
namespace Sdt.Core.Debug;
|
||||
|
||||
public sealed record DebugRunResult(
|
||||
bool Success,
|
||||
ExecutionStopReason? StopReason,
|
||||
string Message,
|
||||
DebugProfileDefinition Profile,
|
||||
RunResult? RunResult,
|
||||
IReadOnlyList<string> OutputLines,
|
||||
IReadOnlyList<ProbeResult> Probes);
|
||||
|
||||
public sealed record DiagnosticsBundleResult(
|
||||
bool Success,
|
||||
string BundleDirectory,
|
||||
string? ZipPath,
|
||||
string Message);
|
||||
|
||||
public sealed record DiagnosticsBundleRequest(
|
||||
string Category,
|
||||
string ProjectRoot,
|
||||
string SummaryMessage,
|
||||
IReadOnlyList<string> OutputLines,
|
||||
IReadOnlyList<WorkflowStepResult> WorkflowSteps,
|
||||
IReadOnlyList<ProbeResult> Probes,
|
||||
DebugDiagnosticsOptions DiagnosticsOptions,
|
||||
DevToolConfig Config,
|
||||
ExecutionStopReason? StopReason = null,
|
||||
RunResult? DebugRun = null,
|
||||
DebugProfileDefinition? DebugProfile = null);
|
||||
|
||||
public interface IDebugProfileRunner
|
||||
{
|
||||
Task<DebugRunResult> RunAsync(
|
||||
DebugProfileDefinition profile,
|
||||
DevToolConfig config,
|
||||
string projectRoot,
|
||||
bool verbose,
|
||||
Func<string, InstallPlan, Task<bool>> confirmInstallAsync,
|
||||
Action<string, bool> onOutput,
|
||||
Action<RunEvent>? onEvent = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IDiagnosticsBundleService
|
||||
{
|
||||
Task<DiagnosticsBundleResult> WriteBundleAsync(
|
||||
DiagnosticsBundleRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
185
Journal.DevTool/Core/Debug/DebugProfileRunner.cs
Normal file
185
Journal.DevTool/Core/Debug/DebugProfileRunner.cs
Normal file
@ -0,0 +1,185 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Runner;
|
||||
|
||||
namespace Sdt.Core.Debug;
|
||||
|
||||
public sealed class DebugProfileRunner(
|
||||
IToolProbe toolProbe,
|
||||
IPrereqInstaller installer) : IDebugProfileRunner
|
||||
{
|
||||
private readonly IToolProbe _toolProbe = toolProbe;
|
||||
private readonly IPrereqInstaller _installer = installer;
|
||||
|
||||
public async Task<DebugRunResult> RunAsync(
|
||||
DebugProfileDefinition profile,
|
||||
DevToolConfig config,
|
||||
string projectRoot,
|
||||
bool verbose,
|
||||
Func<string, InstallPlan, Task<bool>> confirmInstallAsync,
|
||||
Action<string, bool> onOutput,
|
||||
Action<RunEvent>? onEvent = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var probes = new List<ProbeResult>();
|
||||
var output = new List<string>();
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "debug",
|
||||
Type: RunEventType.DebugStarted,
|
||||
Message: $"Debug profile '{profile.Id}' started."));
|
||||
var requires = profile.Requires.Count > 0
|
||||
? profile.Requires
|
||||
: InferRequirements(profile);
|
||||
|
||||
foreach (var req in requires)
|
||||
{
|
||||
var probe = await _toolProbe.ProbeAsync(req.Tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
|
||||
probes.Add(probe);
|
||||
if (probe.IsAvailable)
|
||||
continue;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(probe.Details))
|
||||
{
|
||||
var line = $"Probe detail [{req.Tool}]: {probe.Details}";
|
||||
output.Add("OUT: " + line);
|
||||
if (verbose)
|
||||
onOutput(line, false);
|
||||
}
|
||||
|
||||
if (req.InstallPolicy == InstallPolicy.Never)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "debug",
|
||||
Type: RunEventType.DebugCompleted,
|
||||
Message: $"Missing prerequisite '{req.Tool}'.",
|
||||
Tool: req.Tool,
|
||||
Success: false));
|
||||
return new DebugRunResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.MissingPrereq,
|
||||
Message: $"Missing prerequisite '{req.Tool}' for debug profile '{profile.Label}'.",
|
||||
Profile: profile,
|
||||
RunResult: null,
|
||||
OutputLines: output,
|
||||
Probes: probes);
|
||||
}
|
||||
|
||||
var installPlan = await _installer.GetInstallPlanAsync(req.Tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
|
||||
if (!installPlan.Supported || installPlan.Commands.Count == 0)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "debug",
|
||||
Type: RunEventType.DebugCompleted,
|
||||
Message: $"No installer plan available for '{req.Tool}'.",
|
||||
Tool: req.Tool,
|
||||
Success: false));
|
||||
return new DebugRunResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.MissingPrereq,
|
||||
Message: $"Missing prerequisite '{req.Tool}' and no installer plan is available.",
|
||||
Profile: profile,
|
||||
RunResult: null,
|
||||
OutputLines: output,
|
||||
Probes: probes);
|
||||
}
|
||||
|
||||
var approved = req.InstallPolicy == InstallPolicy.Auto
|
||||
? true
|
||||
: await confirmInstallAsync(req.Tool, installPlan).ConfigureAwait(false);
|
||||
if (!approved)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "debug",
|
||||
Type: RunEventType.InstallDeclined,
|
||||
Message: $"Install declined for '{req.Tool}'.",
|
||||
Tool: req.Tool,
|
||||
Success: false));
|
||||
return new DebugRunResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.UserDeclined,
|
||||
Message: $"Install declined for missing prerequisite '{req.Tool}'.",
|
||||
Profile: profile,
|
||||
RunResult: null,
|
||||
OutputLines: output,
|
||||
Probes: probes);
|
||||
}
|
||||
|
||||
foreach (var cmd in installPlan.Commands)
|
||||
{
|
||||
var installResult = await _installer.RunInstallAsync(cmd, projectRoot, onOutput, cancellationToken).ConfigureAwait(false);
|
||||
if (!installResult.Success)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "debug",
|
||||
Type: RunEventType.DebugCompleted,
|
||||
Message: $"Install failed for '{req.Tool}'.",
|
||||
Tool: req.Tool,
|
||||
Success: false,
|
||||
ExitCode: installResult.ExitCode));
|
||||
return new DebugRunResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.InstallFailed,
|
||||
Message: $"Failed to install prerequisite '{req.Tool}'.",
|
||||
Profile: profile,
|
||||
RunResult: installResult,
|
||||
OutputLines: output,
|
||||
Probes: probes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cwd = Path.GetFullPath(Path.Combine(projectRoot, profile.WorkingDir));
|
||||
var mergedEnv = profile.Env.Count > 0 ? profile.Env : null;
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "debug",
|
||||
Type: RunEventType.DebugCommandStarted,
|
||||
Message: $"{profile.Command} {string.Join(" ", profile.Args)}"));
|
||||
var run = await ProcessRunner.RunAsync(
|
||||
profile.Command,
|
||||
profile.Args,
|
||||
cwd,
|
||||
(line, isErr) =>
|
||||
{
|
||||
output.Add((isErr ? "ERR: " : "OUT: ") + line);
|
||||
if (verbose)
|
||||
onOutput(line, isErr);
|
||||
},
|
||||
mergedEnv,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "debug",
|
||||
Type: RunEventType.DebugCommandCompleted,
|
||||
Message: $"Debug command exited {run.ExitCode}.",
|
||||
Success: run.Success,
|
||||
ExitCode: run.ExitCode));
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "debug",
|
||||
Type: RunEventType.DebugCompleted,
|
||||
Message: run.Success ? "Debug run completed." : "Debug run failed.",
|
||||
Success: run.Success,
|
||||
ExitCode: run.ExitCode));
|
||||
|
||||
return new DebugRunResult(
|
||||
Success: run.Success,
|
||||
StopReason: run.Success ? null : ExecutionStopReason.CommandFailed,
|
||||
Message: run.Success
|
||||
? $"Debug profile '{profile.Label}' completed."
|
||||
: $"Debug profile '{profile.Label}' exited with code {run.ExitCode}.",
|
||||
Profile: profile,
|
||||
RunResult: run,
|
||||
OutputLines: output,
|
||||
Probes: probes);
|
||||
}
|
||||
|
||||
private static List<ToolRequirement> InferRequirements(DebugProfileDefinition profile)
|
||||
{
|
||||
return profile.Type.ToLowerInvariant() switch
|
||||
{
|
||||
"dotnet" => [new ToolRequirement { Tool = "dotnet" }],
|
||||
"node" => [new ToolRequirement { Tool = "node" }, new ToolRequirement { Tool = "npm" }],
|
||||
"python" => [new ToolRequirement { Tool = "python" }],
|
||||
_ => string.IsNullOrWhiteSpace(profile.Command)
|
||||
? []
|
||||
: [new ToolRequirement { Tool = profile.Command }],
|
||||
};
|
||||
}
|
||||
}
|
||||
99
Journal.DevTool/Core/Debug/DiagnosticsBundleService.cs
Normal file
99
Journal.DevTool/Core/Debug/DiagnosticsBundleService.cs
Normal file
@ -0,0 +1,99 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Sdt.Config;
|
||||
|
||||
namespace Sdt.Core.Debug;
|
||||
|
||||
public sealed class DiagnosticsBundleService : IDiagnosticsBundleService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public async Task<DiagnosticsBundleResult> WriteBundleAsync(
|
||||
DiagnosticsBundleRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var timestamp = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss");
|
||||
var root = Path.GetFullPath(Path.Combine(request.ProjectRoot, request.DiagnosticsOptions.OutputDir));
|
||||
var bundleDir = Path.Combine(root, $"{request.Category}-{timestamp}");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var stepsPath = Path.Combine(bundleDir, "steps.json");
|
||||
var toolsPath = Path.Combine(bundleDir, "tools.json");
|
||||
var envPath = Path.Combine(bundleDir, "env.json");
|
||||
var outputPath = Path.Combine(bundleDir, "output.log");
|
||||
var summaryPath = Path.Combine(bundleDir, "summary.json");
|
||||
|
||||
await File.WriteAllTextAsync(stepsPath, JsonSerializer.Serialize(request.WorkflowSteps, JsonOptions), cancellationToken);
|
||||
await File.WriteAllTextAsync(toolsPath, JsonSerializer.Serialize(request.Probes, JsonOptions), cancellationToken);
|
||||
await File.WriteAllTextAsync(envPath, JsonSerializer.Serialize(CaptureEnvironment(request.DiagnosticsOptions), JsonOptions), cancellationToken);
|
||||
await File.WriteAllLinesAsync(outputPath, request.OutputLines, cancellationToken);
|
||||
|
||||
var summary = new
|
||||
{
|
||||
category = request.Category,
|
||||
stopReason = request.StopReason?.ToString(),
|
||||
message = request.SummaryMessage,
|
||||
createdAt = DateTimeOffset.Now,
|
||||
configHash = HashConfig(request.Config),
|
||||
debugProfile = request.DebugProfile?.Id,
|
||||
debugExitCode = request.DebugRun?.ExitCode,
|
||||
envCapture = BuildEnvCaptureSummary(request.DiagnosticsOptions),
|
||||
};
|
||||
await File.WriteAllTextAsync(summaryPath, JsonSerializer.Serialize(summary, JsonOptions), cancellationToken);
|
||||
|
||||
return new DiagnosticsBundleResult(
|
||||
Success: true,
|
||||
BundleDirectory: bundleDir,
|
||||
ZipPath: null,
|
||||
Message: "Diagnostics bundle generated.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DiagnosticsBundleResult(
|
||||
Success: false,
|
||||
BundleDirectory: string.Empty,
|
||||
ZipPath: null,
|
||||
Message: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> CaptureEnvironment(DebugDiagnosticsOptions options)
|
||||
{
|
||||
if (options.IncludeAllEnv)
|
||||
{
|
||||
return Environment.GetEnvironmentVariables()
|
||||
.Cast<System.Collections.DictionaryEntry>()
|
||||
.ToDictionary(e => e.Key?.ToString() ?? "", e => e.Value?.ToString() ?? "", StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (options.CaptureEnvKeys.Count == 0)
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var captured = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var key in options.CaptureEnvKeys)
|
||||
captured[key] = Environment.GetEnvironmentVariable(key) ?? "";
|
||||
return captured;
|
||||
}
|
||||
|
||||
private static string BuildEnvCaptureSummary(DebugDiagnosticsOptions options)
|
||||
{
|
||||
if (options.IncludeAllEnv)
|
||||
return "full";
|
||||
if (options.CaptureEnvKeys.Count == 0)
|
||||
return "allowlist-empty (intentional minimal capture)";
|
||||
return $"allowlist ({options.CaptureEnvKeys.Count} keys)";
|
||||
}
|
||||
|
||||
private static string HashConfig(object config)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(config);
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
}
|
||||
59
Journal.DevTool/Core/LegacyScriptRequirementResolver.cs
Normal file
59
Journal.DevTool/Core/LegacyScriptRequirementResolver.cs
Normal file
@ -0,0 +1,59 @@
|
||||
using Sdt.Config;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
internal static class LegacyScriptRequirementResolver
|
||||
{
|
||||
public static List<ToolRequirement> InferForPowerShellArgs(IReadOnlyList<string> args)
|
||||
{
|
||||
var script = FindScriptArg(args);
|
||||
if (string.IsNullOrWhiteSpace(script))
|
||||
return [];
|
||||
|
||||
static ToolRequirement Req(string tool) => new() { Tool = tool, InstallPolicy = InstallPolicy.Prompt };
|
||||
|
||||
var file = Path.GetFileName(script).ToLowerInvariant();
|
||||
var lowerArgs = args.Select(a => a.ToLowerInvariant()).ToList();
|
||||
|
||||
return file switch
|
||||
{
|
||||
"publish-app.ps1" => IsTauriTarget(lowerArgs)
|
||||
? [Req("python"), Req("node"), Req("npm"), Req("cargo")]
|
||||
: [Req("python"), Req("node"), Req("npm")],
|
||||
"publish-sidecar.ps1" => [Req("python"), Req("dotnet")],
|
||||
"publish-webgateway.ps1" => [Req("python"), Req("dotnet"), Req("node"), Req("npm")],
|
||||
"run-webgateway.ps1" => [Req("python"), Req("dotnet")],
|
||||
"migration-gate.ps1" => [Req("python"), Req("dotnet")],
|
||||
"nuget-export-cache.ps1" => [Req("python"), Req("dotnet")],
|
||||
"nuget-import-cache.ps1" => [Req("python"), Req("dotnet")],
|
||||
"npm-clean.ps1" => [Req("python"), Req("node"), Req("npm")],
|
||||
"publish-output.ps1" => [Req("python"), Req("dotnet"), Req("node"), Req("npm"), Req("cargo")],
|
||||
"sync-output.ps1" => [Req("python")],
|
||||
"dotnet-min.ps1" => [Req("python"), Req("dotnet")],
|
||||
"pip-min.ps1" => [Req("python")],
|
||||
_ => [Req("python")]
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FindScriptArg(IReadOnlyList<string> args)
|
||||
{
|
||||
for (var i = 0; i < args.Count - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], "-File", StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
return args.FirstOrDefault(a => a.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsTauriTarget(IReadOnlyList<string> lowerArgs)
|
||||
{
|
||||
for (var i = 0; i < lowerArgs.Count - 1; i++)
|
||||
{
|
||||
if (lowerArgs[i] is "-target" or "--target" && lowerArgs[i + 1] == "tauri")
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
303
Journal.DevTool/Core/PrereqInstallerService.cs
Normal file
303
Journal.DevTool/Core/PrereqInstallerService.cs
Normal file
@ -0,0 +1,303 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Sdt.Config;
|
||||
using Sdt.Runner;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed class PrereqInstallerService : IPrereqInstaller
|
||||
{
|
||||
public async Task<InstallPlan> GetInstallPlanAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var fromConfig = TryGetPlanFromConfig(tool, config);
|
||||
if (fromConfig is not null)
|
||||
return fromConfig;
|
||||
|
||||
var scriptPath = ScriptLocator.FindHelperScript(projectRoot, "diag.py");
|
||||
if (scriptPath is null)
|
||||
return FallbackPlan(tool);
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = PythonResolver.ResolveExecutable(),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = projectRoot,
|
||||
};
|
||||
psi.ArgumentList.Add(scriptPath);
|
||||
psi.ArgumentList.Add("install-plan");
|
||||
psi.ArgumentList.Add("--tool");
|
||||
psi.ArgumentList.Add(tool);
|
||||
psi.ArgumentList.Add("--json");
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var fallback = FallbackPlan(tool);
|
||||
return fallback with
|
||||
{
|
||||
Summary = $"diag.py install-plan failed for {tool}; using fallback templates. " +
|
||||
$"{(string.IsNullOrWhiteSpace(stderr) ? "No stderr output." : stderr.Trim())}"
|
||||
};
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<InstallPlanJson>(stdout);
|
||||
if (parsed is null)
|
||||
{
|
||||
var fallback = FallbackPlan(tool);
|
||||
return fallback with { Summary = $"diag.py returned invalid JSON for {tool}; using fallback templates." };
|
||||
}
|
||||
|
||||
var commands = parsed.Commands
|
||||
.Select(c => new InstallCommand(c.Command ?? "", c.Args ?? []))
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Command))
|
||||
.ToList();
|
||||
if (!parsed.Supported || commands.Count == 0)
|
||||
{
|
||||
var fallback = FallbackPlan(tool);
|
||||
return fallback with { Summary = $"diag.py returned no usable commands for {tool}; using fallback templates." };
|
||||
}
|
||||
|
||||
return new InstallPlan(
|
||||
parsed.Tool ?? tool,
|
||||
parsed.Supported,
|
||||
parsed.Summary ?? $"Install {tool}",
|
||||
commands);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var fallback = FallbackPlan(tool);
|
||||
return fallback with { Summary = $"diag.py install-plan exception for {tool}; using fallback templates. {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
public Task<RunResult> RunInstallAsync(
|
||||
InstallCommand command,
|
||||
string projectRoot,
|
||||
Action<string, bool> onOutput,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ProcessRunner.RunAsync(command.Command, command.Args, projectRoot, onOutput, cancellationToken: cancellationToken);
|
||||
|
||||
private static InstallPlan FallbackPlan(string tool)
|
||||
{
|
||||
var isWindows = OperatingSystem.IsWindows();
|
||||
var normalized = tool.ToLowerInvariant();
|
||||
if (normalized == "tauri")
|
||||
return BuildTauriFallbackPlan();
|
||||
|
||||
var installCommand = normalized switch
|
||||
{
|
||||
"dotnet" => isWindows
|
||||
? new InstallCommand("winget", ["install", "Microsoft.DotNet.SDK.10"])
|
||||
: new InstallCommand("sh", ["-c", "echo Install dotnet SDK from your distro package manager"]),
|
||||
"node" => isWindows
|
||||
? new InstallCommand("winget", ["install", "OpenJS.NodeJS.LTS"])
|
||||
: new InstallCommand("sh", ["-c", "echo Install nodejs via package manager"]),
|
||||
"npm" => isWindows
|
||||
? new InstallCommand("winget", ["install", "OpenJS.NodeJS.LTS"])
|
||||
: new InstallCommand("sh", ["-c", "echo Install npm via package manager"]),
|
||||
"cargo" => isWindows
|
||||
? new InstallCommand("winget", ["install", "Rustlang.Rustup"])
|
||||
: new InstallCommand("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh"]),
|
||||
"git" => isWindows
|
||||
? new InstallCommand("winget", ["install", "Git.Git"])
|
||||
: new InstallCommand("sh", ["-c", "echo Install git via package manager"]),
|
||||
"docker" => isWindows
|
||||
? new InstallCommand("winget", ["install", "Docker.DockerDesktop"])
|
||||
: new InstallCommand("sh", ["-c", "echo Install docker engine via package manager"]),
|
||||
"python" => isWindows
|
||||
? new InstallCommand("winget", ["install", "Python.Python.3.12"])
|
||||
: new InstallCommand("sh", ["-c", "echo Install python3 via package manager"]),
|
||||
_ => new InstallCommand("sh", ["-c", $"echo No installer template for '{tool}'"]),
|
||||
};
|
||||
|
||||
return new InstallPlan(
|
||||
tool,
|
||||
Supported: true,
|
||||
Summary: $"Fallback install plan for {tool}",
|
||||
Commands: [installCommand]);
|
||||
}
|
||||
|
||||
private static InstallPlan BuildTauriFallbackPlan()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return new InstallPlan(
|
||||
"tauri",
|
||||
Supported: true,
|
||||
Summary: "Fallback tauri plan (Windows): install Node.js, Rust toolchain, and Tauri CLI. Visual Studio C++ build tools/WebView2 may also be required.",
|
||||
Commands:
|
||||
[
|
||||
new InstallCommand("winget", ["install", "OpenJS.NodeJS.LTS"]),
|
||||
new InstallCommand("winget", ["install", "Rustlang.Rustup"]),
|
||||
new InstallCommand("npm", ["install", "-g", "@tauri-apps/cli"]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return new InstallPlan(
|
||||
"tauri",
|
||||
Supported: true,
|
||||
Summary: "Fallback tauri plan (macOS): install Xcode command line tools, Rust toolchain, and Tauri CLI.",
|
||||
Commands:
|
||||
[
|
||||
new InstallCommand("sh", ["-c", "xcode-select --install || true"]),
|
||||
new InstallCommand("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh -s -- -y"]),
|
||||
new InstallCommand("npm", ["install", "-g", "@tauri-apps/cli"]),
|
||||
]);
|
||||
}
|
||||
|
||||
var linuxPlan = BuildLinuxTauriPrereqCommand();
|
||||
return new InstallPlan(
|
||||
"tauri",
|
||||
Supported: true,
|
||||
Summary: $"Fallback tauri plan (Linux): detected package manager `{linuxPlan.PackageManager}` for system deps, then install Rust toolchain and Tauri CLI.",
|
||||
Commands:
|
||||
[
|
||||
new InstallCommand("sh", ["-c", linuxPlan.Command]),
|
||||
new InstallCommand("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh -s -- -y"]),
|
||||
new InstallCommand("npm", ["install", "-g", "@tauri-apps/cli"]),
|
||||
]);
|
||||
}
|
||||
|
||||
private static (string PackageManager, string Command) BuildLinuxTauriPrereqCommand()
|
||||
{
|
||||
if (CommandExists("apt-get"))
|
||||
{
|
||||
return ("apt-get",
|
||||
"sudo apt-get update && sudo apt-get install -y build-essential libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev");
|
||||
}
|
||||
|
||||
if (CommandExists("dnf"))
|
||||
{
|
||||
return ("dnf",
|
||||
"sudo dnf install -y gcc gcc-c++ make webkit2gtk4.1-devel gtk3-devel libappindicator-gtk3 librsvg2-devel");
|
||||
}
|
||||
|
||||
if (CommandExists("pacman"))
|
||||
{
|
||||
return ("pacman",
|
||||
"sudo pacman -S --needed base-devel webkit2gtk gtk3 libappindicator-gtk3 librsvg");
|
||||
}
|
||||
|
||||
if (CommandExists("zypper"))
|
||||
{
|
||||
return ("zypper",
|
||||
"sudo zypper install -y gcc gcc-c++ make webkit2gtk3-devel gtk3-devel libappindicator3-devel librsvg-devel");
|
||||
}
|
||||
|
||||
if (CommandExists("apk"))
|
||||
{
|
||||
return ("apk",
|
||||
"sudo apk add build-base webkit2gtk-dev gtk+3.0-dev libayatana-appindicator-dev librsvg-dev");
|
||||
}
|
||||
|
||||
return ("unknown",
|
||||
"echo Install tauri system dependencies using your distro package manager, then rerun SDT.");
|
||||
}
|
||||
|
||||
private static bool CommandExists(string command)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = OperatingSystem.IsWindows() ? "where" : "which",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add(command);
|
||||
using var process = Process.Start(psi);
|
||||
if (process is null)
|
||||
return false;
|
||||
process.WaitForExit(1500);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static InstallPlan? TryGetPlanFromConfig(string tool, DevToolConfig? config)
|
||||
{
|
||||
var preferred = config?.Tooling?.Tools
|
||||
.FirstOrDefault(t => string.Equals(t.Tool, tool, StringComparison.OrdinalIgnoreCase))
|
||||
?.PreferredInstallCommands;
|
||||
|
||||
if (preferred is null || preferred.Count == 0)
|
||||
return null;
|
||||
|
||||
var commands = new List<InstallCommand>();
|
||||
foreach (var line in preferred)
|
||||
{
|
||||
var parts = SplitShellLike(line);
|
||||
if (parts.Count == 0)
|
||||
continue;
|
||||
|
||||
commands.Add(new InstallCommand(parts[0], parts.Skip(1).ToList()));
|
||||
}
|
||||
|
||||
if (commands.Count == 0)
|
||||
return null;
|
||||
|
||||
return new InstallPlan(
|
||||
tool,
|
||||
Supported: true,
|
||||
Summary: $"Configured install commands for {tool}",
|
||||
Commands: commands);
|
||||
}
|
||||
|
||||
private static List<string> SplitShellLike(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return [];
|
||||
|
||||
var tokens = new List<string>();
|
||||
var matches = Regex.Matches(input, "\"([^\"]*)\"|'([^']*)'|\\S+");
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var value = match.Value.Trim();
|
||||
if (value.Length >= 2 && (
|
||||
(value.StartsWith("\"", StringComparison.Ordinal) && value.EndsWith("\"", StringComparison.Ordinal)) ||
|
||||
(value.StartsWith("'", StringComparison.Ordinal) && value.EndsWith("'", StringComparison.Ordinal))))
|
||||
{
|
||||
value = value[1..^1];
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
tokens.Add(value);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private sealed class InstallPlanJson
|
||||
{
|
||||
public string? Tool { get; init; }
|
||||
public bool Supported { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public List<InstallCommandJson> Commands { get; init; } = [];
|
||||
}
|
||||
|
||||
private sealed class InstallCommandJson
|
||||
{
|
||||
public string? Command { get; init; }
|
||||
public List<string>? Args { get; init; }
|
||||
}
|
||||
}
|
||||
46
Journal.DevTool/Core/PythonResolver.cs
Normal file
46
Journal.DevTool/Core/PythonResolver.cs
Normal file
@ -0,0 +1,46 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
internal static class PythonResolver
|
||||
{
|
||||
public static string ResolveExecutable()
|
||||
{
|
||||
var candidates = OperatingSystem.IsWindows()
|
||||
? new[] { "python", "py" }
|
||||
: new[] { "python3", "python" };
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (CanRun(candidate))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return "python";
|
||||
}
|
||||
|
||||
private static bool CanRun(string exe)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = exe,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--version");
|
||||
|
||||
using var p = new Process { StartInfo = psi };
|
||||
p.Start();
|
||||
p.WaitForExit(2000);
|
||||
return p.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
Journal.DevTool/Core/RequirementResolver.cs
Normal file
67
Journal.DevTool/Core/RequirementResolver.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using Sdt.Config;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed class RequirementResolver : IRequirementResolver
|
||||
{
|
||||
public List<ToolRequirement> Resolve(WorkflowStep step)
|
||||
{
|
||||
if (step.Requires.Count > 0)
|
||||
return step.Requires.ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(step.Action))
|
||||
return InferActionRequirements(step.Action);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(step.Command))
|
||||
return [];
|
||||
|
||||
return InferCommandRequirements(step.Command, step.Args);
|
||||
}
|
||||
|
||||
public List<ToolRequirement> Resolve(BuildTarget target)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target.Command))
|
||||
return [];
|
||||
|
||||
return InferCommandRequirements(target.Command, target.Args);
|
||||
}
|
||||
|
||||
private static List<ToolRequirement> InferActionRequirements(string action)
|
||||
{
|
||||
return action.ToLowerInvariant() switch
|
||||
{
|
||||
"dotnet-restore" or "dotnet-build" or "dotnet-test" or "dotnet-publish" => [Req("dotnet")],
|
||||
"npm-install" or "npm-ci" or "npm-build" or "npm-test" or "npm-audit" => [Req("node"), Req("npm")],
|
||||
"python-venv-create" or "python-pip-install" or "python-pip-sync" or "python-pytest" => [Req("python")],
|
||||
"cargo-build" or "cargo-test" => [Req("cargo")],
|
||||
"tauri-build" => [Req("cargo"), Req("node"), Req("npm")],
|
||||
"git-status" or "git-fetch" or "git-pull" or "git-clean" => [Req("git")],
|
||||
"docker-build" or "docker-compose-up" or "docker-compose-down" => [Req("docker")],
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ToolRequirement> InferCommandRequirements(string command, IReadOnlyList<string> args)
|
||||
{
|
||||
return command.ToLowerInvariant() switch
|
||||
{
|
||||
"dotnet" => [Req("dotnet")],
|
||||
"npm" => [Req("node"), Req("npm")],
|
||||
"pnpm" => [Req("node"), Req("pnpm")],
|
||||
"yarn" => [Req("node"), Req("yarn")],
|
||||
"python" or "py" => [Req("python")],
|
||||
"cargo" => [Req("cargo")],
|
||||
"tauri" => [Req("cargo"), Req("node"), Req("npm")],
|
||||
"git" => [Req("git")],
|
||||
"docker" => [Req("docker")],
|
||||
"pwsh" or "powershell" => LegacyScriptRequirementResolver.InferForPowerShellArgs(args),
|
||||
_ => [],
|
||||
};
|
||||
}
|
||||
|
||||
private static ToolRequirement Req(string tool) => new()
|
||||
{
|
||||
Tool = tool,
|
||||
InstallPolicy = InstallPolicy.Prompt,
|
||||
};
|
||||
}
|
||||
65
Journal.DevTool/Core/RunEventJsonlRecorder.cs
Normal file
65
Journal.DevTool/Core/RunEventJsonlRecorder.cs
Normal file
@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed class RunEventJsonlRecorder : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
static RunEventJsonlRecorder()
|
||||
{
|
||||
JsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
}
|
||||
|
||||
private readonly StreamWriter _writer;
|
||||
private readonly object _gate = new();
|
||||
private bool _disposed;
|
||||
|
||||
public string FilePath { get; }
|
||||
|
||||
private RunEventJsonlRecorder(string filePath, StreamWriter writer)
|
||||
{
|
||||
FilePath = filePath;
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public static RunEventJsonlRecorder Create(string projectRoot, string category)
|
||||
{
|
||||
var root = Path.Combine(projectRoot, ".sdt", "events");
|
||||
Directory.CreateDirectory(root);
|
||||
var fileName = $"{category}-{DateTimeOffset.Now:yyyyMMdd-HHmmss}.jsonl";
|
||||
var path = Path.Combine(root, fileName);
|
||||
var writer = new StreamWriter(new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
|
||||
{
|
||||
AutoFlush = true
|
||||
};
|
||||
return new RunEventJsonlRecorder(path, writer);
|
||||
}
|
||||
|
||||
public void Write(RunEvent evt)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
var line = JsonSerializer.Serialize(evt, JsonOptions);
|
||||
_writer.WriteLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
_writer.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
99
Journal.DevTool/Core/RunEventLogReader.cs
Normal file
99
Journal.DevTool/Core/RunEventLogReader.cs
Normal file
@ -0,0 +1,99 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed record RunEventLogFile(
|
||||
string Path,
|
||||
string Name,
|
||||
DateTimeOffset LastWriteTime,
|
||||
long SizeBytes);
|
||||
|
||||
public sealed class RunEventLogReader
|
||||
{
|
||||
public IReadOnlyList<RunEventLogFile> ListEventFiles(string projectRoot)
|
||||
{
|
||||
var eventsRoot = Path.Combine(projectRoot, ".sdt", "events");
|
||||
if (!Directory.Exists(eventsRoot))
|
||||
return [];
|
||||
|
||||
return Directory.EnumerateFiles(eventsRoot, "*.jsonl", SearchOption.TopDirectoryOnly)
|
||||
.Select(path =>
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
return new RunEventLogFile(
|
||||
Path: path,
|
||||
Name: info.Name,
|
||||
LastWriteTime: info.LastWriteTime,
|
||||
SizeBytes: info.Length);
|
||||
})
|
||||
.OrderByDescending(f => f.LastWriteTime)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<RunEvent> ReadEvents(string filePath)
|
||||
{
|
||||
var results = new List<RunEvent>();
|
||||
if (!File.Exists(filePath))
|
||||
return results;
|
||||
|
||||
foreach (var line in File.ReadLines(filePath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
if (TryParseLine(line, out var evt))
|
||||
results.Add(evt!);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
internal static bool TryParseLine(string jsonLine, out RunEvent? evt)
|
||||
{
|
||||
evt = null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(jsonLine);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var category = root.TryGetProperty("category", out var c) ? c.GetString() : null;
|
||||
var typeRaw = root.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
var message = root.TryGetProperty("message", out var m) ? m.GetString() : null;
|
||||
var workflowId = root.TryGetProperty("workflowId", out var wf) ? wf.GetString() : null;
|
||||
var stepId = root.TryGetProperty("stepId", out var st) ? st.GetString() : null;
|
||||
var tool = root.TryGetProperty("tool", out var tl) ? tl.GetString() : null;
|
||||
var success = root.TryGetProperty("success", out var s) && s.ValueKind != JsonValueKind.Null ? s.GetBoolean() : (bool?)null;
|
||||
var exitCode = root.TryGetProperty("exitCode", out var ec) && ec.ValueKind != JsonValueKind.Null ? ec.GetInt32() : (int?)null;
|
||||
DateTimeOffset? occurred = null;
|
||||
if (root.TryGetProperty("occurredAt", out var ts) && ts.ValueKind == JsonValueKind.String &&
|
||||
DateTimeOffset.TryParse(ts.GetString(), out var parsed))
|
||||
{
|
||||
occurred = parsed;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(category) ||
|
||||
string.IsNullOrWhiteSpace(typeRaw) ||
|
||||
string.IsNullOrWhiteSpace(message) ||
|
||||
!Enum.TryParse<RunEventType>(typeRaw, ignoreCase: true, out var type))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
evt = new RunEvent(
|
||||
Category: category!,
|
||||
Type: type,
|
||||
Message: message!,
|
||||
WorkflowId: workflowId,
|
||||
StepId: stepId,
|
||||
Tool: tool,
|
||||
Success: success,
|
||||
ExitCode: exitCode,
|
||||
Timestamp: occurred);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
Journal.DevTool/Core/RunEvents.cs
Normal file
34
Journal.DevTool/Core/RunEvents.cs
Normal file
@ -0,0 +1,34 @@
|
||||
namespace Sdt.Core;
|
||||
|
||||
public enum RunEventType
|
||||
{
|
||||
WorkflowStarted,
|
||||
WorkflowPlanned,
|
||||
WorkflowStepStarted,
|
||||
WorkflowStepCompleted,
|
||||
ProbeChecked,
|
||||
ProbeFailed,
|
||||
InstallPlanPrepared,
|
||||
InstallDeclined,
|
||||
InstallCommandStarted,
|
||||
InstallCommandCompleted,
|
||||
WorkflowCompleted,
|
||||
DebugStarted,
|
||||
DebugCommandStarted,
|
||||
DebugCommandCompleted,
|
||||
DebugCompleted,
|
||||
}
|
||||
|
||||
public sealed record RunEvent(
|
||||
string Category,
|
||||
RunEventType Type,
|
||||
string Message,
|
||||
string? WorkflowId = null,
|
||||
string? StepId = null,
|
||||
string? Tool = null,
|
||||
bool? Success = null,
|
||||
int? ExitCode = null,
|
||||
DateTimeOffset? Timestamp = null)
|
||||
{
|
||||
public DateTimeOffset OccurredAt { get; init; } = Timestamp ?? DateTimeOffset.Now;
|
||||
}
|
||||
19
Journal.DevTool/Core/ScriptLocator.cs
Normal file
19
Journal.DevTool/Core/ScriptLocator.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace Sdt.Core;
|
||||
|
||||
internal static class ScriptLocator
|
||||
{
|
||||
public static string? FindHelperScript(string projectRoot, string scriptFileName)
|
||||
{
|
||||
// Packaged location: alongside executable in ./scripts
|
||||
var bundled = Path.Combine(AppContext.BaseDirectory, "scripts", scriptFileName);
|
||||
if (File.Exists(bundled))
|
||||
return bundled;
|
||||
|
||||
// Source/project location fallback
|
||||
var project = Path.Combine(projectRoot, "scripts", scriptFileName);
|
||||
if (File.Exists(project))
|
||||
return project;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
122
Journal.DevTool/Core/ToolProbeService.cs
Normal file
122
Journal.DevTool/Core/ToolProbeService.cs
Normal file
@ -0,0 +1,122 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Sdt.Config;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed class ToolProbeService : IToolProbe
|
||||
{
|
||||
public async Task<ProbeResult> ProbeAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var direct = await ProbeDirectAsync(tool, config, cancellationToken).ConfigureAwait(false);
|
||||
if (direct.IsAvailable)
|
||||
return direct;
|
||||
|
||||
var scriptPath = ScriptLocator.FindHelperScript(projectRoot, "diag.py");
|
||||
if (scriptPath is null)
|
||||
return direct;
|
||||
|
||||
if (!(await ProbeDirectAsync("python", config, cancellationToken).ConfigureAwait(false)).IsAvailable)
|
||||
return direct;
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = PythonResolver.ResolveExecutable(),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = projectRoot,
|
||||
};
|
||||
psi.ArgumentList.Add(scriptPath);
|
||||
psi.ArgumentList.Add("probe");
|
||||
psi.ArgumentList.Add("--tool");
|
||||
psi.ArgumentList.Add(tool);
|
||||
psi.ArgumentList.Add("--json");
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (process.ExitCode != 0)
|
||||
return new ProbeResult(tool, false, Details: stderr.Trim());
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<DiagProbeJson>(stdout);
|
||||
if (parsed is null)
|
||||
return new ProbeResult(tool, false, Details: "diag.py returned invalid JSON");
|
||||
|
||||
return new ProbeResult(parsed.Tool ?? tool, parsed.Available, parsed.Version, parsed.Details);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ProbeResult(tool, false, Details: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ProbeResult> ProbeDirectAsync(string tool, DevToolConfig? config, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = tool.ToLowerInvariant() switch
|
||||
{
|
||||
"python" => PythonResolver.ResolveExecutable(),
|
||||
"dotnet" => "dotnet",
|
||||
"node" => "node",
|
||||
"npm" => "npm",
|
||||
"cargo" => "cargo",
|
||||
"tauri" => "tauri",
|
||||
"git" => "git",
|
||||
"docker" => "docker",
|
||||
_ => tool,
|
||||
};
|
||||
var resolution = CommandResolver.ResolveWithTrace(command, config, tool);
|
||||
command = resolution.Resolved;
|
||||
|
||||
var versionArg = command is "python" ? "--version" : "--version";
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = command,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add(versionArg);
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var failDetails = string.IsNullOrWhiteSpace(stderr) ? stdout.Trim() : stderr.Trim();
|
||||
var trace = $"{resolution.Source}: {resolution.Resolved}";
|
||||
return new ProbeResult(tool, false, Details: string.IsNullOrWhiteSpace(failDetails) ? trace : $"{trace} | {failDetails}");
|
||||
}
|
||||
|
||||
var version = string.IsNullOrWhiteSpace(stdout) ? stderr.Trim() : stdout.Trim();
|
||||
return new ProbeResult(tool, true, Version: version, Details: $"{resolution.Source}: {resolution.Resolved}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ProbeResult(tool, false, Details: $"{resolution.Source}: {resolution.Resolved} | {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DiagProbeJson
|
||||
{
|
||||
public string? Tool { get; init; }
|
||||
public bool Available { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
}
|
||||
273
Journal.DevTool/Core/WorkflowExecutor.cs
Normal file
273
Journal.DevTool/Core/WorkflowExecutor.cs
Normal file
@ -0,0 +1,273 @@
|
||||
using Sdt.Config;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed class WorkflowExecutor(
|
||||
IWorkflowPlanner planner,
|
||||
IToolProbe toolProbe,
|
||||
IPrereqInstaller installer,
|
||||
IActionRunner actionRunner,
|
||||
IRequirementResolver requirementResolver)
|
||||
{
|
||||
private readonly IWorkflowPlanner _planner = planner;
|
||||
private readonly IToolProbe _toolProbe = toolProbe;
|
||||
private readonly IPrereqInstaller _installer = installer;
|
||||
private readonly IActionRunner _actionRunner = actionRunner;
|
||||
private readonly IRequirementResolver _requirementResolver = requirementResolver;
|
||||
|
||||
public async Task<WorkflowExecutionResult> ExecuteAsync(
|
||||
WorkflowDefinition rootWorkflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinition> allWorkflows,
|
||||
DevToolConfig config,
|
||||
string projectRoot,
|
||||
Func<string, InstallPlan, Task<bool>> confirmInstallAsync,
|
||||
Action<string, bool> onOutput,
|
||||
Action<RunEvent>? onEvent = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<WorkflowStepResult>();
|
||||
var plan = _planner.ResolvePlan(rootWorkflow, allWorkflows);
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowStarted,
|
||||
Message: $"Workflow '{rootWorkflow.Id}' started.",
|
||||
WorkflowId: rootWorkflow.Id));
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowPlanned,
|
||||
Message: $"Execution plan contains {plan.Count} workflow(s).",
|
||||
WorkflowId: rootWorkflow.Id));
|
||||
|
||||
if (plan.Count == 0)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowCompleted,
|
||||
Message: "No executable workflow steps were found.",
|
||||
WorkflowId: rootWorkflow.Id,
|
||||
Success: false));
|
||||
return new WorkflowExecutionResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.ValidationFailed,
|
||||
Message: "This workflow has no executable steps.",
|
||||
Steps: results);
|
||||
}
|
||||
|
||||
foreach (var workflow in plan)
|
||||
{
|
||||
foreach (var step in workflow.Steps)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowStepStarted,
|
||||
Message: $"Step '{step.Label}' started.",
|
||||
WorkflowId: workflow.Id,
|
||||
StepId: step.Id));
|
||||
|
||||
var requires = _requirementResolver.Resolve(step);
|
||||
foreach (var req in requires)
|
||||
{
|
||||
var probe = await _toolProbe.ProbeAsync(req.Tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.ProbeChecked,
|
||||
Message: probe.IsAvailable
|
||||
? $"Tool '{req.Tool}' is available."
|
||||
: $"Tool '{req.Tool}' is missing.",
|
||||
WorkflowId: workflow.Id,
|
||||
StepId: step.Id,
|
||||
Tool: req.Tool,
|
||||
Success: probe.IsAvailable));
|
||||
if (probe.IsAvailable)
|
||||
continue;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(probe.Details))
|
||||
{
|
||||
onOutput($"Probe detail [{req.Tool}]: {probe.Details}", false);
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.ProbeFailed,
|
||||
Message: probe.Details,
|
||||
WorkflowId: workflow.Id,
|
||||
StepId: step.Id,
|
||||
Tool: req.Tool,
|
||||
Success: false));
|
||||
}
|
||||
|
||||
if (req.InstallPolicy == InstallPolicy.Never)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowCompleted,
|
||||
Message: $"Missing prerequisite '{req.Tool}'.",
|
||||
WorkflowId: rootWorkflow.Id,
|
||||
Tool: req.Tool,
|
||||
Success: false));
|
||||
return new WorkflowExecutionResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.MissingPrereq,
|
||||
Message: $"Missing prerequisite '{req.Tool}' for step '{step.Label}'.",
|
||||
Steps: results);
|
||||
}
|
||||
|
||||
var installPlan = await _installer.GetInstallPlanAsync(
|
||||
req.Tool,
|
||||
projectRoot,
|
||||
config,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.InstallPlanPrepared,
|
||||
Message: installPlan.Summary,
|
||||
WorkflowId: workflow.Id,
|
||||
StepId: step.Id,
|
||||
Tool: req.Tool,
|
||||
Success: installPlan.Supported));
|
||||
if (!installPlan.Supported || installPlan.Commands.Count == 0)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowCompleted,
|
||||
Message: $"No installer plan available for '{req.Tool}'.",
|
||||
WorkflowId: rootWorkflow.Id,
|
||||
Tool: req.Tool,
|
||||
Success: false));
|
||||
return new WorkflowExecutionResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.MissingPrereq,
|
||||
Message: $"Missing prerequisite '{req.Tool}' and no installer plan is available.",
|
||||
Steps: results);
|
||||
}
|
||||
|
||||
var approved = req.InstallPolicy == InstallPolicy.Auto
|
||||
? true
|
||||
: await confirmInstallAsync(req.Tool, installPlan).ConfigureAwait(false);
|
||||
|
||||
if (!approved)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.InstallDeclined,
|
||||
Message: $"Install declined for '{req.Tool}'.",
|
||||
WorkflowId: workflow.Id,
|
||||
StepId: step.Id,
|
||||
Tool: req.Tool,
|
||||
Success: false));
|
||||
return new WorkflowExecutionResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.UserDeclined,
|
||||
Message: $"Install declined for missing prerequisite '{req.Tool}'.",
|
||||
Steps: results);
|
||||
}
|
||||
|
||||
foreach (var installCommand in installPlan.Commands)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.InstallCommandStarted,
|
||||
Message: $"{installCommand.Command} {string.Join(" ", installCommand.Args)}",
|
||||
WorkflowId: workflow.Id,
|
||||
StepId: step.Id,
|
||||
Tool: req.Tool));
|
||||
|
||||
var installResult = await _installer.RunInstallAsync(
|
||||
installCommand,
|
||||
projectRoot,
|
||||
onOutput,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.InstallCommandCompleted,
|
||||
Message: $"Install command exited {installResult.ExitCode}.",
|
||||
WorkflowId: workflow.Id,
|
||||
StepId: step.Id,
|
||||
Tool: req.Tool,
|
||||
Success: installResult.Success,
|
||||
ExitCode: installResult.ExitCode));
|
||||
|
||||
if (!installResult.Success)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowCompleted,
|
||||
Message: $"Install failed for '{req.Tool}'.",
|
||||
WorkflowId: rootWorkflow.Id,
|
||||
Tool: req.Tool,
|
||||
Success: false));
|
||||
return new WorkflowExecutionResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.InstallFailed,
|
||||
Message: $"Failed to install prerequisite '{req.Tool}'.",
|
||||
Steps: results);
|
||||
}
|
||||
}
|
||||
|
||||
var verifyProbe = await _toolProbe.ProbeAsync(req.Tool, projectRoot, config, cancellationToken).ConfigureAwait(false);
|
||||
if (!verifyProbe.IsAvailable)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowCompleted,
|
||||
Message: $"Tool '{req.Tool}' still missing after install.",
|
||||
WorkflowId: rootWorkflow.Id,
|
||||
Tool: req.Tool,
|
||||
Success: false));
|
||||
return new WorkflowExecutionResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.InstallFailed,
|
||||
Message: $"Prerequisite '{req.Tool}' is still missing after install attempt.",
|
||||
Steps: results);
|
||||
}
|
||||
}
|
||||
|
||||
var runResult = await _actionRunner.RunStepAsync(
|
||||
step,
|
||||
projectRoot,
|
||||
onOutput,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results.Add(new WorkflowStepResult(
|
||||
workflow.Id,
|
||||
step.Id,
|
||||
string.IsNullOrWhiteSpace(step.Label) ? step.Id : step.Label,
|
||||
runResult));
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowStepCompleted,
|
||||
Message: $"Step '{step.Label}' exited {runResult.ExitCode}.",
|
||||
WorkflowId: workflow.Id,
|
||||
StepId: step.Id,
|
||||
Success: runResult.Success,
|
||||
ExitCode: runResult.ExitCode));
|
||||
|
||||
if (!runResult.Success)
|
||||
{
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowCompleted,
|
||||
Message: $"Step '{step.Label}' failed.",
|
||||
WorkflowId: rootWorkflow.Id,
|
||||
Success: false,
|
||||
ExitCode: runResult.ExitCode));
|
||||
return new WorkflowExecutionResult(
|
||||
Success: false,
|
||||
StopReason: ExecutionStopReason.CommandFailed,
|
||||
Message: $"Step '{step.Label}' failed with exit code {runResult.ExitCode}.",
|
||||
Steps: results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onEvent?.Invoke(new RunEvent(
|
||||
Category: "workflow",
|
||||
Type: RunEventType.WorkflowCompleted,
|
||||
Message: "Workflow completed successfully.",
|
||||
WorkflowId: rootWorkflow.Id,
|
||||
Success: true));
|
||||
return new WorkflowExecutionResult(
|
||||
Success: true,
|
||||
StopReason: null,
|
||||
Message: "Workflow completed successfully.",
|
||||
Steps: results);
|
||||
}
|
||||
}
|
||||
35
Journal.DevTool/Core/WorkflowPlanner.cs
Normal file
35
Journal.DevTool/Core/WorkflowPlanner.cs
Normal file
@ -0,0 +1,35 @@
|
||||
using Sdt.Config;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed class WorkflowPlanner : IWorkflowPlanner
|
||||
{
|
||||
public List<WorkflowDefinition> ResolvePlan(
|
||||
WorkflowDefinition workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinition> allWorkflows)
|
||||
{
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var plan = new List<WorkflowDefinition>();
|
||||
Visit(workflow, allWorkflows, visited, plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static void Visit(
|
||||
WorkflowDefinition workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinition> allWorkflows,
|
||||
HashSet<string> visited,
|
||||
List<WorkflowDefinition> plan)
|
||||
{
|
||||
if (!visited.Add(workflow.Id))
|
||||
return;
|
||||
|
||||
foreach (var depId in workflow.DependsOn)
|
||||
{
|
||||
if (allWorkflows.TryGetValue(depId, out var dep))
|
||||
Visit(dep, allWorkflows, visited, plan);
|
||||
}
|
||||
|
||||
if (workflow.Steps.Count > 0)
|
||||
plan.Add(workflow);
|
||||
}
|
||||
}
|
||||
37
Journal.DevTool/DevTool.csproj
Normal file
37
Journal.DevTool/DevTool.csproj
Normal file
@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>sdt</AssemblyName>
|
||||
<RootNamespace>Sdt</RootNamespace>
|
||||
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="tests\**\*.cs" />
|
||||
<None Include="tests\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="scripts\*.py">
|
||||
<Link>scripts\%(Filename)%(Extension)</Link>
|
||||
<TargetPath>scripts\%(Filename)%(Extension)</TargetPath>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
<Content Include="scripts\dev-shell.ps1;scripts\dev-shell.sh;scripts\dev-shell.cmd;scripts\_pwsh-python-shim.ps1">
|
||||
<Link>scripts\%(Filename)%(Extension)</Link>
|
||||
<TargetPath>scripts\%(Filename)%(Extension)</TargetPath>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -2,30 +2,72 @@ using Sdt.Config;
|
||||
using Sdt.Tui;
|
||||
using Spectre.Console;
|
||||
|
||||
// ── Workspace + project discovery ────────────────────────────────────────────
|
||||
|
||||
var workspaceResult = WorkspaceLoader.FindAndLoad();
|
||||
var projectResult = ConfigLoader.FindAndLoad();
|
||||
|
||||
if (projectResult is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No devtool.json found[/] in current directory or any parent.");
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Create a devtool.json in your project root to get started."));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Main run loop (handles workspace project switching) ───────────────────────
|
||||
|
||||
var (currentConfig, currentRoot) = projectResult.Value;
|
||||
var (workspace, workspaceRoot) = workspaceResult.HasValue
|
||||
? (workspaceResult.Value.Config, workspaceResult.Value.WorkspaceRoot)
|
||||
: ((WorkspaceConfig?)null, (string?)null);
|
||||
|
||||
try
|
||||
{
|
||||
// ── Workspace + project discovery ────────────────────────────────────────
|
||||
|
||||
var workspaceResult = WorkspaceLoader.FindAndLoad();
|
||||
var projectResult = ConfigLoader.FindAndLoad();
|
||||
var cliArgs = Environment.GetCommandLineArgs().Skip(1).ToArray();
|
||||
var forceInit = cliArgs.Any(a => string.Equals(a, "init", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(a, "--init", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (forceInit)
|
||||
{
|
||||
var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory());
|
||||
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Initialized config at {path}"));
|
||||
projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot);
|
||||
}
|
||||
|
||||
if (projectResult is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No devtool.json found[/] in current directory or any parent.");
|
||||
var bootstrap = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Generate a default devtool.json for this project now?[/]",
|
||||
defaultValue: true);
|
||||
if (!bootstrap)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Create a devtool.json in your project root to get started."));
|
||||
return 1;
|
||||
}
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory());
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Detected project root: {scan.ProjectRoot}"));
|
||||
if (scan.ToolFamilies.Count > 0)
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Detected tool families: {string.Join(", ", scan.ToolFamilies)}"));
|
||||
|
||||
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
var preview = ConfigBootstrapper.ToJson(generated);
|
||||
AnsiConsole.Write(new Panel(Markup.Escape(preview)).Header("Generated devtool.json preview").BorderStyle(Theme.DimStyle));
|
||||
|
||||
var confirmWrite = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Write generated devtool.json to {scan.ProjectRoot}?[/]",
|
||||
defaultValue: true);
|
||||
if (!confirmWrite)
|
||||
return 1;
|
||||
|
||||
var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Created {path}"));
|
||||
projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot);
|
||||
if (projectResult is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail("Generated config could not be reloaded."));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main run loop (handles workspace project switching) ────────────────
|
||||
|
||||
var currentLoaded = projectResult;
|
||||
var (workspace, workspaceRoot) = workspaceResult.HasValue
|
||||
? (workspaceResult.Value.Config, workspaceResult.Value.WorkspaceRoot)
|
||||
: ((WorkspaceConfig?)null, (string?)null);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var app = new App(currentConfig, currentRoot, workspace, workspaceRoot);
|
||||
var app = new App(currentLoaded.Config, currentLoaded.ProjectRoot, currentLoaded.Warnings, workspace, workspaceRoot);
|
||||
var result = await app.RunAsync();
|
||||
|
||||
if (result.Reason == AppExitReason.Quit)
|
||||
@ -34,7 +76,18 @@ try
|
||||
// User switched projects — reload config from new root
|
||||
if (result.Reason == AppExitReason.SwitchProject && result.NewProjectRoot is not null)
|
||||
{
|
||||
var loaded = ConfigLoader.FindAndLoad(result.NewProjectRoot);
|
||||
LoadedProjectConfig? loaded;
|
||||
try
|
||||
{
|
||||
loaded = ConfigLoader.FindAndLoad(result.NewProjectRoot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail(ex.Message));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
continue;
|
||||
}
|
||||
if (loaded is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail($"No devtool.json found at: {result.NewProjectRoot}"));
|
||||
@ -43,7 +96,7 @@ try
|
||||
continue; // go back to current app
|
||||
}
|
||||
|
||||
(currentConfig, currentRoot) = loaded.Value;
|
||||
currentLoaded = loaded;
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +104,40 @@ try
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail($"Fatal: {ex.Message}"));
|
||||
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
|
||||
var message = ex.Message;
|
||||
var isExpectedMigrationError =
|
||||
ex is InvalidOperationException &&
|
||||
message.Contains("Legacy targets-only config detected", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
AnsiConsole.MarkupLine(Theme.Fail($"Fatal: {message}"));
|
||||
if (isExpectedMigrationError)
|
||||
{
|
||||
var configPath = ConfigLoader.FindConfigPath();
|
||||
if (!string.IsNullOrWhiteSpace(configPath))
|
||||
{
|
||||
var migrate = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Apply automatic migration now (creates backup + converts targets -> workflows)?[/]",
|
||||
defaultValue: true);
|
||||
if (migrate)
|
||||
{
|
||||
var result = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
|
||||
if (result.Success)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Ok("Migration applied successfully."));
|
||||
if (!string.IsNullOrWhiteSpace(result.BackupPath))
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Backup: {result.BackupPath}"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Run sdt.exe again in strict mode."));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail($"Migration failed: {result.Message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExpectedMigrationError)
|
||||
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
@ -1,234 +1,200 @@
|
||||
# SDT — Stan's Dev Tools
|
||||
# SDT (Stan's Dev Tools)
|
||||
|
||||
> **Status: v0.1 — active development**
|
||||
> Phosphor-green TUI build orchestrator. Currently embedded in Project Journal; long-term goal is a standalone universal dev tool.
|
||||
Cross-platform terminal orchestrator for project workflows, toolchain checks, and prerequisite gating.
|
||||
|
||||
---
|
||||
## Current State
|
||||
|
||||
## What It Is
|
||||
- Standalone `.NET` TUI app (`net10.0`)
|
||||
- Workflow-first config model in `devtool.json`
|
||||
- Strict-by-default legacy migration (`targets`-only configs fail unless compat mode is enabled)
|
||||
- Python-first diagnostics/build script layer under `scripts/`
|
||||
- Fail-fast execution with install prompt gating for missing prerequisites
|
||||
- Debug profiles with attach metadata and diagnostics bundle generation
|
||||
- Workspace-first project switching with support for external project paths
|
||||
- Workspace-level defaults layering via `sdt-defaults.json` (ancestor defaults merged, project config wins)
|
||||
- Project status tracking is maintained in `ROADMAP.md`
|
||||
- Core run-event stream (`RunEvent`) shared by workflow + debug execution (TUI consumes it; GUI-ready)
|
||||
- Run events are persisted to JSONL at `.sdt/events/` for external tooling/GUI consumers
|
||||
- TUI includes `SYSTEM -> View run events` to inspect persisted JSONL event logs
|
||||
- `SYSTEM -> Run config doctor` can apply common autofixes (missing working dirs, legacy migration)
|
||||
|
||||
SDT is a terminal UI (TUI) application for managing builds, toolchains, and multi-project workspaces. It reads a `devtool.json` from your project root and presents a menu-driven interface so you never have to remember which script to run, in what order, with what flags.
|
||||
|
||||
```
|
||||
____ ____ _____
|
||||
/ ___|| _ \|_ _|
|
||||
\___ \| | | | | |
|
||||
___) | |_| | | |
|
||||
|____/|____/ |_|
|
||||
─────────── Project Journal v0.1.0 ─────────────
|
||||
root: E:\stansshit\csharp\journal-master\journal
|
||||
|
||||
What would you like to do?
|
||||
BUILD
|
||||
> Publish Sidecar Build Journal.Sidecar as self-contained exe
|
||||
Build Web UI Build SvelteKit bundle
|
||||
Publish WebGateway Publish ASP.NET host with embedded web UI
|
||||
Build Tauri Desktop App Build desktop exe (no installer)
|
||||
Full Release Build ✦ Sidecar → Web → WebGateway → Tauri, in order
|
||||
...
|
||||
SYSTEM
|
||||
⬡ Toolchain management python / node
|
||||
⚙ Edit environment variables
|
||||
✗ Quit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current Features (v0.1)
|
||||
|
||||
### Build Target Runner
|
||||
- Menu driven by `devtool.json` — add/remove targets without touching SDT code
|
||||
- **Dependency resolution**: targets declare `dependsOn`; SDT topologically sorts them and runs each step exactly once. Select `webgateway` and `web` runs first automatically. Select `all` and everything runs in the right order.
|
||||
- **Live output streaming**: stdout in phosphor green, stderr in amber — you see the build as it happens
|
||||
- Each step reports exit code and elapsed time
|
||||
|
||||
### Environment Variable Editor
|
||||
- All configurable env vars declared in `devtool.json`
|
||||
- Dropdown selection for known options (e.g. `none`/`python-sidecar` for AI provider)
|
||||
- Free-text input for path overrides
|
||||
- Changes apply to SDT's process environment for the current session
|
||||
|
||||
### Toolchain Management
|
||||
Python:
|
||||
- Detect system Python and venv status in one health-check table
|
||||
- Create / recreate venv (`python -m venv`)
|
||||
- Install requirements profile — select cpu / gpu / nlp, handles `--extra-index-url` automatically
|
||||
- Upgrade pip
|
||||
- Cross-platform venv path resolution (`Scripts/` on Windows, `bin/` on Linux/Mac)
|
||||
|
||||
Node / npm:
|
||||
- Detect Node.js and npm versions
|
||||
- Check `node_modules` status
|
||||
- Run `npm install` (or `pnpm`/`yarn` — configurable per project)
|
||||
|
||||
### Multi-Project Workspace Switcher
|
||||
- Place `sdt-workspace.json` in any parent directory listing your projects
|
||||
- SDT detects it automatically on startup
|
||||
- **Switch Project** appears in SYSTEM menu when workspace is loaded with more than one project
|
||||
- Selecting a different project hot-reloads config in-process — no restart needed
|
||||
- Project table shows current (`►`), path, and whether `devtool.json` exists
|
||||
|
||||
---
|
||||
|
||||
## Running SDT
|
||||
|
||||
From the project root (where `devtool.json` lives):
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
dotnet run --project Journal.DevTool/Journal.DevTool.csproj
|
||||
dotnet run --project DevTool.csproj
|
||||
```
|
||||
|
||||
Or via `just`:
|
||||
Run from any subdirectory inside a project; SDT walks up to find `devtool.json`.
|
||||
|
||||
If `devtool.json` is missing, SDT now offers to scan the repo and generate a default config.
|
||||
|
||||
Explicit bootstrap command:
|
||||
|
||||
```powershell
|
||||
just sdt
|
||||
dotnet run --project DevTool.csproj -- init
|
||||
```
|
||||
|
||||
SDT walks up the directory tree from wherever you launch it to find `devtool.json`. You don't need to be in any specific directory.
|
||||
Bootstrap detects common stacks (`dotnet`, `npm/node`, `python`, `cargo/tauri`, `git`, `docker`) and generates:
|
||||
|
||||
---
|
||||
- default workflows
|
||||
- toolchain/tooling defaults
|
||||
- debug profiles + diagnostics defaults
|
||||
|
||||
## Config Files
|
||||
## Config Model
|
||||
|
||||
### `devtool.json` (per project)
|
||||
SDT supports both:
|
||||
|
||||
- `workflows` (preferred)
|
||||
- `targets` (legacy; compat mode only)
|
||||
|
||||
### Legacy Migration Mode (v1.2)
|
||||
|
||||
- Default: strict mode
|
||||
- Behavior: `targets`-only config fails early with migration instructions
|
||||
- Preview file: SDT writes `devtool.generated.workflows.json` for migration help
|
||||
- Temporary rollback: set `SDT_LEGACY_MODE=compat`
|
||||
|
||||
Permanent fix (recommended):
|
||||
|
||||
1. Open `devtool.generated.workflows.json`
|
||||
2. Copy its `workflows` into `devtool.json`
|
||||
3. Remove or empty legacy `targets`
|
||||
4. Run `sdt.exe` again in strict mode
|
||||
|
||||
### Workflow shape (preferred)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Project",
|
||||
"version": "1.0.0",
|
||||
"toolchains": {
|
||||
"python": {
|
||||
"executable": "python3",
|
||||
"venvDir": ".venv",
|
||||
"profiles": [
|
||||
{ "id": "default", "label": "Default", "requirementsFile": "requirements.txt" }
|
||||
]
|
||||
},
|
||||
"node": { "packageManager": "npm", "workingDir": "frontend" }
|
||||
},
|
||||
"targets": [
|
||||
"id": "build",
|
||||
"label": "Build",
|
||||
"description": "Build project",
|
||||
"group": "Build",
|
||||
"dependsOn": [],
|
||||
"steps": [
|
||||
{
|
||||
"id": "build",
|
||||
"label": "Build",
|
||||
"description": "Build everything",
|
||||
"group": "Build",
|
||||
"command": "dotnet",
|
||||
"args": ["build"],
|
||||
"id": "dotnet-build",
|
||||
"label": "dotnet build",
|
||||
"action": "dotnet-build",
|
||||
"actionArgs": [],
|
||||
"workingDir": ".",
|
||||
"dependsOn": []
|
||||
}
|
||||
],
|
||||
"env": [
|
||||
{
|
||||
"key": "MY_VAR",
|
||||
"description": "Controls something",
|
||||
"default": "value",
|
||||
"options": ["value", "other"]
|
||||
"requires": [
|
||||
{ "tool": "dotnet", "installPolicy": "Prompt" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Target fields:**
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `id` | Unique identifier, referenced by `dependsOn` |
|
||||
| `label` | Display name in the menu |
|
||||
| `description` | Short hint shown in the menu |
|
||||
| `group` | Menu category (BUILD / DEV / TEST / etc.) |
|
||||
| `command` | Executable to run (`dotnet`, `pwsh`, `npm`, etc.) — `null` for virtual aggregator |
|
||||
| `args` | Argument array passed to the executable |
|
||||
| `workingDir` | Working directory relative to project root |
|
||||
| `dependsOn` | List of target IDs that must run first |
|
||||
### Extra sections
|
||||
|
||||
### `sdt-workspace.json` (per workspace, any parent directory)
|
||||
- `tooling.tools[].preferredInstallCommands`: preferred install commands per tool
|
||||
- `tooling.tools[].executables`: explicit executable candidates for non-standard PATH setups
|
||||
- `project.rootHints`: files/folders that identify project root
|
||||
- `env`: session-level environment variable editor values
|
||||
- `debug.profiles[]`: run/attach debug profiles
|
||||
- `debug.diagnostics`: diagnostics bundle policy (`.sdt/debug` by default)
|
||||
- secure default: allowlist-only environment capture
|
||||
- set `includeAllEnv=true` to opt into full environment capture
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Dev Workspace",
|
||||
"projects": [
|
||||
{ "name": "Project A", "description": "Does X", "path": "project-a" },
|
||||
{ "name": "Project B", "description": "Does Y", "path": "../other-repo/project-b" }
|
||||
]
|
||||
}
|
||||
### Workspace Defaults Layering
|
||||
|
||||
If SDT finds `sdt-defaults.json` in the project directory tree (current project root or an ancestor), it merges it into the effective config before runtime:
|
||||
|
||||
- base layer: `sdt-defaults.json`
|
||||
- override layer: project `devtool.json` (project values win)
|
||||
|
||||
Merge behavior:
|
||||
|
||||
- objects merge recursively
|
||||
- arrays/scalars are replaced when project provides the property
|
||||
|
||||
This is useful for shared defaults like toolchains, diagnostics policies, and baseline env definitions across multiple projects in one workspace.
|
||||
|
||||
## Execution Behavior
|
||||
|
||||
For each workflow step:
|
||||
|
||||
1. Resolve dependencies (topological order)
|
||||
2. Probe required tools
|
||||
3. If missing, show install commands and prompt (`Prompt` policy)
|
||||
4. On decline/install failure/step failure, stop immediately
|
||||
5. Render step summary table with exit code + elapsed time
|
||||
6. On workflow/debug failure, generate diagnostics bundle when enabled
|
||||
|
||||
Installer command precedence:
|
||||
|
||||
1. `tooling.tools[].preferredInstallCommands`
|
||||
2. `scripts/diag.py install-plan`
|
||||
3. built-in C# fallback templates (used automatically if script planning fails)
|
||||
|
||||
When a tool probe fails, SDT now prints probe diagnostics (including command resolution source/path) in run output before prompting for installs.
|
||||
|
||||
## Scripts
|
||||
|
||||
See [scripts/README.md](/e:/stansshit/csharp/DevTool-master/scripts/README.md).
|
||||
|
||||
Primary Python entrypoints:
|
||||
|
||||
- `scripts/diag.py`
|
||||
- `scripts/build.py`
|
||||
- `scripts/dotnet-min.py`
|
||||
- `scripts/pip-min.py`
|
||||
- `scripts/publish-*.py`
|
||||
|
||||
## Workspace Support
|
||||
|
||||
- Uses `sdt-workspace.json` when present
|
||||
- If missing, can auto-discover nearby projects containing `devtool.json`
|
||||
- Workspace screen can add external project roots (absolute paths supported)
|
||||
- `projects[].disabled`, `projects[].tags`, and `projects[].toolFamilies` are supported
|
||||
|
||||
## Dev Shell Bootstrap
|
||||
|
||||
Python-first cross-shell dev environment bootstrap:
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
. ./scripts/dev-shell.ps1
|
||||
|
||||
# cmd
|
||||
scripts\dev-shell.cmd
|
||||
```
|
||||
|
||||
Paths are relative to the `sdt-workspace.json` file. Absolute paths also work.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Journal.DevTool/
|
||||
├── Journal.DevTool.csproj net10.0 exe, outputs as 'sdt'
|
||||
├── Program.cs Entry point — discovers workspace + project, run loop
|
||||
├── Config/
|
||||
│ ├── DevToolConfig.cs devtool.json models (BuildTarget, EnvVarDef, ToolchainConfig)
|
||||
│ ├── ConfigLoader.cs Walks up dirs to find and parse devtool.json
|
||||
│ ├── WorkspaceConfig.cs sdt-workspace.json model
|
||||
│ └── WorkspaceLoader.cs Finds and parses sdt-workspace.json
|
||||
├── Runner/
|
||||
│ ├── ProcessRunner.cs Runs a process with live stdout/stderr streaming
|
||||
│ └── TargetRunner.cs Topological dependency resolver
|
||||
└── Tui/
|
||||
├── Theme.cs Phosphor green palette (#00FF41 + amber + red)
|
||||
├── App.cs Main TUI loop — banner, menu, target runner, env editor
|
||||
├── ToolchainScreen.cs Python venv/pip + Node/npm management
|
||||
└── WorkspaceScreen.cs Project switcher
|
||||
```bash
|
||||
# bash/zsh
|
||||
source ./scripts/dev-shell.sh
|
||||
```
|
||||
|
||||
---
|
||||
Underlying implementation is `scripts/dev_shell.py`:
|
||||
|
||||
## Roadmap
|
||||
- `python scripts/dev_shell.py export --shell pwsh --json`
|
||||
- `python scripts/dev_shell.py doctor`
|
||||
|
||||
### v0.2 — Usability & Polish
|
||||
- [ ] `--target <id>` flag for non-interactive single-target run (CI use)
|
||||
- [ ] `--list` flag to print targets and exit
|
||||
- [ ] Config validation with clear error messages on startup
|
||||
- [ ] Build history — last run result + timestamp per target (persisted to `.sdt-state.json`)
|
||||
- [ ] Parallel target execution for independent steps (opt-in per target)
|
||||
## Legacy PowerShell Compatibility
|
||||
|
||||
### v0.3 — Package Manager Depth
|
||||
- [ ] Full pip environment health: list installed packages, outdated check
|
||||
- [ ] npm/pnpm/yarn: show scripts from `package.json`, run arbitrary script
|
||||
- [ ] Python version manager integration (pyenv, py launcher on Windows)
|
||||
- [ ] Virtual environment activation hint for the current shell
|
||||
Legacy `.ps1` scripts remain for migration compatibility only. New functionality is implemented in Python scripts first. `script-common.ps1` is legacy-only.
|
||||
|
||||
### v0.4 — Multi-Project & Workspace
|
||||
- [ ] Workspace-level targets that span multiple projects (e.g. build all repos in order)
|
||||
- [ ] Project dependency graph across workspace (project A's publish feeds project B's build)
|
||||
- [ ] Workspace health dashboard — all projects' last-build status in one table
|
||||
Legacy runtime behavior in v1.2:
|
||||
|
||||
### v0.5 — Env & Secrets
|
||||
- [ ] `.env` file load/save support (per project and per workspace)
|
||||
- [ ] Secret redaction in log output (already in Journal's LogRedactor — port to SDT)
|
||||
- [ ] Environment profiles: save/load named env var sets (e.g. "dev", "staging")
|
||||
- strict mode rejects `targets`-only configs by default
|
||||
- compat mode (`SDT_LEGACY_MODE=compat`) temporarily allows legacy execution
|
||||
- TUI `SYSTEM` includes `Migrate legacy targets -> workflows` to apply migration in place (with backup)
|
||||
|
||||
### v1.0 — Standalone Universal Tool
|
||||
- [ ] Extract from Journal repo into its own standalone repository
|
||||
- [ ] Publish as a `dotnet tool` (`dotnet tool install sdt`)
|
||||
- [ ] Plugin system: projects can register custom SDT commands via `IsdtPlugin`
|
||||
- [ ] GUI mode (Avalonia or web UI) as an optional launch mode — default CLI, `--gui` for graphical
|
||||
- [ ] Linux and macOS first-class support (already mostly there — mainly path/exe resolution)
|
||||
- [ ] JSON schema for `devtool.json` with IDE autocompletion
|
||||
Deprecation target:
|
||||
|
||||
### Long-term Vision
|
||||
SDT's goal is to be **the single tool you open when you sit down at a project** — regardless of language, framework, or OS. Instead of remembering 15 different CLI commands across `dotnet`, `npm`, `pip`, `cargo`, `just`, and `pwsh`, you open SDT and it knows your project's shape from `devtool.json`.
|
||||
- v1.x: compatibility only (no new behavior guarantees)
|
||||
- v2.0: remove legacy `.ps1` scripts from default SDT workflows and docs
|
||||
|
||||
The workspace layer means you can manage a portfolio of projects — switching between them, running cross-project builds, and keeping a consistent interface across everything you work on.
|
||||
## Testing
|
||||
|
||||
---
|
||||
Run unit/integration tests:
|
||||
|
||||
## Dependencies
|
||||
```powershell
|
||||
dotnet test tests/DevTool.Tests/DevTool.Tests.csproj
|
||||
```
|
||||
|
||||
- [`Spectre.Console`](https://spectreconsole.net/) `0.49.1` — TUI rendering, selection prompts, tables, progress
|
||||
Run Python script smoke checks:
|
||||
|
||||
No dependency on `Journal.Core` — SDT is intentionally standalone so it can be extracted cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- SDT does **not** set background colour — it renders on whatever your terminal's background is. A dark terminal is strongly recommended for the phosphor look.
|
||||
- Environment variable changes made in SDT apply to SDT's own process environment for the session. They are **not** written to your system or shell permanently — by design.
|
||||
- When a build step fails, SDT stops the plan and does not run subsequent steps.
|
||||
```powershell
|
||||
python -m py_compile scripts/*.py
|
||||
```
|
||||
|
||||
50
Journal.DevTool/ROADMAP.md
Normal file
50
Journal.DevTool/ROADMAP.md
Normal file
@ -0,0 +1,50 @@
|
||||
# SDT Roadmap / Kanban
|
||||
|
||||
## Done (v1.2 Stabilization)
|
||||
|
||||
- [x] Python-first runtime for diagnostics/build wrappers
|
||||
- [x] Config bootstrap generator for missing `devtool.json`
|
||||
- [x] Workflow-first execution + dual schema support
|
||||
- [x] Strict legacy mode default (`targets`-only blocked)
|
||||
- [x] Compatibility escape hatch (`SDT_LEGACY_MODE=compat`)
|
||||
- [x] Auto-generated migration preview (`devtool.generated.workflows.json`)
|
||||
- [x] In-app migration action: `Migrate legacy targets -> workflows`
|
||||
- [x] Centralized requirement inference (`RequirementResolver`)
|
||||
- [x] Installer planning precedence + fallback resilience
|
||||
- [x] Windows resolver hardening (`%VAR%` PATH expansion)
|
||||
- [x] Resolver tracing surfaced in probe details
|
||||
- [x] Secure diagnostics default (allowlist-only env capture)
|
||||
- [x] Workspace external project add flow
|
||||
- [x] Shared run event stream (`RunEvent`) across workflow + debug execution
|
||||
- [x] TUI event rendering layer wired on top of core run events (GUI-readiness slice)
|
||||
- [x] Persist run-event stream to JSONL for external GUI/client consumption (`.sdt/events/*.jsonl`)
|
||||
- [x] TUI events viewer for persisted run-event logs (`SYSTEM -> View run events`)
|
||||
- [x] Config doctor (`SYSTEM -> Run config doctor`)
|
||||
- [x] Doctor autofix actions (create missing working dirs + invoke legacy migration)
|
||||
- [x] Rich probe diagnostics panel in workflow failure summary
|
||||
- [x] Enhanced Tauri fallback guidance (Windows/macOS/Linux package manager aware)
|
||||
- [x] Workspace-level defaults file layering (`sdt-defaults.json`) above per-project `devtool.json`
|
||||
|
||||
## In Progress (next focus)
|
||||
|
||||
- [x] Add dedicated TUI "Events" viewer for last run
|
||||
- [x] Add doctor autofix actions for common issues (missing dirs, legacy schema migration)
|
||||
|
||||
## Next (v1.3 candidates)
|
||||
|
||||
- [ ] Setup wizard for first run (bootstrap + tool fixes)
|
||||
- [ ] Env profiles (`dev`, `ci`, `release`) with deterministic merge order
|
||||
- [ ] Managed secrets redaction policy for diagnostics bundles
|
||||
- [ ] Favorites/quick actions across projects
|
||||
|
||||
## Later
|
||||
|
||||
- [ ] JSON event stream for GUI integration
|
||||
- [ ] Native GUI shell over headless core services
|
||||
- [ ] Remove legacy PowerShell wrappers in v2
|
||||
|
||||
## Current Milestone Status
|
||||
|
||||
- Robustness Sprint v1.2: **complete**
|
||||
- v1.1 expansion items shipped partially (debug profiles + diagnostics + workspace add external)
|
||||
- Remaining gaps for broader AIO vision are mainly UX/workspace scale and advanced env orchestration
|
||||
@ -18,11 +18,12 @@ public static class ProcessRunner
|
||||
IEnumerable<string> args,
|
||||
string workingDir,
|
||||
Action<string, bool> onOutput,
|
||||
IReadOnlyDictionary<string, string>? envOverrides = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = command,
|
||||
FileName = Core.CommandResolver.Resolve(command),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
@ -30,6 +31,12 @@ public static class ProcessRunner
|
||||
WorkingDirectory = workingDir,
|
||||
};
|
||||
|
||||
if (envOverrides is not null)
|
||||
{
|
||||
foreach (var kvp in envOverrides)
|
||||
psi.Environment[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
foreach (var arg in args)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
|
||||
@ -1,28 +1,65 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Runner;
|
||||
using Sdt.Core;
|
||||
using Sdt.Core.Debug;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Sdt.Tui;
|
||||
|
||||
/// <summary>Thin wrapper used in Spectre.Console selection prompts.</summary>
|
||||
internal sealed record MenuItem(string Display, string Value);
|
||||
|
||||
public enum AppExitReason { Quit, SwitchProject }
|
||||
public sealed record AppResult(AppExitReason Reason, string? NewProjectRoot = null);
|
||||
|
||||
public sealed class App(
|
||||
DevToolConfig config,
|
||||
string projectRoot,
|
||||
WorkspaceConfig? workspace = null,
|
||||
string? workspaceRoot = null)
|
||||
public sealed class App
|
||||
{
|
||||
private DevToolConfig _config = config;
|
||||
private string _projectRoot = projectRoot;
|
||||
private readonly WorkspaceConfig? _workspace = workspace;
|
||||
private readonly string? _workspaceRoot = workspaceRoot;
|
||||
private DevToolConfig _config;
|
||||
private string _projectRoot;
|
||||
private readonly WorkspaceConfig? _workspace;
|
||||
private readonly string? _workspaceRoot;
|
||||
private List<WorkflowDefinition> _workflows;
|
||||
private readonly List<string> _warnings;
|
||||
private readonly IDebugProfileRunner _debugRunner;
|
||||
private readonly IDiagnosticsBundleService _diagnostics;
|
||||
private readonly IRequirementResolver _requirementResolver = new RequirementResolver();
|
||||
|
||||
private IReadOnlyDictionary<string, BuildTarget> TargetMap =>
|
||||
_config.Targets.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
|
||||
private readonly WorkflowExecutor _executor = new(
|
||||
new WorkflowPlanner(),
|
||||
new ToolProbeService(),
|
||||
new PrereqInstallerService(),
|
||||
new ActionRunner(),
|
||||
new RequirementResolver());
|
||||
|
||||
private IReadOnlyDictionary<string, WorkflowDefinition> WorkflowMap =>
|
||||
_workflows.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public App(
|
||||
DevToolConfig config,
|
||||
string projectRoot,
|
||||
IReadOnlyList<string>? warnings = null,
|
||||
WorkspaceConfig? workspace = null,
|
||||
string? workspaceRoot = null)
|
||||
{
|
||||
_config = config;
|
||||
_projectRoot = projectRoot;
|
||||
_workspace = workspace;
|
||||
_workspaceRoot = workspaceRoot;
|
||||
var normalized = WorkflowModelBuilder.Normalize(config, ResolveLegacyMode(), _requirementResolver);
|
||||
_workflows = normalized.Workflows.ToList();
|
||||
_warnings = [];
|
||||
_debugRunner = new DebugProfileRunner(new ToolProbeService(), new PrereqInstallerService());
|
||||
_diagnostics = new DiagnosticsBundleService();
|
||||
if (warnings is not null)
|
||||
_warnings.AddRange(warnings);
|
||||
_warnings.AddRange(normalized.Warnings);
|
||||
}
|
||||
|
||||
private static LegacyMode ResolveLegacyMode()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
|
||||
return string.Equals(raw, "compat", StringComparison.OrdinalIgnoreCase)
|
||||
? LegacyMode.Compat
|
||||
: LegacyMode.Strict;
|
||||
}
|
||||
|
||||
public async Task<AppResult> RunAsync()
|
||||
{
|
||||
@ -31,8 +68,14 @@ public sealed class App(
|
||||
AnsiConsole.Clear();
|
||||
RenderBanner();
|
||||
|
||||
var choice = ShowMainMenu();
|
||||
if (_warnings.Count > 0)
|
||||
{
|
||||
foreach (var warning in _warnings.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
AnsiConsole.MarkupLine(Theme.Warn("Config warning: " + warning));
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
var choice = ShowMainMenu();
|
||||
switch (choice)
|
||||
{
|
||||
case "__env__":
|
||||
@ -43,6 +86,14 @@ public sealed class App(
|
||||
await new ToolchainScreen(_config, _projectRoot).RunAsync();
|
||||
break;
|
||||
|
||||
case "__doctor__":
|
||||
await RunConfigDoctorAsync();
|
||||
break;
|
||||
|
||||
case "__events__":
|
||||
new EventsScreen(_projectRoot, _config.Name, _config.Version).Run();
|
||||
break;
|
||||
|
||||
case "__workspace__":
|
||||
if (_workspace is not null && _workspaceRoot is not null)
|
||||
{
|
||||
@ -53,14 +104,33 @@ public sealed class App(
|
||||
}
|
||||
break;
|
||||
|
||||
case "__migrate_legacy__":
|
||||
ApplyLegacyMigration();
|
||||
break;
|
||||
|
||||
case "__quit__":
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Later.") + "\n");
|
||||
return new AppResult(AppExitReason.Quit);
|
||||
|
||||
default:
|
||||
var targetMap = TargetMap;
|
||||
if (targetMap.TryGetValue(choice, out var target))
|
||||
await RunTargetAsync(target, targetMap);
|
||||
if (choice.StartsWith("__debugattach__:", StringComparison.Ordinal))
|
||||
{
|
||||
var profileId = choice["__debugattach__:".Length..];
|
||||
ShowAttachInstructions(profileId);
|
||||
break;
|
||||
}
|
||||
|
||||
if (choice.StartsWith("__debug__:", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = choice.Split(':', 3, StringSplitOptions.None);
|
||||
if (parts.Length == 3)
|
||||
await RunDebugProfileAsync(parts[1], string.Equals(parts[2], "verbose", StringComparison.OrdinalIgnoreCase));
|
||||
break;
|
||||
}
|
||||
|
||||
var workflowMap = WorkflowMap;
|
||||
if (workflowMap.TryGetValue(choice, out var workflow))
|
||||
await RunWorkflowAsync(workflow, workflowMap);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -72,14 +142,9 @@ public sealed class App(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Banner ────────────────────────────────────────────────────────────────
|
||||
|
||||
private void RenderBanner()
|
||||
{
|
||||
// Phosphor green figlet
|
||||
AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor));
|
||||
|
||||
// Project + workspace info line
|
||||
var wsInfo = _workspace is not null
|
||||
? $" [{Theme.GreenDim}]∙ {Markup.Escape(_workspace.Name)}[/]"
|
||||
: string.Empty;
|
||||
@ -87,12 +152,9 @@ public sealed class App(
|
||||
AnsiConsole.Write(
|
||||
new Rule($"[bold {Theme.GreenBold}]{Markup.Escape(_config.Name)}[/] [{Theme.GreenDim}]v{Markup.Escape(_config.Version)}[/]{wsInfo}")
|
||||
.RuleStyle(Theme.DimStyle));
|
||||
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"root: {_projectRoot}") + "\n");
|
||||
}
|
||||
|
||||
// ── Main menu ─────────────────────────────────────────────────────────────
|
||||
|
||||
private string ShowMainMenu()
|
||||
{
|
||||
var prompt = new SelectionPrompt<MenuItem>()
|
||||
@ -101,8 +163,7 @@ public sealed class App(
|
||||
.MoreChoicesText(Theme.Faint("(scroll to see more)"))
|
||||
.UseConverter(m => m.Display);
|
||||
|
||||
// Targets, grouped
|
||||
var groups = _config.Targets
|
||||
var groups = _workflows
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t.Label))
|
||||
.GroupBy(t => string.IsNullOrWhiteSpace(t.Group) ? "General" : t.Group);
|
||||
|
||||
@ -119,9 +180,35 @@ public sealed class App(
|
||||
prompt.AddChoiceGroup(header, items);
|
||||
}
|
||||
|
||||
// System actions
|
||||
var debugProfiles = _config.Debug?.Profiles ?? [];
|
||||
if (debugProfiles.Count > 0)
|
||||
{
|
||||
var debugItems = new List<MenuItem>();
|
||||
foreach (var profile in debugProfiles)
|
||||
{
|
||||
debugItems.Add(new MenuItem(
|
||||
$"[{Theme.Green}]Run {Markup.Escape(profile.Label)}[/] [{Theme.GreenDim}]debug profile[/]",
|
||||
$"__debug__:{profile.Id}:normal"));
|
||||
debugItems.Add(new MenuItem(
|
||||
$"[{Theme.Green}]Run {Markup.Escape(profile.Label)} (verbose)[/] [{Theme.GreenDim}]stream full output[/]",
|
||||
$"__debug__:{profile.Id}:verbose"));
|
||||
if (profile.Attach is not null)
|
||||
{
|
||||
debugItems.Add(new MenuItem(
|
||||
$"[{Theme.Green}]Attach instructions: {Markup.Escape(profile.Label)}[/]",
|
||||
$"__debugattach__:{profile.Id}"));
|
||||
}
|
||||
}
|
||||
|
||||
prompt.AddChoiceGroup(
|
||||
new MenuItem($"[bold {Theme.Amber}]DEBUG[/]", "__group__"),
|
||||
debugItems);
|
||||
}
|
||||
|
||||
var systemItems = new List<MenuItem>
|
||||
{
|
||||
new($"[{Theme.Green}]🩺 Run config doctor[/] [{Theme.GreenDim}]validate config, tools, paths[/]", "__doctor__"),
|
||||
new($"[{Theme.Green}]📜 View run events[/] [{Theme.GreenDim}].sdt/events JSONL viewer[/]", "__events__"),
|
||||
new($"[{Theme.Green}]⚙ Edit environment variables[/]", "__env__"),
|
||||
};
|
||||
|
||||
@ -130,11 +217,16 @@ public sealed class App(
|
||||
$"[{Theme.Green}]⬡ Toolchain management[/] [{Theme.GreenDim}]python / node[/]",
|
||||
"__toolchains__"));
|
||||
|
||||
if (_workspace is not null && (_workspace.Projects.Count > 1))
|
||||
if (_workspace is not null)
|
||||
systemItems.Insert(0, new MenuItem(
|
||||
$"[{Theme.Green}]⇄ Switch project[/] [{Theme.GreenDim}]{Markup.Escape(_workspace.Name)}[/]",
|
||||
$"[{Theme.Green}]⇄ Workspace projects[/] [{Theme.GreenDim}]{Markup.Escape(_workspace.Name)}[/]",
|
||||
"__workspace__"));
|
||||
|
||||
if (_config.Targets.Count > 0)
|
||||
systemItems.Insert(0, new MenuItem(
|
||||
$"[{Theme.Green}]⇪ Migrate legacy targets → workflows[/] [{Theme.GreenDim}]writes devtool.json + backup[/]",
|
||||
"__migrate_legacy__"));
|
||||
|
||||
systemItems.Add(new MenuItem($"[{Theme.GreenDim}]✗ Quit[/]", "__quit__"));
|
||||
|
||||
prompt.AddChoiceGroup(
|
||||
@ -144,84 +236,489 @@ public sealed class App(
|
||||
return AnsiConsole.Prompt(prompt).Value;
|
||||
}
|
||||
|
||||
// ── Target execution ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task RunTargetAsync(BuildTarget target, IReadOnlyDictionary<string, BuildTarget> targetMap)
|
||||
private async Task RunWorkflowAsync(
|
||||
WorkflowDefinition workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap)
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
AnsiConsole.Write(Theme.SectionRule(target.Label));
|
||||
|
||||
var plan = TargetRunner.ResolvePlan(target, targetMap);
|
||||
AnsiConsole.Write(Theme.SectionRule(workflow.Label));
|
||||
|
||||
var plan = new WorkflowPlanner().ResolvePlan(workflow, workflowMap);
|
||||
if (plan.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Warn("This target has no executable steps."));
|
||||
AnsiConsole.MarkupLine(Theme.Warn("This workflow has no executable steps."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.Count > 1)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Execution plan — {plan.Count} steps:"));
|
||||
foreach (var step in plan)
|
||||
AnsiConsole.MarkupLine($" [{Theme.GreenDim}]→[/] [{Theme.Green}]{Markup.Escape(step.Label)}[/]");
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Execution plan — {plan.Count} workflow(s):"));
|
||||
foreach (var item in plan)
|
||||
AnsiConsole.MarkupLine($" [{Theme.GreenDim}]→[/] [{Theme.Green}]{Markup.Escape(item.Label)}[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
var allOk = true;
|
||||
var totalSw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var outputLines = new List<string>();
|
||||
using var eventRecorder = RunEventJsonlRecorder.Create(_projectRoot, "workflow");
|
||||
|
||||
foreach (var step in plan)
|
||||
{
|
||||
AnsiConsole.Write(Theme.DimRule());
|
||||
|
||||
var workingDir = Path.GetFullPath(Path.Combine(_projectRoot, step.WorkingDir));
|
||||
var cmdDisplay = $"{step.Command} {string.Join(" ", step.Args)}";
|
||||
|
||||
AnsiConsole.MarkupLine($"[{Theme.GreenDim}]$ {Markup.Escape(cmdDisplay)}[/]");
|
||||
AnsiConsole.MarkupLine($"[{Theme.GreenDim}] {Markup.Escape(workingDir)}[/]\n");
|
||||
|
||||
RunResult result;
|
||||
try
|
||||
var result = await _executor.ExecuteAsync(
|
||||
workflow,
|
||||
workflowMap,
|
||||
_config,
|
||||
_projectRoot,
|
||||
confirmInstallAsync: ConfirmInstallAsync,
|
||||
onOutput: (line, isErr) =>
|
||||
{
|
||||
result = await ProcessRunner.RunAsync(
|
||||
step.Command!,
|
||||
step.Args,
|
||||
workingDir,
|
||||
(line, isErr) =>
|
||||
{
|
||||
var escaped = Markup.Escape(line);
|
||||
AnsiConsole.MarkupLine(isErr
|
||||
? $"[{Theme.Amber}]{escaped}[/]"
|
||||
: $"[{Theme.Green}]{escaped}[/]");
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
outputLines.Add((isErr ? "ERR: " : "OUT: ") + line);
|
||||
var escaped = Markup.Escape(line);
|
||||
AnsiConsole.MarkupLine(isErr
|
||||
? $"[{Theme.Amber}]{escaped}[/]"
|
||||
: $"[{Theme.Green}]{escaped}[/]");
|
||||
},
|
||||
onEvent: evt =>
|
||||
{
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Fail($"Failed to launch: {ex.Message}"));
|
||||
allOk = false;
|
||||
break;
|
||||
}
|
||||
eventRecorder.Write(evt);
|
||||
RenderRunEvent(evt);
|
||||
});
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
if (result.Success)
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"{step.Label} ({result.Elapsed.TotalSeconds:F1}s)"));
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail($"{step.Label} — exited {result.ExitCode} ({result.Elapsed.TotalSeconds:F1}s)"));
|
||||
allOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
totalSw.Stop();
|
||||
AnsiConsole.Write(Theme.SectionRule());
|
||||
AnsiConsole.MarkupLine(allOk
|
||||
? "\n" + Theme.Ok($"Done! Total: {totalSw.Elapsed.TotalSeconds:F1}s")
|
||||
: "\n" + Theme.Fail("Build failed. Check output above."));
|
||||
RenderStepSummary(result);
|
||||
RenderProbeDiagnosticsSummary(outputLines);
|
||||
if (result.Success)
|
||||
{
|
||||
var totalSeconds = result.Steps.Sum(s => s.Result.Elapsed.TotalSeconds);
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Ok($"Done! Total: {totalSeconds:F1}s"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}"));
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Fail($"{result.StopReason}: {result.Message}"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}"));
|
||||
await WriteWorkflowDiagnosticsAsync(workflow, workflowMap, result, outputLines);
|
||||
}
|
||||
|
||||
// ── Environment editor ────────────────────────────────────────────────────
|
||||
private static void RenderStepSummary(WorkflowExecutionResult result)
|
||||
{
|
||||
if (result.Steps.Count == 0)
|
||||
return;
|
||||
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.BorderStyle(Theme.DimStyle)
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Workflow[/]"))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Step[/]"))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Exit[/]").Width(8))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Seconds[/]").Width(10))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(12));
|
||||
|
||||
foreach (var step in result.Steps)
|
||||
{
|
||||
var ok = step.Result.Success;
|
||||
table.AddRow(
|
||||
Theme.Faint(step.WorkflowId),
|
||||
Theme.G(step.StepLabel),
|
||||
Theme.Bold(step.Result.ExitCode.ToString()),
|
||||
Theme.Faint($"{step.Result.Elapsed.TotalSeconds:F1}"),
|
||||
ok ? Theme.Ok("ok") : Theme.Fail("failed"));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
private async Task RunDebugProfileAsync(string profileId, bool verbose)
|
||||
{
|
||||
var profile = _config.Debug?.Profiles.FirstOrDefault(p => string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase));
|
||||
if (profile is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail($"Debug profile not found: {profileId}"));
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.Clear();
|
||||
AnsiConsole.Write(Theme.SectionRule($"DEBUG — {profile.Label}"));
|
||||
using var eventRecorder = RunEventJsonlRecorder.Create(_projectRoot, "debug");
|
||||
|
||||
if (profile.Attach is not null)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Attach: {profile.Attach.Kind}"));
|
||||
if (profile.Attach.Port is not null)
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Port: {profile.Attach.Port}"));
|
||||
if (!string.IsNullOrWhiteSpace(profile.Attach.ProcessName))
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Process: {profile.Attach.ProcessName}"));
|
||||
if (!string.IsNullOrWhiteSpace(profile.Attach.Note))
|
||||
AnsiConsole.MarkupLine(Theme.Faint(profile.Attach.Note!));
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
var result = await _debugRunner.RunAsync(
|
||||
profile,
|
||||
_config,
|
||||
_projectRoot,
|
||||
verbose,
|
||||
ConfirmInstallAsync,
|
||||
(line, isErr) =>
|
||||
{
|
||||
var escaped = Markup.Escape(line);
|
||||
AnsiConsole.MarkupLine(isErr
|
||||
? $"[{Theme.Amber}]{escaped}[/]"
|
||||
: $"[{Theme.Green}]{escaped}[/]");
|
||||
},
|
||||
evt =>
|
||||
{
|
||||
eventRecorder.Write(evt);
|
||||
RenderRunEvent(evt);
|
||||
});
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
var seconds = result.RunResult?.Elapsed.TotalSeconds ?? 0;
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Ok($"Debug run completed in {seconds:F1}s"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}"));
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Fail($"{result.StopReason}: {result.Message}"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Run events: {eventRecorder.FilePath}"));
|
||||
|
||||
var diagnostics = _config.Debug?.Diagnostics ?? new DebugDiagnosticsOptions();
|
||||
if (!diagnostics.Enabled || !diagnostics.BundleOnFailure)
|
||||
return;
|
||||
|
||||
var bundle = await _diagnostics.WriteBundleAsync(
|
||||
new DiagnosticsBundleRequest(
|
||||
Category: "debug",
|
||||
ProjectRoot: _projectRoot,
|
||||
SummaryMessage: result.Message,
|
||||
OutputLines: result.OutputLines,
|
||||
WorkflowSteps: [],
|
||||
Probes: result.Probes,
|
||||
DiagnosticsOptions: diagnostics,
|
||||
Config: _config,
|
||||
StopReason: result.StopReason,
|
||||
DebugRun: result.RunResult,
|
||||
DebugProfile: result.Profile));
|
||||
|
||||
if (bundle.Success)
|
||||
AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle: {bundle.BundleDirectory}"));
|
||||
else
|
||||
AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle failed: {bundle.Message}"));
|
||||
}
|
||||
|
||||
private static void RenderProbeDiagnosticsSummary(IReadOnlyList<string> outputLines)
|
||||
{
|
||||
var diagnostics = new List<(string Tool, string Detail)>();
|
||||
foreach (var line in outputLines)
|
||||
{
|
||||
const string marker = "Probe detail [";
|
||||
var markerIndex = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (markerIndex < 0)
|
||||
continue;
|
||||
|
||||
var toolStart = markerIndex + marker.Length;
|
||||
var toolEnd = line.IndexOf(']', toolStart);
|
||||
if (toolEnd <= toolStart)
|
||||
continue;
|
||||
|
||||
var tool = line[toolStart..toolEnd].Trim();
|
||||
var detailStart = line.IndexOf(':', toolEnd);
|
||||
var detail = detailStart >= 0 && detailStart + 1 < line.Length
|
||||
? line[(detailStart + 1)..].Trim()
|
||||
: "";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tool))
|
||||
diagnostics.Add((tool, detail));
|
||||
}
|
||||
|
||||
if (diagnostics.Count == 0)
|
||||
return;
|
||||
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.BorderStyle(Theme.DimStyle)
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Probe Tool[/]").Width(14))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Resolver / Probe Detail[/]"))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Suggested Fix[/]"));
|
||||
|
||||
foreach (var diag in diagnostics.Distinct())
|
||||
{
|
||||
table.AddRow(
|
||||
Theme.Warn(diag.Tool),
|
||||
Theme.Faint(diag.Detail),
|
||||
Theme.Faint(GetProbeFixHint(diag.Tool, diag.Detail)));
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.Write(Theme.SectionRule("PROBE DIAGNOSTICS"));
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
private static string GetProbeFixHint(string tool, string detail)
|
||||
{
|
||||
if (detail.Contains("ConfiguredOverride", StringComparison.OrdinalIgnoreCase))
|
||||
return "Check tooling.tools[].executables paths.";
|
||||
if (detail.Contains("NodeAdjacentShim", StringComparison.OrdinalIgnoreCase))
|
||||
return "Node found; verify npm/yarn shim in node directory.";
|
||||
if (detail.Contains("Fallback", StringComparison.OrdinalIgnoreCase))
|
||||
return $"Add {tool} to PATH or set tooling.tools[].executables.";
|
||||
return $"Install/configure {tool} then rerun.";
|
||||
}
|
||||
|
||||
private static void RenderRunEvent(RunEvent evt)
|
||||
{
|
||||
var shouldRender = evt.Type is
|
||||
RunEventType.WorkflowStarted or
|
||||
RunEventType.WorkflowStepStarted or
|
||||
RunEventType.WorkflowStepCompleted or
|
||||
RunEventType.ProbeFailed or
|
||||
RunEventType.InstallPlanPrepared or
|
||||
RunEventType.DebugStarted or
|
||||
RunEventType.DebugCommandStarted or
|
||||
RunEventType.DebugCommandCompleted or
|
||||
RunEventType.WorkflowCompleted or
|
||||
RunEventType.DebugCompleted;
|
||||
|
||||
if (!shouldRender)
|
||||
return;
|
||||
|
||||
var prefix = evt.Category.Equals("debug", StringComparison.OrdinalIgnoreCase) ? "DBG" : "RUN";
|
||||
var tone = evt.Success is false ? Theme.Amber : Theme.GreenDim;
|
||||
AnsiConsole.MarkupLine($"[{tone}]{prefix} {Markup.Escape(evt.Message)}[/]");
|
||||
}
|
||||
|
||||
private void ShowAttachInstructions(string profileId)
|
||||
{
|
||||
var profile = _config.Debug?.Profiles.FirstOrDefault(p => string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase));
|
||||
if (profile?.Attach is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Warn("No attach instructions configured for this profile."));
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.Clear();
|
||||
AnsiConsole.Write(Theme.SectionRule($"ATTACH — {profile.Label}"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Kind: {profile.Attach.Kind}"));
|
||||
if (profile.Attach.Port is not null)
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Port: {profile.Attach.Port}"));
|
||||
if (!string.IsNullOrWhiteSpace(profile.Attach.ProcessName))
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Process: {profile.Attach.ProcessName}"));
|
||||
if (!string.IsNullOrWhiteSpace(profile.Attach.Note))
|
||||
AnsiConsole.MarkupLine(Theme.G(profile.Attach.Note!));
|
||||
}
|
||||
|
||||
private async Task WriteWorkflowDiagnosticsAsync(
|
||||
WorkflowDefinition workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap,
|
||||
WorkflowExecutionResult result,
|
||||
IReadOnlyList<string> outputLines)
|
||||
{
|
||||
var diagnostics = _config.Debug?.Diagnostics ?? new DebugDiagnosticsOptions();
|
||||
if (!diagnostics.Enabled || !diagnostics.BundleOnFailure)
|
||||
return;
|
||||
|
||||
var probes = await SnapshotWorkflowToolsAsync(workflow, workflowMap);
|
||||
|
||||
var bundle = await _diagnostics.WriteBundleAsync(
|
||||
new DiagnosticsBundleRequest(
|
||||
Category: "workflow",
|
||||
ProjectRoot: _projectRoot,
|
||||
SummaryMessage: result.Message,
|
||||
OutputLines: outputLines,
|
||||
WorkflowSteps: result.Steps,
|
||||
Probes: probes,
|
||||
DiagnosticsOptions: diagnostics,
|
||||
Config: _config,
|
||||
StopReason: result.StopReason));
|
||||
|
||||
if (bundle.Success)
|
||||
AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle: {bundle.BundleDirectory}"));
|
||||
else
|
||||
AnsiConsole.MarkupLine(Theme.Warn($"Diagnostics bundle failed: {bundle.Message}"));
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ProbeResult>> SnapshotWorkflowToolsAsync(
|
||||
WorkflowDefinition workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap)
|
||||
{
|
||||
var plan = new WorkflowPlanner().ResolvePlan(workflow, workflowMap);
|
||||
var tools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in plan)
|
||||
{
|
||||
foreach (var step in item.Steps)
|
||||
{
|
||||
foreach (var req in _requirementResolver.Resolve(step))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(req.Tool))
|
||||
tools.Add(req.Tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var probeService = new ToolProbeService();
|
||||
var probes = new List<ProbeResult>();
|
||||
foreach (var tool in tools)
|
||||
probes.Add(await probeService.ProbeAsync(tool, _projectRoot, _config));
|
||||
return probes;
|
||||
}
|
||||
|
||||
private Task<bool> ConfirmInstallAsync(string tool, InstallPlan plan)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Warn($"Missing prerequisite: {tool}"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint(plan.Summary));
|
||||
foreach (var cmd in plan.Commands)
|
||||
AnsiConsole.MarkupLine(Theme.Faint($" $ {cmd.Command} {string.Join(" ", cmd.Args)}"));
|
||||
|
||||
var allow = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Run install commands for {Markup.Escape(tool)} now?[/]",
|
||||
defaultValue: false);
|
||||
return Task.FromResult(allow);
|
||||
}
|
||||
|
||||
private async Task RunConfigDoctorAsync()
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
AnsiConsole.Write(Theme.SectionRule("CONFIG DOCTOR"));
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var service = new ConfigDoctorService(new ToolProbeService(), _requirementResolver);
|
||||
var report = await service.RunAsync(_config, _projectRoot);
|
||||
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.BorderStyle(Theme.DimStyle)
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Check[/]").Width(26))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(10))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Detail[/]"))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Fix[/]"));
|
||||
|
||||
foreach (var check in report.Checks)
|
||||
{
|
||||
var statusText = check.Status switch
|
||||
{
|
||||
DoctorStatus.Pass => Theme.Ok("ok"),
|
||||
DoctorStatus.Warn => Theme.Warn("warn"),
|
||||
DoctorStatus.Fail => Theme.Fail("fail"),
|
||||
_ => Theme.Faint("n/a"),
|
||||
};
|
||||
|
||||
table.AddRow(
|
||||
Theme.G(check.Name),
|
||||
statusText,
|
||||
Theme.Faint(check.Detail),
|
||||
string.IsNullOrWhiteSpace(check.Fix) ? Theme.Faint("-") : Theme.Faint(check.Fix));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
if (report.HasFailures)
|
||||
AnsiConsole.MarkupLine(Theme.Fail("Doctor found blocking issues."));
|
||||
else if (report.HasWarnings)
|
||||
AnsiConsole.MarkupLine(Theme.Warn("Doctor completed with warnings."));
|
||||
else
|
||||
AnsiConsole.MarkupLine(Theme.Ok("Doctor completed: no issues found."));
|
||||
|
||||
if (!report.HasFailures && !report.HasWarnings)
|
||||
return;
|
||||
|
||||
var applyFixes = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Apply common autofixes now?[/]",
|
||||
defaultValue: false);
|
||||
if (!applyFixes)
|
||||
return;
|
||||
|
||||
var fixer = new ConfigDoctorAutoFixService();
|
||||
var missingDirs = fixer.FindMissingWorkingDirectories(_config, _projectRoot);
|
||||
if (missingDirs.Count > 0)
|
||||
{
|
||||
var createDirs = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Create {missingDirs.Count} missing working director{(missingDirs.Count == 1 ? "y" : "ies")}?[/]",
|
||||
defaultValue: true);
|
||||
if (createDirs)
|
||||
{
|
||||
var dirFix = fixer.CreateMissingWorkingDirectories(missingDirs);
|
||||
AnsiConsole.MarkupLine(dirFix.Success ? Theme.Ok(dirFix.Message) : Theme.Fail(dirFix.Message));
|
||||
}
|
||||
}
|
||||
|
||||
if (_config.Targets.Count > 0)
|
||||
{
|
||||
var migrate = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Migrate legacy targets to workflows now?[/]",
|
||||
defaultValue: true);
|
||||
if (migrate)
|
||||
{
|
||||
var migration = fixer.ApplyLegacyMigration(_projectRoot);
|
||||
if (migration.Success)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Ok("Legacy migration applied from doctor."));
|
||||
if (!string.IsNullOrWhiteSpace(migration.BackupPath))
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Backup: {migration.BackupPath}"));
|
||||
|
||||
var reloaded = ConfigLoader.FindAndLoad(_projectRoot);
|
||||
if (reloaded is not null)
|
||||
{
|
||||
_config = reloaded.Config;
|
||||
var normalized = WorkflowModelBuilder.Normalize(_config, ResolveLegacyMode(), _requirementResolver);
|
||||
_workflows = normalized.Workflows.ToList();
|
||||
_warnings.Clear();
|
||||
_warnings.AddRange(reloaded.Warnings);
|
||||
_warnings.AddRange(normalized.Warnings);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail(migration.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyLegacyMigration()
|
||||
{
|
||||
if (_config.Targets.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Warn("No legacy targets found in this config."));
|
||||
return;
|
||||
}
|
||||
|
||||
var configPath = ConfigLoader.FindConfigPath(_projectRoot);
|
||||
if (string.IsNullOrWhiteSpace(configPath))
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail("Could not locate devtool.json to migrate."));
|
||||
return;
|
||||
}
|
||||
|
||||
var confirm = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Migrate legacy targets to workflows and overwrite devtool.json (with backup)?[/]",
|
||||
defaultValue: true);
|
||||
if (!confirm)
|
||||
return;
|
||||
|
||||
var result = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
|
||||
if (!result.Success)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail(result.Message));
|
||||
return;
|
||||
}
|
||||
|
||||
var reloaded = ConfigLoader.FindAndLoad(_projectRoot);
|
||||
if (reloaded is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail("Migration wrote config, but reload failed."));
|
||||
return;
|
||||
}
|
||||
|
||||
_config = reloaded.Config;
|
||||
var normalized = WorkflowModelBuilder.Normalize(_config, ResolveLegacyMode(), _requirementResolver);
|
||||
_workflows = normalized.Workflows.ToList();
|
||||
_warnings.Clear();
|
||||
_warnings.AddRange(reloaded.Warnings);
|
||||
_warnings.AddRange(normalized.Warnings);
|
||||
|
||||
AnsiConsole.MarkupLine(Theme.Ok("Migration complete."));
|
||||
if (!string.IsNullOrWhiteSpace(result.BackupPath))
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Backup: {result.BackupPath}"));
|
||||
}
|
||||
|
||||
private void EditEnvironment()
|
||||
{
|
||||
|
||||
126
Journal.DevTool/Tui/EventsScreen.cs
Normal file
126
Journal.DevTool/Tui/EventsScreen.cs
Normal file
@ -0,0 +1,126 @@
|
||||
using Sdt.Core;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Sdt.Tui;
|
||||
|
||||
public sealed class EventsScreen(string projectRoot, string projectName, string version)
|
||||
{
|
||||
private readonly string _projectRoot = projectRoot;
|
||||
private readonly string _projectName = projectName;
|
||||
private readonly string _version = version;
|
||||
private readonly RunEventLogReader _reader = new();
|
||||
|
||||
public void Run()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
RenderHeader("EVENTS");
|
||||
var files = _reader.ListEventFiles(_projectRoot);
|
||||
if (files.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Warn("No event logs found yet."));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Run a workflow/debug action first, then return here."));
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.BorderStyle(Theme.DimStyle)
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]File[/]"))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Updated[/]").Width(21))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Size[/]").Width(10));
|
||||
foreach (var file in files.Take(12))
|
||||
{
|
||||
table.AddRow(
|
||||
Theme.G(file.Name),
|
||||
Theme.Faint(file.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss")),
|
||||
Theme.Faint($"{Math.Max(1, file.SizeBytes / 1024)} KB"));
|
||||
}
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var choices = files
|
||||
.Take(20)
|
||||
.Select(f => new MenuItem(
|
||||
$"[{Theme.Green}]{Markup.Escape(f.Name)}[/] [{Theme.GreenDim}]{f.LastWriteTime:yyyy-MM-dd HH:mm:ss}[/]",
|
||||
f.Path))
|
||||
.ToList();
|
||||
choices.Add(new MenuItem(Theme.Faint("← Back"), "__back__"));
|
||||
|
||||
var selected = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<MenuItem>()
|
||||
.Title($"[{Theme.Green}]Select an event log to view:[/]")
|
||||
.PageSize(20)
|
||||
.UseConverter(m => m.Display)
|
||||
.AddChoices(choices));
|
||||
|
||||
if (selected.Value == "__back__")
|
||||
return;
|
||||
|
||||
ShowEventFile(selected.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowEventFile(string filePath)
|
||||
{
|
||||
var events = _reader.ReadEvents(filePath);
|
||||
AnsiConsole.Clear();
|
||||
RenderHeader("EVENTS VIEWER");
|
||||
AnsiConsole.MarkupLine(Theme.Faint(Path.GetFileName(filePath)));
|
||||
AnsiConsole.MarkupLine(Theme.Faint(filePath));
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
if (events.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Warn("No parseable events in selected file."));
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to return..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.BorderStyle(Theme.DimStyle)
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Time[/]").Width(12))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Type[/]").Width(26))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Message[/]"))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(10));
|
||||
|
||||
foreach (var evt in events.TakeLast(120))
|
||||
{
|
||||
var status = evt.Success switch
|
||||
{
|
||||
true => Theme.Ok("ok"),
|
||||
false => Theme.Fail("fail"),
|
||||
null => Theme.Faint("-"),
|
||||
};
|
||||
var message = evt.Message;
|
||||
if (!string.IsNullOrWhiteSpace(evt.Tool))
|
||||
message += $" [{evt.Tool}]";
|
||||
if (evt.ExitCode is not null)
|
||||
message += $" (exit {evt.ExitCode})";
|
||||
|
||||
table.AddRow(
|
||||
Theme.Faint(evt.OccurredAt.ToString("HH:mm:ss")),
|
||||
Theme.G(evt.Type.ToString()),
|
||||
Theme.Faint(message),
|
||||
status);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to return..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
}
|
||||
|
||||
private void RenderHeader(string section)
|
||||
{
|
||||
AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor));
|
||||
AnsiConsole.Write(
|
||||
new Rule($"[bold {Theme.GreenBold}]{Markup.Escape(_projectName)}[/] [{Theme.GreenDim}]v{Markup.Escape(_version)}[/] [{Theme.Amber}]{Markup.Escape(section)}[/]")
|
||||
.RuleStyle(Theme.DimStyle));
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"root: {_projectRoot}") + "\n");
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Sdt.Runner;
|
||||
using Sdt.Tui;
|
||||
using Spectre.Console;
|
||||
@ -178,7 +179,7 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
|
||||
|
||||
// Upgrade pip first
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Upgrading pip..."));
|
||||
await RunLiveAsync(venvPy, ["-m", "pip", "install", "--upgrade", "pip"], _projectRoot);
|
||||
await RunPipAsync(py, venvPy, ["install", "--upgrade", "pip"]);
|
||||
|
||||
// Build install args
|
||||
var installArgs = new List<string> { "-m", "pip", "install" };
|
||||
@ -193,7 +194,7 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine(Theme.G($"Installing {profile.Label}..."));
|
||||
AnsiConsole.WriteLine();
|
||||
await RunLiveAsync(venvPy, installArgs, _projectRoot);
|
||||
await RunPipAsync(py, venvPy, installArgs.Skip(2)); // strip leading "-m pip"
|
||||
|
||||
// Post-install commands
|
||||
foreach (var cmd in profile.PostInstallCommands)
|
||||
@ -211,10 +212,9 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
|
||||
var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, py.VenvDir));
|
||||
var venvPy = GetVenvPython(venvPath);
|
||||
var exe = File.Exists(venvPy) ? venvPy : ResolvePythonExe(py);
|
||||
|
||||
AnsiConsole.MarkupLine(Theme.G($"Upgrading pip using: {exe}"));
|
||||
AnsiConsole.WriteLine();
|
||||
await RunLiveAsync(exe, ["-m", "pip", "install", "--upgrade", "pip"], _projectRoot);
|
||||
await RunPipAsync(py, exe, ["install", "--upgrade", "pip"]);
|
||||
}
|
||||
|
||||
// ── Node ──────────────────────────────────────────────────────────────────
|
||||
@ -278,7 +278,7 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = command,
|
||||
FileName = CommandResolver.Resolve(command),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
@ -310,4 +310,38 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
|
||||
? Theme.Ok($"Done ({result.Elapsed.TotalSeconds:F1}s)")
|
||||
: Theme.Fail($"Exited {result.ExitCode} ({result.Elapsed.TotalSeconds:F1}s)"));
|
||||
}
|
||||
|
||||
private async Task RunPipAsync(PythonToolchain py, string pythonExe, IEnumerable<string> pipArgs)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(py.PipScript))
|
||||
{
|
||||
var pipScriptPath = ResolvePipScriptPath(py.PipScript);
|
||||
if (File.Exists(pipScriptPath))
|
||||
{
|
||||
var ext = Path.GetExtension(pipScriptPath).ToLowerInvariant();
|
||||
if (ext == ".py")
|
||||
{
|
||||
await RunLiveAsync(ResolvePythonExe(py), [pipScriptPath, .. pipArgs], _projectRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine(Theme.Warn($"Ignoring non-Python pipScript: {pipScriptPath}"));
|
||||
}
|
||||
}
|
||||
|
||||
await RunLiveAsync(pythonExe, ["-m", "pip", .. pipArgs], _projectRoot);
|
||||
}
|
||||
|
||||
private string ResolvePipScriptPath(string pipScriptConfigPath)
|
||||
{
|
||||
if (Path.IsPathRooted(pipScriptConfigPath))
|
||||
return pipScriptConfigPath;
|
||||
|
||||
var fileName = Path.GetFileName(pipScriptConfigPath);
|
||||
var bundled = ScriptLocator.FindHelperScript(_projectRoot, fileName);
|
||||
if (bundled is not null)
|
||||
return bundled;
|
||||
|
||||
return Path.GetFullPath(Path.Combine(_projectRoot, pipScriptConfigPath));
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,86 +15,159 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR
|
||||
/// </summary>
|
||||
public string? SelectProject()
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
AnsiConsole.Write(Theme.SectionRule("WORKSPACE — " + _workspace.Name));
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var projects = _workspace.Projects;
|
||||
if (projects.Count == 0)
|
||||
while (true)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Warn("No projects defined in sdt-workspace.json."));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Add entries to the \"projects\" array."));
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
return null;
|
||||
AnsiConsole.Clear();
|
||||
AnsiConsole.Write(Theme.SectionRule("WORKSPACE — " + _workspace.Name));
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var projects = _workspace.Projects;
|
||||
if (projects.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Warn("No projects defined in sdt-workspace.json."));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Add entries to the \"projects\" array."));
|
||||
if (AnsiConsole.Confirm($"[{Theme.Amber}]Add an external project now?[/]", defaultValue: true))
|
||||
{
|
||||
AddExternalProject();
|
||||
continue;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build choice list with current project marked
|
||||
var choices = new List<WorkspaceMenuItem>();
|
||||
foreach (var proj in projects)
|
||||
{
|
||||
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
|
||||
var devtoolPath = Path.Combine(absPath, "devtool.json");
|
||||
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
|
||||
var exists = File.Exists(devtoolPath);
|
||||
|
||||
var label = isCurrent
|
||||
? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/] [{Theme.GreenDim}](current)[/]"
|
||||
: $"[{Theme.Green}] {Markup.Escape(proj.Name)}[/]";
|
||||
if (proj.Disabled)
|
||||
label += $" [{Theme.Amber}](disabled)[/]";
|
||||
|
||||
var desc = !exists
|
||||
? $" [{Theme.Red}]devtool.json not found[/]"
|
||||
: string.IsNullOrWhiteSpace(proj.Description)
|
||||
? $" [{Theme.GreenDim}]{Markup.Escape(absPath)}[/]"
|
||||
: $" [{Theme.GreenDim}]{Markup.Escape(proj.Description)}[/]";
|
||||
if (proj.Tags.Count > 0)
|
||||
desc += $" [{Theme.GreenDim}]tags: {Markup.Escape(string.Join(",", proj.Tags))}[/]";
|
||||
|
||||
choices.Add(new WorkspaceMenuItem(label + "\n" + desc, absPath, exists && !isCurrent && !proj.Disabled));
|
||||
}
|
||||
|
||||
choices.Add(new WorkspaceMenuItem($"[{Theme.Green}]+ Add external project[/]", "__add__", true));
|
||||
choices.Add(new WorkspaceMenuItem($"[{Theme.GreenDim}]← Cancel[/]", null, true));
|
||||
|
||||
// Show project table for overview
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.BorderStyle(Theme.DimStyle)
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Project[/]"))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Path[/]"))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(12));
|
||||
|
||||
foreach (var proj in projects)
|
||||
{
|
||||
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
|
||||
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
|
||||
var hasConfig = File.Exists(Path.Combine(absPath, "devtool.json"));
|
||||
var status = proj.Disabled
|
||||
? Theme.Warn("disabled")
|
||||
: hasConfig ? Theme.Ok("ready") : Theme.Fail("no config");
|
||||
|
||||
table.AddRow(
|
||||
isCurrent
|
||||
? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/]"
|
||||
: Theme.G(proj.Name),
|
||||
Theme.Faint(proj.Path),
|
||||
status);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var switchable = choices.Where(c => c.Selectable).ToList();
|
||||
if (switchable.Count == 1) // only Cancel
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Warn("No other projects available to switch to."));
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
var selected = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<WorkspaceMenuItem>()
|
||||
.Title($"[{Theme.Green}]Switch to project:[/]")
|
||||
.PageSize(15)
|
||||
.UseConverter(m => m.Display)
|
||||
.AddChoices(switchable));
|
||||
|
||||
if (selected.AbsPath == "__add__")
|
||||
{
|
||||
AddExternalProject();
|
||||
continue;
|
||||
}
|
||||
|
||||
return selected.AbsPath; // null = cancelled
|
||||
}
|
||||
}
|
||||
|
||||
private void AddExternalProject()
|
||||
{
|
||||
var raw = AnsiConsole.Ask<string>($"[{Theme.Amber}]Project root path[/]");
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return;
|
||||
|
||||
var absolutePath = Path.GetFullPath(raw.Trim());
|
||||
if (!Directory.Exists(absolutePath))
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail("Directory does not exist."));
|
||||
Thread.Sleep(700);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build choice list with current project marked
|
||||
var choices = new List<WorkspaceMenuItem>();
|
||||
foreach (var proj in projects)
|
||||
var configPath = Path.Combine(absolutePath, "devtool.json");
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
|
||||
var devtoolPath = Path.Combine(absPath, "devtool.json");
|
||||
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
|
||||
var exists = File.Exists(devtoolPath);
|
||||
var create = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]No devtool.json found. Create a minimal template?[/]",
|
||||
defaultValue: true);
|
||||
if (!create)
|
||||
return;
|
||||
|
||||
var label = isCurrent
|
||||
? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/] [{Theme.GreenDim}](current)[/]"
|
||||
: $"[{Theme.Green}] {Markup.Escape(proj.Name)}[/]";
|
||||
|
||||
var desc = !exists
|
||||
? $" [{Theme.Red}]devtool.json not found[/]"
|
||||
: string.IsNullOrWhiteSpace(proj.Description)
|
||||
? $" [{Theme.GreenDim}]{Markup.Escape(absPath)}[/]"
|
||||
: $" [{Theme.GreenDim}]{Markup.Escape(proj.Description)}[/]";
|
||||
|
||||
choices.Add(new WorkspaceMenuItem(label + "\n" + desc, absPath, exists && !isCurrent));
|
||||
File.WriteAllText(configPath, "{\n \"name\": \"SDT Project\",\n \"version\": \"0.1.0\",\n \"workflows\": []\n}\n");
|
||||
}
|
||||
|
||||
choices.Add(new WorkspaceMenuItem($"[{Theme.GreenDim}]← Cancel[/]", null, true));
|
||||
|
||||
// Show project table for overview
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.BorderStyle(Theme.DimStyle)
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Project[/]"))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Path[/]"))
|
||||
.AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(12));
|
||||
|
||||
foreach (var proj in projects)
|
||||
if (_workspace.Projects.Any(p =>
|
||||
string.Equals(WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, p), absolutePath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
|
||||
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
|
||||
var hasConfig = File.Exists(Path.Combine(absPath, "devtool.json"));
|
||||
|
||||
table.AddRow(
|
||||
isCurrent
|
||||
? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/]"
|
||||
: Theme.G(proj.Name),
|
||||
Theme.Faint(proj.Path),
|
||||
hasConfig ? Theme.Ok("ready") : Theme.Fail("no config"));
|
||||
AnsiConsole.MarkupLine(Theme.Warn("Project already exists in workspace."));
|
||||
Thread.Sleep(700);
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var switchable = choices.Where(c => c.Selectable).ToList();
|
||||
if (switchable.Count == 1) // only Cancel
|
||||
var relativePath = Path.GetRelativePath(_workspaceRoot, absolutePath);
|
||||
var useRelative = !relativePath.StartsWith("..", StringComparison.OrdinalIgnoreCase) && !Path.IsPathRooted(relativePath);
|
||||
var projectEntry = new WorkspaceProject
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Warn("No other projects available to switch to."));
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
return null;
|
||||
}
|
||||
Name = new DirectoryInfo(absolutePath).Name,
|
||||
Description = $"External project at {absolutePath}",
|
||||
Path = useRelative ? relativePath : absolutePath,
|
||||
Disabled = false,
|
||||
};
|
||||
|
||||
var selected = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<WorkspaceMenuItem>()
|
||||
.Title($"[{Theme.Green}]Switch to project:[/]")
|
||||
.PageSize(15)
|
||||
.UseConverter(m => m.Display)
|
||||
.AddChoices(switchable));
|
||||
|
||||
return selected.AbsPath; // null = cancelled
|
||||
_workspace.Projects.Add(projectEntry);
|
||||
WorkspaceLoader.Save(_workspaceRoot, _workspace);
|
||||
AnsiConsole.MarkupLine(Theme.Ok("Project added to workspace."));
|
||||
Thread.Sleep(700);
|
||||
}
|
||||
|
||||
private sealed record WorkspaceMenuItem(string Display, string? AbsPath, bool Selectable);
|
||||
|
||||
31
Journal.DevTool/package-lock.json
generated
Normal file
31
Journal.DevTool/package-lock.json
generated
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "DevTool-master",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"tauri-plugin-mic-recorder-api": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/tauri-plugin-mic-recorder-api": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tauri-plugin-mic-recorder-api/-/tauri-plugin-mic-recorder-api-2.0.0.tgz",
|
||||
"integrity": "sha512-04wqYCX4WIlYd6KUY7aS3+W4B5RtnSoVczaQCBSXKpQkEx9XdaaBN05XCee2unxGva0btSXBItFqQSdosnS4jQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
Journal.DevTool/package.json
Normal file
5
Journal.DevTool/package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"tauri-plugin-mic-recorder-api": "^2.0.0"
|
||||
}
|
||||
}
|
||||
63
Journal.DevTool/scripts/README.md
Normal file
63
Journal.DevTool/scripts/README.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Scripts (Python-first, cross-platform)
|
||||
|
||||
This folder now uses Python as the default runtime for orchestration and diagnostics.
|
||||
|
||||
## Preferred scripts
|
||||
|
||||
- `diag.py`: tool probing and install-plan generation (`dotnet`, `python`, `node`, `npm`, `cargo`, `tauri`)
|
||||
- `build.py`: normalized build actions used by SDT workflows
|
||||
- `dev_shell.py`: cross-platform shell bootstrap export/doctor helper
|
||||
- `dotnet-min.py`: resilient `dotnet` wrapper with local cache env
|
||||
- `pip-min.py`: resilient `pip` wrapper with local cache env and repo-local target default
|
||||
- `npm-clean.py`: remove `node_modules` cross-platform
|
||||
- `migration-gate.py`: build/test quality gate
|
||||
- `nuget-export-cache.py`: archive `.nuget` cache
|
||||
- `nuget-import-cache.py`: restore `.nuget` cache from archive
|
||||
- `publish-app.py`: build web or tauri app (cross-platform)
|
||||
- `publish-sidecar.py`: publish sidecar .NET service
|
||||
- `publish-webgateway.py`: publish gateway .NET service and optional web assets
|
||||
- `run-webgateway.py`: run gateway in dev or published-output mode
|
||||
- `publish-output.py`: orchestrate sidecar/web/gateway/desktop publish steps
|
||||
- `sync-output.py`: sweep newest build artifacts into `output/`
|
||||
- `script_common.py`: shared helpers (repo root resolution, env shaping, command runner)
|
||||
- `project.rootHints` supports glob markers (for example `*.sln`) and directory/file markers (`.git`, `package.json`)
|
||||
- Windows PATH token expansion (`%NVM_HOME%`, `%NVM_SYMLINK%`, etc.) is applied during command resolution
|
||||
|
||||
## Shell bootstrap wrappers
|
||||
|
||||
- `dev-shell.ps1`: PowerShell wrapper over `dev_shell.py`
|
||||
- `dev-shell.sh`: bash/zsh wrapper over `dev_shell.py`
|
||||
- `dev-shell.cmd`: cmd wrapper over `dev_shell.py`
|
||||
|
||||
## Legacy scripts
|
||||
|
||||
Existing `.ps1` entrypoints are now compatibility wrappers that forward to Python scripts.
|
||||
`script-common.ps1` is legacy-only compatibility and not used by active SDT workflows.
|
||||
|
||||
Original PowerShell implementations are archived under `scripts/legacy/` as `*.legacy.ps1` for reference during transition.
|
||||
|
||||
## Root Hint Semantics
|
||||
|
||||
`project.rootHints` is evaluated in this order:
|
||||
1. Exact marker exists at candidate root (file or directory)
|
||||
2. Root-level glob match (`glob`)
|
||||
3. Recursive glob match (`rglob`)
|
||||
|
||||
Examples:
|
||||
- `"*.sln"`
|
||||
- `".git"`
|
||||
- `"package.json"`
|
||||
- `"src-tauri/tauri.conf.json"`
|
||||
|
||||
## Quick usage
|
||||
|
||||
```powershell
|
||||
python scripts/diag.py probe --tool dotnet --json
|
||||
python scripts/dotnet-min.py build
|
||||
python scripts/migration-gate.py
|
||||
python scripts/nuget-export-cache.py --output-zip nuget-cache-export.zip
|
||||
python scripts/nuget-import-cache.py --input-zip nuget-cache-export.zip
|
||||
python scripts/npm-clean.py --working-dir .
|
||||
python scripts/dev_shell.py export --shell pwsh --json
|
||||
python scripts/dev_shell.py doctor
|
||||
```
|
||||
57
Journal.DevTool/scripts/WORKFLOWS.md
Normal file
57
Journal.DevTool/scripts/WORKFLOWS.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Cross-Platform Script Workflows
|
||||
|
||||
## 1) Probe toolchain availability
|
||||
|
||||
```powershell
|
||||
python scripts/diag.py probe --tool dotnet --json
|
||||
python scripts/diag.py probe --tool python --json
|
||||
python scripts/diag.py probe --tool node --json
|
||||
python scripts/diag.py probe --tool npm --json
|
||||
python scripts/diag.py probe --tool cargo --json
|
||||
python scripts/diag.py probe --tool tauri --json
|
||||
python scripts/diag.py probe --tool git --json
|
||||
python scripts/diag.py probe --tool docker --json
|
||||
```
|
||||
|
||||
## Shell bootstrap (cross-platform)
|
||||
|
||||
```powershell
|
||||
python scripts/dev_shell.py export --shell pwsh --json
|
||||
python scripts/dev_shell.py doctor
|
||||
```
|
||||
|
||||
## 2) Build and run SDT
|
||||
|
||||
```powershell
|
||||
python scripts/dotnet-min.py build
|
||||
dotnet run --project DevTool.csproj
|
||||
```
|
||||
|
||||
## 3) Run migration gate
|
||||
|
||||
```powershell
|
||||
python scripts/migration-gate.py
|
||||
```
|
||||
|
||||
## 4) Manage NuGet cache
|
||||
|
||||
```powershell
|
||||
python scripts/nuget-export-cache.py --output-zip nuget-cache-export.zip
|
||||
python scripts/nuget-import-cache.py --input-zip nuget-cache-export.zip
|
||||
```
|
||||
|
||||
## 5) Clean Node modules
|
||||
|
||||
```powershell
|
||||
python scripts/npm-clean.py --working-dir .
|
||||
```
|
||||
|
||||
## 6) Build app/gateway bundles
|
||||
|
||||
```powershell
|
||||
python scripts/publish-app.py --target web
|
||||
python scripts/publish-sidecar.py --project path/to/sidecar.csproj
|
||||
python scripts/publish-webgateway.py --project path/to/gateway.csproj --skip-web-assets
|
||||
python scripts/publish-output.py --dry-run
|
||||
python scripts/sync-output.py
|
||||
```
|
||||
39
Journal.DevTool/scripts/_pwsh-python-shim.ps1
Normal file
39
Journal.DevTool/scripts/_pwsh-python-shim.ps1
Normal file
@ -0,0 +1,39 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Resolve-SdtPython {
|
||||
$candidates = @('python')
|
||||
if ($IsWindows) { $candidates += 'py' } else { $candidates += 'python3' }
|
||||
foreach ($c in $candidates) {
|
||||
try {
|
||||
& $c --version *> $null
|
||||
if ($LASTEXITCODE -eq 0) { return $c }
|
||||
} catch {}
|
||||
}
|
||||
return 'python'
|
||||
}
|
||||
|
||||
function Resolve-SdtScriptPath {
|
||||
param([Parameter(Mandatory=$true)][string]$ScriptName)
|
||||
|
||||
$bundled = Join-Path $PSScriptRoot $ScriptName
|
||||
if (Test-Path $bundled) { return $bundled }
|
||||
|
||||
$project = Join-Path (Join-Path $PSScriptRoot '..') ('scripts\\' + $ScriptName)
|
||||
if (Test-Path $project) { return (Resolve-Path $project).Path }
|
||||
|
||||
throw "Python helper script not found: $ScriptName"
|
||||
}
|
||||
|
||||
function Invoke-SdtPythonScript {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$ScriptName,
|
||||
[string[]]$ForwardArgs = @()
|
||||
)
|
||||
|
||||
$python = Resolve-SdtPython
|
||||
$scriptPath = Resolve-SdtScriptPath -ScriptName $ScriptName
|
||||
|
||||
& $python $scriptPath @ForwardArgs
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
419
Journal.DevTool/scripts/build.py
Normal file
419
Journal.DevTool/scripts/build.py
Normal file
@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from script_common import resolve_command
|
||||
|
||||
|
||||
def run_step(command, args, cwd):
|
||||
resolved = resolve_command(command)
|
||||
if shutil.which(resolved) is None and not pathlib.Path(resolved).exists():
|
||||
return {
|
||||
"command": resolved,
|
||||
"args": args,
|
||||
"cwd": cwd,
|
||||
"exit_code": 127,
|
||||
"elapsed_seconds": 0.0,
|
||||
"status": "failed",
|
||||
"failure_reason": f"command_not_found:{resolved}",
|
||||
}
|
||||
|
||||
started = time.time()
|
||||
proc = subprocess.run([resolved, *args], cwd=cwd, check=False)
|
||||
elapsed = round(time.time() - started, 3)
|
||||
return {
|
||||
"command": resolved,
|
||||
"args": args,
|
||||
"cwd": cwd,
|
||||
"exit_code": proc.returncode,
|
||||
"elapsed_seconds": elapsed,
|
||||
"status": "ok" if proc.returncode == 0 else "failed",
|
||||
"failure_reason": None if proc.returncode == 0 else f"non_zero_exit:{proc.returncode}",
|
||||
}
|
||||
|
||||
|
||||
def resolve_python_executable():
|
||||
candidates = ["py", "python"] if os.name == "nt" else ["python3", "python"]
|
||||
for c in candidates:
|
||||
if shutil.which(c):
|
||||
return c
|
||||
return "python"
|
||||
|
||||
|
||||
def parse_common(parser):
|
||||
parser.add_argument("--project-root", required=True)
|
||||
parser.add_argument("--working-dir", default=".")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
|
||||
|
||||
def resolve_cwd(project_root, working_dir):
|
||||
return os.path.abspath(os.path.join(project_root, working_dir))
|
||||
|
||||
|
||||
EXCLUDED_SCAN_DIRS = {".git", "node_modules", "bin", "obj", ".venv", "venv", ".sdt", "dist", "build"}
|
||||
|
||||
|
||||
def discover_dotnet_target(project_root: str, cwd: str):
|
||||
# Prefer nearest/top-level solution from cwd, then csproj, then bounded scan from project root.
|
||||
local_sln = sorted(pathlib.Path(cwd).glob("*.sln"))
|
||||
if len(local_sln) == 1:
|
||||
return str(local_sln[0])
|
||||
|
||||
local_csproj = sorted(pathlib.Path(cwd).glob("*.csproj"))
|
||||
if len(local_csproj) == 1:
|
||||
return str(local_csproj[0])
|
||||
|
||||
sln_hits = bounded_find_files(project_root, ".sln", max_depth=4)
|
||||
if len(sln_hits) == 1:
|
||||
return sln_hits[0]
|
||||
|
||||
csproj_hits = bounded_find_files(project_root, ".csproj", max_depth=4)
|
||||
if len(csproj_hits) == 1:
|
||||
return csproj_hits[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def bounded_find_files(root: str, extension: str, max_depth: int):
|
||||
root_path = pathlib.Path(root).resolve()
|
||||
results = []
|
||||
for current_root, dirs, files in os.walk(root_path):
|
||||
rel = pathlib.Path(current_root).resolve().relative_to(root_path)
|
||||
depth = len(rel.parts)
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_SCAN_DIRS]
|
||||
if depth > max_depth:
|
||||
dirs[:] = []
|
||||
continue
|
||||
|
||||
for name in files:
|
||||
if name.lower().endswith(extension.lower()):
|
||||
results.append(str(pathlib.Path(current_root) / name))
|
||||
return sorted(results)
|
||||
|
||||
|
||||
def run_dotnet_action(project_root, working_dir, verb):
|
||||
cwd = resolve_cwd(project_root, working_dir)
|
||||
args = [verb]
|
||||
target = discover_dotnet_target(project_root, cwd)
|
||||
if target:
|
||||
args.append(target)
|
||||
step = run_step("dotnet", args, cwd)
|
||||
if target:
|
||||
step["resolved_target"] = target
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def _deps_hash(app_root):
|
||||
h = hashlib.sha256()
|
||||
for name in ("package.json", "package-lock.json"):
|
||||
p = pathlib.Path(app_root) / name
|
||||
if p.exists():
|
||||
h.update(p.read_bytes())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def ensure_npm_dependencies(app_root):
|
||||
node_modules = pathlib.Path(app_root) / "node_modules"
|
||||
deps_hash_file = node_modules / ".sdt-deps.sha256"
|
||||
expected = _deps_hash(app_root)
|
||||
|
||||
should_install = not node_modules.exists()
|
||||
if not should_install:
|
||||
if not deps_hash_file.exists():
|
||||
should_install = True
|
||||
else:
|
||||
current = deps_hash_file.read_text(encoding="utf-8").strip()
|
||||
should_install = current != expected
|
||||
|
||||
if not should_install:
|
||||
return {"installed": False, "reason": "deps_unchanged"}
|
||||
|
||||
lock_exists = (pathlib.Path(app_root) / "package-lock.json").exists()
|
||||
install_args = ["ci", "--no-audit", "--fund=false"] if lock_exists else ["install", "--no-audit", "--fund=false"]
|
||||
install_step = run_step("npm", install_args, app_root)
|
||||
if install_step["exit_code"] != 0:
|
||||
if lock_exists and install_args[0] == "ci":
|
||||
fallback = run_step("npm", ["install", "--no-audit", "--fund=false"], app_root)
|
||||
if fallback["exit_code"] != 0:
|
||||
fallback["failure_reason"] = "deps_install_failed_after_ci_fallback"
|
||||
return {"installed": True, "reason": "install_failed", "step": fallback}
|
||||
install_step = fallback
|
||||
else:
|
||||
return {"installed": True, "reason": "install_failed", "step": install_step}
|
||||
|
||||
node_modules.mkdir(parents=True, exist_ok=True)
|
||||
deps_hash_file.write_text(expected, encoding="utf-8")
|
||||
return {"installed": True, "reason": "installed", "step": install_step}
|
||||
|
||||
|
||||
def action_dotnet_build(args):
|
||||
return run_dotnet_action(args.project_root, args.working_dir, "build")
|
||||
|
||||
|
||||
def action_dotnet_restore(args):
|
||||
return run_dotnet_action(args.project_root, args.working_dir, "restore")
|
||||
|
||||
|
||||
def action_dotnet_test(args):
|
||||
return run_dotnet_action(args.project_root, args.working_dir, "test")
|
||||
|
||||
|
||||
def action_dotnet_publish(args):
|
||||
return run_dotnet_action(args.project_root, args.working_dir, "publish")
|
||||
|
||||
|
||||
def action_npm_install(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("npm", ["install"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_npm_ci(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("npm", ["ci"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_npm_build(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
deps = ensure_npm_dependencies(cwd)
|
||||
if deps.get("reason") == "install_failed":
|
||||
step = deps["step"]
|
||||
step["failure_reason"] = "deps_install_failed"
|
||||
return step["exit_code"], step
|
||||
step = run_step("npm", ["run", "build"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_npm_test(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
deps = ensure_npm_dependencies(cwd)
|
||||
if deps.get("reason") == "install_failed":
|
||||
step = deps["step"]
|
||||
step["failure_reason"] = "deps_install_failed"
|
||||
return step["exit_code"], step
|
||||
step = run_step("npm", ["test"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_npm_audit(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("npm", ["audit"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_python_venv_create(args):
|
||||
cwd = resolve_cwd(args.project_root, ".")
|
||||
venv_dir = args.venv_dir or ".venv"
|
||||
step = run_step(resolve_python_executable(), ["-m", "venv", venv_dir], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_python_pip_install(args):
|
||||
cwd = resolve_cwd(args.project_root, ".")
|
||||
req = args.requirements
|
||||
step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_python_pip_sync(args):
|
||||
cwd = resolve_cwd(args.project_root, ".")
|
||||
req = args.requirements
|
||||
step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_python_pytest(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step(resolve_python_executable(), ["-m", "pytest"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_cargo_build(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("cargo", ["build"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_cargo_test(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("cargo", ["test"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_tauri_build(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
deps = ensure_npm_dependencies(cwd)
|
||||
if deps.get("reason") == "install_failed":
|
||||
step = deps["step"]
|
||||
step["failure_reason"] = "deps_install_failed"
|
||||
return step["exit_code"], step
|
||||
|
||||
tauri_args = ["run", "tauri", "build"]
|
||||
if args.no_bundle:
|
||||
tauri_args.extend(["--", "--no-bundle"])
|
||||
step = run_step("npm", tauri_args, cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_git_status(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("git", ["status"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_git_fetch(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("git", ["fetch"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_git_pull(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("git", ["pull"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_git_clean(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("git", ["clean", "-fd"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_docker_build(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("docker", ["build", "."], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_docker_compose_up(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("docker", ["compose", "up", "-d"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def action_docker_compose_down(args):
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
step = run_step("docker", ["compose", "down"], cwd)
|
||||
return 0 if step["exit_code"] == 0 else step["exit_code"], step
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="SDT normalized build actions")
|
||||
sub = parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
p0 = sub.add_parser("dotnet-restore")
|
||||
parse_common(p0)
|
||||
|
||||
p1 = sub.add_parser("dotnet-build")
|
||||
parse_common(p1)
|
||||
|
||||
p1b = sub.add_parser("dotnet-test")
|
||||
parse_common(p1b)
|
||||
|
||||
p1c = sub.add_parser("dotnet-publish")
|
||||
parse_common(p1c)
|
||||
|
||||
p2 = sub.add_parser("npm-install")
|
||||
parse_common(p2)
|
||||
|
||||
p2b = sub.add_parser("npm-ci")
|
||||
parse_common(p2b)
|
||||
|
||||
p3 = sub.add_parser("npm-build")
|
||||
parse_common(p3)
|
||||
|
||||
p3b = sub.add_parser("npm-test")
|
||||
parse_common(p3b)
|
||||
|
||||
p3c = sub.add_parser("npm-audit")
|
||||
parse_common(p3c)
|
||||
|
||||
p4 = sub.add_parser("python-venv-create")
|
||||
parse_common(p4)
|
||||
p4.add_argument("--venv-dir", default=".venv")
|
||||
|
||||
p5 = sub.add_parser("python-pip-install")
|
||||
parse_common(p5)
|
||||
p5.add_argument("--requirements", required=True)
|
||||
|
||||
p5b = sub.add_parser("python-pip-sync")
|
||||
parse_common(p5b)
|
||||
p5b.add_argument("--requirements", required=True)
|
||||
|
||||
p5c = sub.add_parser("python-pytest")
|
||||
parse_common(p5c)
|
||||
|
||||
p6 = sub.add_parser("cargo-build")
|
||||
parse_common(p6)
|
||||
|
||||
p6b = sub.add_parser("cargo-test")
|
||||
parse_common(p6b)
|
||||
|
||||
p7 = sub.add_parser("tauri-build")
|
||||
parse_common(p7)
|
||||
p7.add_argument("--no-bundle", action="store_true")
|
||||
|
||||
p8 = sub.add_parser("git-status")
|
||||
parse_common(p8)
|
||||
|
||||
p9 = sub.add_parser("git-fetch")
|
||||
parse_common(p9)
|
||||
|
||||
p10 = sub.add_parser("git-pull")
|
||||
parse_common(p10)
|
||||
|
||||
p11 = sub.add_parser("git-clean")
|
||||
parse_common(p11)
|
||||
|
||||
p12 = sub.add_parser("docker-build")
|
||||
parse_common(p12)
|
||||
|
||||
p13 = sub.add_parser("docker-compose-up")
|
||||
parse_common(p13)
|
||||
|
||||
p14 = sub.add_parser("docker-compose-down")
|
||||
parse_common(p14)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
handlers = {
|
||||
"dotnet-restore": action_dotnet_restore,
|
||||
"dotnet-build": action_dotnet_build,
|
||||
"dotnet-test": action_dotnet_test,
|
||||
"dotnet-publish": action_dotnet_publish,
|
||||
"npm-install": action_npm_install,
|
||||
"npm-ci": action_npm_ci,
|
||||
"npm-build": action_npm_build,
|
||||
"npm-test": action_npm_test,
|
||||
"npm-audit": action_npm_audit,
|
||||
"python-venv-create": action_python_venv_create,
|
||||
"python-pip-install": action_python_pip_install,
|
||||
"python-pip-sync": action_python_pip_sync,
|
||||
"python-pytest": action_python_pytest,
|
||||
"cargo-build": action_cargo_build,
|
||||
"cargo-test": action_cargo_test,
|
||||
"tauri-build": action_tauri_build,
|
||||
"git-status": action_git_status,
|
||||
"git-fetch": action_git_fetch,
|
||||
"git-pull": action_git_pull,
|
||||
"git-clean": action_git_clean,
|
||||
"docker-build": action_docker_build,
|
||||
"docker-compose-up": action_docker_compose_up,
|
||||
"docker-compose-down": action_docker_compose_down,
|
||||
}
|
||||
|
||||
code, summary = handlers[args.action](args)
|
||||
if args.json:
|
||||
print(json.dumps(summary))
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
17
Journal.DevTool/scripts/dev-shell.cmd
Normal file
17
Journal.DevTool/scripts/dev-shell.cmd
Normal file
@ -0,0 +1,17 @@
|
||||
@echo off
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
|
||||
where py >nul 2>nul
|
||||
if %ERRORLEVEL%==0 (
|
||||
set "PYEXE=py"
|
||||
) else (
|
||||
where python >nul 2>nul
|
||||
if not %ERRORLEVEL%==0 (
|
||||
echo python not found.
|
||||
exit /b 1
|
||||
)
|
||||
set "PYEXE=python"
|
||||
)
|
||||
|
||||
for /f "usebackq delims=" %%L in (`"%PYEXE%" "%SCRIPT_DIR%dev_shell.py" export --shell cmd`) do %%L
|
||||
echo Development shell initialized from Python bootstrap script.
|
||||
21
Journal.DevTool/scripts/dev-shell.ps1
Normal file
21
Journal.DevTool/scripts/dev-shell.ps1
Normal file
@ -0,0 +1,21 @@
|
||||
# Run this in PowerShell before development commands:
|
||||
# . ./scripts/dev-shell.ps1
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. (Join-Path $PSScriptRoot '_pwsh-python-shim.ps1')
|
||||
|
||||
$scriptPath = Resolve-SdtScriptPath -ScriptName 'dev_shell.py'
|
||||
$python = Resolve-SdtPython
|
||||
|
||||
$lines = & $python $scriptPath export --shell pwsh
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to initialize development shell via dev_shell.py"
|
||||
}
|
||||
|
||||
foreach ($line in $lines) {
|
||||
Invoke-Expression $line
|
||||
}
|
||||
|
||||
Write-Host "Development shell initialized from Python bootstrap script."
|
||||
16
Journal.DevTool/scripts/dev-shell.sh
Normal file
16
Journal.DevTool/scripts/dev-shell.sh
Normal file
@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_EXE="python3"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON_EXE="python"
|
||||
else
|
||||
echo "python3/python not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
eval "$("$PYTHON_EXE" "$SCRIPT_DIR/dev_shell.py" export --shell bash)"
|
||||
echo "Development shell initialized from Python bootstrap script."
|
||||
148
Journal.DevTool/scripts/dev_shell.py
Normal file
148
Journal.DevTool/scripts/dev_shell.py
Normal file
@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
from script_common import PROXY_VARS, clean_proxy_env, dotnet_env, ensure_dirs, pip_env, resolve_repo_root
|
||||
|
||||
|
||||
def huggingface_env(repo_root: pathlib.Path) -> dict[str, str]:
|
||||
env = {}
|
||||
hf_home = repo_root / ".cache" / "huggingface"
|
||||
hf_hub_cache = hf_home / "hub"
|
||||
ensure_dirs([hf_hub_cache])
|
||||
env["HF_HOME"] = str(hf_home)
|
||||
env["HUGGINGFACE_HUB_CACHE"] = str(hf_hub_cache)
|
||||
env["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def resolved_env(repo_root: pathlib.Path) -> dict[str, str]:
|
||||
env = {}
|
||||
dotnet = dotnet_env(repo_root)
|
||||
pip = pip_env(repo_root)
|
||||
hf = huggingface_env(repo_root)
|
||||
|
||||
dotnet_keys = [
|
||||
"DOTNET_CLI_HOME",
|
||||
"NUGET_PACKAGES",
|
||||
"NUGET_HTTP_CACHE_PATH",
|
||||
"DOTNET_SKIP_FIRST_TIME_EXPERIENCE",
|
||||
"DOTNET_ADD_GLOBAL_TOOLS_TO_PATH",
|
||||
"DOTNET_GENERATE_ASPNET_CERTIFICATE",
|
||||
"DOTNET_CLI_TELEMETRY_OPTOUT",
|
||||
"NUGET_CERT_REVOCATION_MODE",
|
||||
]
|
||||
pip_keys = [
|
||||
"PIP_CACHE_DIR",
|
||||
"PIP_DISABLE_PIP_VERSION_CHECK",
|
||||
"PIP_DEFAULT_TIMEOUT",
|
||||
"PIP_RETRIES",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
]
|
||||
for key in dotnet_keys:
|
||||
env[key] = dotnet[key]
|
||||
for key in pip_keys:
|
||||
env[key] = pip[key]
|
||||
env.update(hf)
|
||||
clean_proxy_env(env)
|
||||
return env
|
||||
|
||||
|
||||
def export_lines(shell: str, env_map: dict[str, str]) -> list[str]:
|
||||
def sh_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
if shell == "pwsh":
|
||||
lines = [f"Remove-Item Env:{k} -ErrorAction SilentlyContinue" for k in PROXY_VARS]
|
||||
lines.extend(f"$env:{k} = \"{v.replace('\"', '`\"')}\"" for k, v in env_map.items())
|
||||
return lines
|
||||
if shell in ("bash", "zsh"):
|
||||
lines = [f"unset {k}" for k in PROXY_VARS]
|
||||
lines.extend(f"export {k}={sh_quote(v)}" for k, v in env_map.items())
|
||||
return lines
|
||||
if shell == "cmd":
|
||||
lines = [f"set {k}=" for k in PROXY_VARS]
|
||||
lines.extend(f"set {k}={v}" for k, v in env_map.items())
|
||||
return lines
|
||||
raise ValueError(shell)
|
||||
|
||||
|
||||
def cmd_export(args):
|
||||
try:
|
||||
repo_root = resolve_repo_root(args.project_root)
|
||||
except Exception as ex:
|
||||
print(f"Failed to resolve project root: {ex}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
env_map = resolved_env(repo_root)
|
||||
payload = {
|
||||
"projectRoot": str(repo_root),
|
||||
"env": env_map,
|
||||
"createdDirs": [
|
||||
str(repo_root / ".dotnet_home"),
|
||||
str(repo_root / ".nuget" / "packages"),
|
||||
str(repo_root / ".nuget" / "http-cache"),
|
||||
str(repo_root / ".pip" / "cache"),
|
||||
str(repo_root / ".tmp" / "pip-temp"),
|
||||
str(repo_root / ".cache" / "huggingface" / "hub"),
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
try:
|
||||
lines = export_lines(args.shell, env_map)
|
||||
except ValueError:
|
||||
print(f"Unsupported shell target: {args.shell}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(payload))
|
||||
else:
|
||||
for line in lines:
|
||||
print(line)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_doctor(args):
|
||||
try:
|
||||
repo_root = resolve_repo_root(args.project_root)
|
||||
except Exception as ex:
|
||||
print(f"Failed to resolve project root: {ex}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
env_map = resolved_env(repo_root)
|
||||
checks = {
|
||||
"repo_root": str(repo_root),
|
||||
"dotnet_home_exists": (repo_root / ".dotnet_home").exists(),
|
||||
"nuget_cache_exists": (repo_root / ".nuget" / "packages").exists(),
|
||||
"pip_cache_exists": (repo_root / ".pip" / "cache").exists(),
|
||||
"hf_cache_exists": (repo_root / ".cache" / "huggingface" / "hub").exists(),
|
||||
"env_count": len(env_map),
|
||||
}
|
||||
print(json.dumps(checks))
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="SDT cross-platform shell bootstrap helper")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_export = sub.add_parser("export", help="Print env exports for a shell")
|
||||
p_export.add_argument("--shell", required=True)
|
||||
p_export.add_argument("--project-root")
|
||||
p_export.add_argument("--json", action="store_true")
|
||||
|
||||
p_doctor = sub.add_parser("doctor", help="Validate env bootstrap paths")
|
||||
p_doctor.add_argument("--project-root")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "export":
|
||||
return cmd_export(args)
|
||||
return cmd_doctor(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
128
Journal.DevTool/scripts/diag.py
Normal file
128
Journal.DevTool/scripts/diag.py
Normal file
@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from script_common import resolve_command
|
||||
|
||||
|
||||
def run_capture(cmd):
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
out = (proc.stdout or "").strip()
|
||||
err = (proc.stderr or "").strip()
|
||||
text = out if out else err
|
||||
return proc.returncode == 0, text
|
||||
except Exception as ex:
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
def probe_tool(tool):
|
||||
mapping = {
|
||||
"dotnet": ["dotnet", "--version"],
|
||||
"node": ["node", "--version"],
|
||||
"npm": ["npm", "--version"],
|
||||
"python": ["python", "--version"],
|
||||
"cargo": ["cargo", "--version"],
|
||||
"tauri": ["tauri", "--version"],
|
||||
"git": ["git", "--version"],
|
||||
"docker": ["docker", "--version"],
|
||||
}
|
||||
cmd = mapping.get(tool, [tool, "--version"])
|
||||
resolved = resolve_command(cmd[0])
|
||||
if shutil.which(resolved) is None and not os.path.exists(resolved):
|
||||
return {"tool": tool, "available": False, "version": None, "details": f"{cmd[0]} not found in PATH"}
|
||||
cmd = [resolved, *cmd[1:]]
|
||||
ok, text = run_capture(cmd)
|
||||
return {"tool": tool, "available": ok, "version": text if ok else None, "details": None if ok else text}
|
||||
|
||||
|
||||
def install_plan(tool):
|
||||
is_windows = platform.system().lower().startswith("win")
|
||||
if is_windows:
|
||||
plans = {
|
||||
"dotnet": [("winget", ["install", "Microsoft.DotNet.SDK.10"])],
|
||||
"node": [("winget", ["install", "OpenJS.NodeJS.LTS"])],
|
||||
"npm": [("winget", ["install", "OpenJS.NodeJS.LTS"])],
|
||||
"python": [("winget", ["install", "Python.Python.3.12"])],
|
||||
"cargo": [("winget", ["install", "Rustlang.Rustup"])],
|
||||
"tauri": [("npm", ["install", "-g", "@tauri-apps/cli"])],
|
||||
"git": [("winget", ["install", "Git.Git"])],
|
||||
"docker": [("winget", ["install", "Docker.DockerDesktop"])],
|
||||
}
|
||||
else:
|
||||
plans = {
|
||||
"dotnet": [("sh", ["-c", "echo install dotnet sdk with your package manager"])],
|
||||
"node": [("sh", ["-c", "echo install nodejs with your package manager"])],
|
||||
"npm": [("sh", ["-c", "echo install npm with your package manager"])],
|
||||
"python": [("sh", ["-c", "echo install python3 with your package manager"])],
|
||||
"cargo": [("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh"])],
|
||||
"tauri": [("npm", ["install", "-g", "@tauri-apps/cli"])],
|
||||
"git": [("sh", ["-c", "echo install git with your package manager"])],
|
||||
"docker": [("sh", ["-c", "echo install docker with your package manager"])],
|
||||
}
|
||||
|
||||
cmds = plans.get(tool, [])
|
||||
return {
|
||||
"tool": tool,
|
||||
"supported": len(cmds) > 0,
|
||||
"summary": f"Install plan for {tool} on {platform.system()}",
|
||||
"commands": [{"command": c, "args": a} for c, a in cmds],
|
||||
}
|
||||
|
||||
|
||||
def run_install(tool):
|
||||
plan = install_plan(tool)
|
||||
if not plan["supported"]:
|
||||
return 2
|
||||
for cmd in plan["commands"]:
|
||||
proc = subprocess.run([cmd["command"], *cmd["args"]], check=False)
|
||||
if proc.returncode != 0:
|
||||
return proc.returncode
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="SDT diagnostics and install planner")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_probe = sub.add_parser("probe")
|
||||
p_probe.add_argument("--tool", required=True)
|
||||
p_probe.add_argument("--json", action="store_true")
|
||||
|
||||
p_plan = sub.add_parser("install-plan")
|
||||
p_plan.add_argument("--tool", required=True)
|
||||
p_plan.add_argument("--json", action="store_true")
|
||||
|
||||
p_run = sub.add_parser("install-run")
|
||||
p_run.add_argument("--tool", required=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "probe":
|
||||
result = probe_tool(args.tool.lower())
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
print(result)
|
||||
return 0 if result["available"] else 1
|
||||
|
||||
if args.cmd == "install-plan":
|
||||
result = install_plan(args.tool.lower())
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
print(result)
|
||||
return 0 if result["supported"] else 2
|
||||
|
||||
if args.cmd == "install-run":
|
||||
return run_install(args.tool.lower())
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
34
Journal.DevTool/scripts/dotnet-min.py
Normal file
34
Journal.DevTool/scripts/dotnet-min.py
Normal file
@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from script_common import dotnet_env, resolve_repo_root, run
|
||||
|
||||
|
||||
DOTNET_SAFE_CMDS = {"restore", "build", "run", "test", "publish", "pack"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Cross-platform minimal dotnet wrapper")
|
||||
parser.add_argument("dotnet_args", nargs=argparse.REMAINDER)
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.dotnet_args:
|
||||
print("Usage: python scripts/dotnet-min.py <dotnet args>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
dotnet_args = list(args.dotnet_args)
|
||||
cmd = dotnet_args[0].lower()
|
||||
|
||||
if cmd in DOTNET_SAFE_CMDS:
|
||||
dotnet_args.extend(["-p:RestoreIgnoreFailedSources=true", "-p:NuGetAudit=false"])
|
||||
if cmd == "restore":
|
||||
dotnet_args.append("--ignore-failed-sources")
|
||||
|
||||
return run("dotnet", dotnet_args, repo_root, env=dotnet_env(repo_root))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
44
Journal.DevTool/scripts/migration-gate.py
Normal file
44
Journal.DevTool/scripts/migration-gate.py
Normal file
@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from script_common import resolve_repo_root
|
||||
|
||||
|
||||
def run_step(repo_root: Path, title: str, command: list[str]) -> int:
|
||||
print(f"\n== {title} ==")
|
||||
print("$", " ".join(command))
|
||||
proc = subprocess.run(command, cwd=str(repo_root), check=False)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Cross-platform migration quality gate")
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
parser.add_argument("--skip-tests", action="store_true")
|
||||
parser.add_argument("--test-project", default=None, help="Optional test csproj path")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
|
||||
code = run_step(repo_root, "Build", [sys.executable, "scripts/dotnet-min.py", "build"])
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
if not args.skip_tests:
|
||||
if args.test_project:
|
||||
test_cmd = [sys.executable, "scripts/dotnet-min.py", "test", args.test_project]
|
||||
else:
|
||||
test_cmd = [sys.executable, "scripts/dotnet-min.py", "test"]
|
||||
code = run_step(repo_root, "Tests", test_cmd)
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
print("\nMigration gate passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
35
Journal.DevTool/scripts/npm-clean.py
Normal file
35
Journal.DevTool/scripts/npm-clean.py
Normal file
@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from script_common import resolve_repo_root
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Cross-platform node_modules cleanup")
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
parser.add_argument("--working-dir", default=".")
|
||||
parser.add_argument("--also-cache", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
work_dir = (repo_root / args.working_dir).resolve()
|
||||
node_modules = work_dir / "node_modules"
|
||||
if node_modules.exists():
|
||||
shutil.rmtree(node_modules)
|
||||
print(f"Removed: {node_modules}")
|
||||
else:
|
||||
print(f"Not found: {node_modules}")
|
||||
|
||||
if args.also_cache:
|
||||
npm_cache = repo_root / ".npm" / "cache"
|
||||
if npm_cache.exists():
|
||||
shutil.rmtree(npm_cache)
|
||||
print(f"Removed: {npm_cache}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
42
Journal.DevTool/scripts/nuget-export-cache.py
Normal file
42
Journal.DevTool/scripts/nuget-export-cache.py
Normal file
@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from script_common import resolve_repo_root
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Export local NuGet cache to zip")
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
parser.add_argument("--output-zip", default="nuget-cache-export.zip")
|
||||
parser.add_argument("--include-dotnet-home", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
output_zip = (repo_root / args.output_zip).resolve()
|
||||
|
||||
nuget_dir = repo_root / ".nuget"
|
||||
dotnet_home = repo_root / ".dotnet_home"
|
||||
if not nuget_dir.exists():
|
||||
print(f"NuGet cache not found: {nuget_dir}")
|
||||
return 2
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
stage = Path(td) / "cache-export"
|
||||
stage.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(nuget_dir, stage / ".nuget")
|
||||
if args.include_dotnet_home and dotnet_home.exists():
|
||||
shutil.copytree(dotnet_home, stage / ".dotnet_home")
|
||||
manifest = stage / "nuget-cache-manifest.txt"
|
||||
manifest.write_text("exported_by=nuget-export-cache.py\n", encoding="utf-8")
|
||||
archive_base = str(output_zip.with_suffix(""))
|
||||
shutil.make_archive(archive_base, "zip", root_dir=str(stage))
|
||||
|
||||
print(f"Exported cache: {output_zip}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
28
Journal.DevTool/scripts/nuget-import-cache.py
Normal file
28
Journal.DevTool/scripts/nuget-import-cache.py
Normal file
@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from script_common import resolve_repo_root
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Import NuGet cache from zip")
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
parser.add_argument("--input-zip", default="nuget-cache-export.zip")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
input_zip = (repo_root / args.input_zip).resolve()
|
||||
if not input_zip.exists():
|
||||
print(f"Input zip not found: {input_zip}")
|
||||
return 2
|
||||
|
||||
shutil.unpack_archive(str(input_zip), extract_dir=str(repo_root))
|
||||
print(f"Imported cache from: {input_zip}")
|
||||
print("Run `python scripts/dotnet-min.py restore` to validate restore in this repo.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
35
Journal.DevTool/scripts/pip-min.py
Normal file
35
Journal.DevTool/scripts/pip-min.py
Normal file
@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from script_common import pip_env, resolve_repo_root, run
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Cross-platform minimal pip wrapper")
|
||||
parser.add_argument("pip_args", nargs=argparse.REMAINDER)
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.pip_args:
|
||||
print("Usage: python scripts/pip-min.py <pip args>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
pip_args = list(args.pip_args)
|
||||
|
||||
# Preserve legacy behavior: for bare install, default target to repo-local deps.
|
||||
if pip_args and pip_args[0].lower() == "install":
|
||||
has_target = any(a in ("--target", "--prefix") for a in pip_args)
|
||||
if not has_target:
|
||||
pip_args = [a for a in pip_args if a != "--user"]
|
||||
target = repo_root / ".pydeps" / f"py{sys.version_info.major}{sys.version_info.minor}"
|
||||
os.makedirs(target, exist_ok=True)
|
||||
pip_args.extend(["--target", str(target)])
|
||||
|
||||
return run(sys.executable, ["-m", "pip", *pip_args], repo_root, env=pip_env(repo_root))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
46
Journal.DevTool/scripts/pip_safe.py
Normal file
46
Journal.DevTool/scripts/pip_safe.py
Normal file
@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def _mkdtemp_compat(
|
||||
suffix: str | None = None,
|
||||
prefix: str | None = None,
|
||||
dir: str | None = None,
|
||||
) -> str:
|
||||
# Python 3.14 on some Windows hosts creates mkdtemp dirs that are
|
||||
# immediately non-writable by the same process when mode=0o700 is used.
|
||||
# pip relies heavily on tempfile; force 0o777 for compatibility.
|
||||
if dir is None:
|
||||
dir = tempfile.gettempdir()
|
||||
if prefix is None:
|
||||
prefix = tempfile.template
|
||||
if suffix is None:
|
||||
suffix = ""
|
||||
|
||||
names = tempfile._get_candidate_names()
|
||||
for _ in range(tempfile.TMP_MAX):
|
||||
name = next(names)
|
||||
path = os.path.join(dir, f"{prefix}{name}{suffix}")
|
||||
try:
|
||||
os.mkdir(path, 0o777)
|
||||
return path
|
||||
except FileExistsError:
|
||||
continue
|
||||
|
||||
raise FileExistsError("No usable temporary directory name found.")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
tempfile.mkdtemp = _mkdtemp_compat # type: ignore[assignment]
|
||||
|
||||
from pip._internal.cli.main import main as pip_main
|
||||
|
||||
return int(pip_main(argv))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(__import__("sys").argv[1:]))
|
||||
|
||||
91
Journal.DevTool/scripts/publish-app.py
Normal file
91
Journal.DevTool/scripts/publish-app.py
Normal file
@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from script_common import find_node_app_root, resolve_repo_root, run, sha256_files
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Cross-platform web/tauri publish helper")
|
||||
parser.add_argument("--target", choices=["web", "tauri"], default="web")
|
||||
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
|
||||
parser.add_argument("--tauri-bundles", choices=["none", "nsis", "msi"], default="none")
|
||||
parser.add_argument("--install-deps", action="store_true")
|
||||
parser.add_argument("--skip-install", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
parser.add_argument("--app-root", default=None, help="Relative or absolute app root with package.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
app_root = find_node_app_root(repo_root, args.app_root)
|
||||
if app_root is None:
|
||||
print("Unable to locate app root (no unique package.json found).")
|
||||
return 2
|
||||
|
||||
package_json = app_root / "package.json"
|
||||
lock_file = app_root / "package-lock.json"
|
||||
node_modules = app_root / "node_modules"
|
||||
deps_hash_file = node_modules / ".sdt-deps.sha256"
|
||||
expected_hash = sha256_files([package_json, lock_file])
|
||||
|
||||
should_install = args.install_deps or not node_modules.exists()
|
||||
if not should_install and not args.skip_install:
|
||||
if not deps_hash_file.exists():
|
||||
should_install = True
|
||||
else:
|
||||
current = deps_hash_file.read_text(encoding="utf-8").strip()
|
||||
should_install = current != expected_hash
|
||||
if args.skip_install:
|
||||
should_install = False
|
||||
|
||||
print(f"App root: {app_root}")
|
||||
print(f"Target: {args.target} ({args.configuration})")
|
||||
|
||||
if should_install:
|
||||
install_args = ["ci", "--no-audit", "--fund=false"] if lock_file.exists() else ["install", "--no-audit", "--fund=false"]
|
||||
print("$ npm " + " ".join(install_args))
|
||||
if not args.dry_run:
|
||||
code = run("npm", install_args, app_root)
|
||||
if code != 0:
|
||||
if lock_file.exists() and install_args[0] == "ci":
|
||||
print("npm ci failed (likely lockfile out of sync). Falling back to npm install...")
|
||||
fallback_args = ["install", "--no-audit", "--fund=false"]
|
||||
print("$ npm " + " ".join(fallback_args))
|
||||
code = run("npm", fallback_args, app_root)
|
||||
if code != 0:
|
||||
return code
|
||||
else:
|
||||
return code
|
||||
node_modules.mkdir(parents=True, exist_ok=True)
|
||||
deps_hash_file.write_text(expected_hash, encoding="utf-8")
|
||||
else:
|
||||
print("Skipping dependency install.")
|
||||
|
||||
if args.target == "web":
|
||||
cmd = ["run", "build"]
|
||||
print("$ npm " + " ".join(cmd))
|
||||
if not args.dry_run:
|
||||
return run("npm", cmd, app_root)
|
||||
return 0
|
||||
|
||||
tauri_cmd = ["run", "tauri", "build"]
|
||||
tauri_tail: list[str] = []
|
||||
if args.tauri_bundles == "none":
|
||||
tauri_tail.extend(["--no-bundle"])
|
||||
else:
|
||||
tauri_tail.extend(["--bundles", args.tauri_bundles])
|
||||
if args.configuration == "Debug":
|
||||
tauri_tail.append("--debug")
|
||||
if tauri_tail:
|
||||
tauri_cmd.extend(["--", *tauri_tail])
|
||||
|
||||
print("$ npm " + " ".join(tauri_cmd))
|
||||
if not args.dry_run:
|
||||
return run("npm", tauri_cmd, app_root)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
90
Journal.DevTool/scripts/publish-output.py
Normal file
90
Journal.DevTool/scripts/publish-output.py
Normal file
@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from script_common import find_node_app_root, resolve_repo_root
|
||||
|
||||
|
||||
def run_step(label: str, cmd: list[str], cwd: Path, dry_run: bool) -> int:
|
||||
print(f"\n> {label}")
|
||||
print("$", " ".join(cmd))
|
||||
if dry_run:
|
||||
return 0
|
||||
proc = subprocess.run(cmd, cwd=str(cwd), check=False)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Publish bundled outputs using Python script entrypoints")
|
||||
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
|
||||
parser.add_argument("--runtime", default="win-x64")
|
||||
parser.add_argument("--skip-sidecar", action="store_true")
|
||||
parser.add_argument("--skip-web", action="store_true")
|
||||
parser.add_argument("--skip-webgateway", action="store_true")
|
||||
parser.add_argument("--skip-tauri", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
parser.add_argument("--sidecar-project", default=None)
|
||||
parser.add_argument("--gateway-project", default=None)
|
||||
parser.add_argument("--app-root", default=None)
|
||||
parser.add_argument("--output-dir", default="output")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
output_root = (repo_root / args.output_dir).resolve()
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
py = sys.executable
|
||||
if not args.skip_sidecar:
|
||||
cmd = [py, "scripts/publish-sidecar.py", "--configuration", args.configuration, "--runtime", args.runtime]
|
||||
if args.sidecar_project:
|
||||
cmd.extend(["--project", args.sidecar_project])
|
||||
code = run_step("Publish sidecar", cmd, repo_root, args.dry_run)
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
if not args.skip_web:
|
||||
cmd = [py, "scripts/publish-app.py", "--target", "web", "--configuration", args.configuration]
|
||||
if args.app_root:
|
||||
cmd.extend(["--app-root", args.app_root])
|
||||
code = run_step("Build web", cmd, repo_root, args.dry_run)
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
if not args.skip_webgateway:
|
||||
cmd = [py, "scripts/publish-webgateway.py", "--configuration", args.configuration, "--runtime", args.runtime]
|
||||
if args.gateway_project:
|
||||
cmd.extend(["--project", args.gateway_project])
|
||||
code = run_step("Publish web gateway", cmd, repo_root, args.dry_run)
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
if not args.skip_tauri:
|
||||
cmd = [py, "scripts/publish-app.py", "--target", "tauri", "--configuration", args.configuration, "--tauri-bundles", "none"]
|
||||
if args.app_root:
|
||||
cmd.extend(["--app-root", args.app_root])
|
||||
code = run_step("Build tauri", cmd, repo_root, args.dry_run)
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
app_root = find_node_app_root(repo_root, args.app_root)
|
||||
if app_root is not None:
|
||||
target_dir = app_root / "src-tauri" / "target" / ("debug" if args.configuration == "Debug" else "release")
|
||||
exes = sorted(target_dir.glob("*.exe"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if exes:
|
||||
staged = output_root / exes[0].name
|
||||
if args.dry_run:
|
||||
print(f"Would copy: {exes[0]} -> {staged}")
|
||||
else:
|
||||
shutil.copy2(exes[0], staged)
|
||||
print(f"Staged desktop executable: {staged}")
|
||||
|
||||
print("\nPublish output workflow complete.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
10
Journal.DevTool/scripts/publish-sidecar.ps1
Normal file
10
Journal.DevTool/scripts/publish-sidecar.ps1
Normal file
@ -0,0 +1,10 @@
|
||||
param(
|
||||
[Parameter(ValueFromRemainingArguments = $($true))]
|
||||
[string[]]$ForwardArgs
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
. (Join-Path $PSScriptRoot '_pwsh-python-shim.ps1')
|
||||
Invoke-SdtPythonScript -ScriptName 'publish-sidecar.py' -ForwardArgs $ForwardArgs
|
||||
58
Journal.DevTool/scripts/publish-sidecar.py
Normal file
58
Journal.DevTool/scripts/publish-sidecar.py
Normal file
@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Cross-platform .NET sidecar publish helper")
|
||||
parser.add_argument("--configuration", default="Release")
|
||||
parser.add_argument("--runtime", default="win-x64")
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
parser.add_argument("--project", default=None, help="Relative/absolute path to sidecar csproj")
|
||||
parser.add_argument("--output-dir", default="output")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
output_dir = (repo_root / args.output_dir).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.project:
|
||||
csproj = (repo_root / args.project).resolve()
|
||||
else:
|
||||
csproj = find_csproj_by_keyword(repo_root, ["sidecar"])
|
||||
if csproj is None or not csproj.exists():
|
||||
print("Could not locate sidecar project. Pass --project <path/to/project.csproj>.")
|
||||
return 2
|
||||
|
||||
publish_args = [
|
||||
"publish",
|
||||
str(csproj),
|
||||
"-c",
|
||||
args.configuration,
|
||||
"-r",
|
||||
args.runtime,
|
||||
"--self-contained",
|
||||
"-p:PublishSingleFile=true",
|
||||
"-p:IncludeNativeLibrariesForSelfExtract=true",
|
||||
"-p:RestoreIgnoreFailedSources=true",
|
||||
"-p:NuGetAudit=false",
|
||||
"-o",
|
||||
str(output_dir),
|
||||
]
|
||||
code = run("dotnet", publish_args, repo_root, env=dotnet_env(repo_root))
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
binary_name = csproj.stem + (".exe" if args.runtime.startswith("win-") else "")
|
||||
binary_path = output_dir / binary_name
|
||||
if binary_path.exists():
|
||||
print(f"Published executable: {binary_path}")
|
||||
else:
|
||||
print(f"Publish completed. Output directory: {output_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
78
Journal.DevTool/scripts/publish-webgateway.py
Normal file
78
Journal.DevTool/scripts/publish-webgateway.py
Normal file
@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import shutil
|
||||
|
||||
from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Cross-platform ASP.NET gateway publish helper")
|
||||
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
|
||||
parser.add_argument("--runtime", default="win-x64")
|
||||
parser.add_argument("--self-contained", action="store_true")
|
||||
parser.add_argument("--skip-web-assets", action="store_true")
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
parser.add_argument("--project", default=None, help="Relative/absolute path to gateway csproj")
|
||||
parser.add_argument("--web-build-dir", default=None, help="Relative path to web build assets root")
|
||||
parser.add_argument("--output-dir", default="output/webgateway")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
output_dir = (repo_root / args.output_dir).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.project:
|
||||
csproj = (repo_root / args.project).resolve()
|
||||
else:
|
||||
csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"])
|
||||
if csproj is None or not csproj.exists():
|
||||
print("Could not locate web gateway project. Pass --project <path/to/project.csproj>.")
|
||||
return 2
|
||||
|
||||
publish_args = [
|
||||
"publish",
|
||||
str(csproj),
|
||||
"-c",
|
||||
args.configuration,
|
||||
"-r",
|
||||
args.runtime,
|
||||
"--self-contained",
|
||||
"true" if args.self_contained else "false",
|
||||
"-p:RestoreIgnoreFailedSources=true",
|
||||
"-p:NuGetAudit=false",
|
||||
"-o",
|
||||
str(output_dir),
|
||||
]
|
||||
code = run("dotnet", publish_args, repo_root, env=dotnet_env(repo_root))
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
if not args.skip_web_assets:
|
||||
if args.web_build_dir:
|
||||
web_build_dir = (repo_root / args.web_build_dir).resolve()
|
||||
else:
|
||||
web_build_dir = next((p.parent for p in repo_root.rglob("package.json") if (p.parent / "build").exists()), None)
|
||||
if web_build_dir is not None:
|
||||
web_build_dir = web_build_dir / "build"
|
||||
|
||||
if web_build_dir is None or not web_build_dir.exists():
|
||||
print("Web assets not found. Skip with --skip-web-assets or pass --web-build-dir.")
|
||||
else:
|
||||
web_out = output_dir / "wwwroot"
|
||||
web_out.mkdir(parents=True, exist_ok=True)
|
||||
for item in web_build_dir.iterdir():
|
||||
dst = web_out / item.name
|
||||
if item.is_dir():
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(item, dst)
|
||||
else:
|
||||
shutil.copy2(item, dst)
|
||||
print(f"Copied web assets: {web_out}")
|
||||
|
||||
print(f"Publish completed: {output_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
60
Journal.DevTool/scripts/run-webgateway.py
Normal file
60
Journal.DevTool/scripts/run-webgateway.py
Normal file
@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run gateway in dev or output mode")
|
||||
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
|
||||
parser.add_argument("--urls", default="http://0.0.0.0:5180")
|
||||
parser.add_argument("--project-root", default=None, help="Runtime project root exposed via SDT_PROJECT_ROOT")
|
||||
parser.add_argument("--mode", choices=["Dev", "Output"], default="Dev")
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
parser.add_argument("--project", default=None, help="Gateway csproj path")
|
||||
parser.add_argument("--output-exe", default=None, help="Published gateway executable path")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
effective_project_root = Path(args.project_root).resolve() if args.project_root else repo_root
|
||||
if not effective_project_root.exists():
|
||||
print(f"Project root does not exist: {effective_project_root}")
|
||||
return 2
|
||||
|
||||
env = dotnet_env(repo_root)
|
||||
env["SDT_PROJECT_ROOT"] = str(effective_project_root)
|
||||
|
||||
if args.mode == "Output":
|
||||
exe_path = Path(args.output_exe).resolve() if args.output_exe else (repo_root / "output" / "webgateway" / ("webgateway.exe" if os.name == "nt" else "webgateway"))
|
||||
if not exe_path.exists():
|
||||
print(f"Output executable not found: {exe_path}")
|
||||
return 2
|
||||
return run(str(exe_path), ["--urls", args.urls], repo_root, env=env)
|
||||
|
||||
if args.project:
|
||||
csproj = (repo_root / args.project).resolve()
|
||||
else:
|
||||
csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"])
|
||||
if csproj is None or not csproj.exists():
|
||||
print("Could not locate gateway project. Pass --project <path/to/project.csproj>.")
|
||||
return 2
|
||||
|
||||
run_args = [
|
||||
"run",
|
||||
"--project",
|
||||
str(csproj),
|
||||
"-c",
|
||||
args.configuration,
|
||||
"--no-launch-profile",
|
||||
"--urls",
|
||||
args.urls,
|
||||
"-p:RestoreIgnoreFailedSources=true",
|
||||
"-p:NuGetAudit=false",
|
||||
]
|
||||
return run("dotnet", run_args, repo_root, env=env)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
124
Journal.DevTool/scripts/script-common.ps1
Normal file
124
Journal.DevTool/scripts/script-common.ps1
Normal file
@ -0,0 +1,124 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Legacy compatibility helper only.
|
||||
# Active SDT workflows and shell bootstrap now use Python scripts.
|
||||
|
||||
function Clear-SdtProxyEnv {
|
||||
Remove-Item Env:HTTP_PROXY -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:HTTPS_PROXY -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:ALL_PROXY -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:http_proxy -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:https_proxy -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:all_proxy -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GIT_HTTP_PROXY -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GIT_HTTPS_PROXY -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:PIP_NO_INDEX -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Resolve-SdtRepoRoot {
|
||||
param([string]$StartPath)
|
||||
|
||||
$candidateStarts = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($StartPath)) {
|
||||
$candidateStarts += $StartPath
|
||||
}
|
||||
$cwd = (Get-Location).Path
|
||||
if (-not [string]::IsNullOrWhiteSpace($cwd) -and ($candidateStarts -notcontains $cwd)) {
|
||||
$candidateStarts += $cwd
|
||||
}
|
||||
|
||||
$override = $env:SDT_REPO_ROOT
|
||||
if ([string]::IsNullOrWhiteSpace($override)) {
|
||||
$override = $env:JOURNAL_REPO_ROOT # backward compatibility
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($override)) {
|
||||
$overridePath = [System.IO.Path]::GetFullPath($override)
|
||||
if (Test-Path (Join-Path $overridePath "devtool.json")) {
|
||||
return $overridePath
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($start in $candidateStarts) {
|
||||
$cursor = [System.IO.Path]::GetFullPath($start)
|
||||
while (-not [string]::IsNullOrWhiteSpace($cursor)) {
|
||||
if (Test-Path (Join-Path $cursor "devtool.json")) {
|
||||
return $cursor
|
||||
}
|
||||
$parent = [System.IO.Directory]::GetParent($cursor)
|
||||
if ($null -eq $parent -or $parent.FullName -eq $cursor) {
|
||||
break
|
||||
}
|
||||
$cursor = $parent.FullName
|
||||
}
|
||||
}
|
||||
|
||||
if (Get-Command git -ErrorAction SilentlyContinue) {
|
||||
foreach ($start in $candidateStarts) {
|
||||
try {
|
||||
$gitRoot = & git -C $start rev-parse --show-toplevel 2>$null
|
||||
if ($? -and -not [string]::IsNullOrWhiteSpace($gitRoot)) {
|
||||
return [System.IO.Path]::GetFullPath($gitRoot.Trim())
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
||||
throw "Could not locate repository root. Ensure a devtool.json exists in the project root."
|
||||
}
|
||||
|
||||
function Initialize-SdtDotnetEnv {
|
||||
param([Parameter(Mandatory = $true)][string]$RepoRoot)
|
||||
|
||||
$dotnetCliHome = Join-Path $RepoRoot ".dotnet_home"
|
||||
$nugetPackages = Join-Path $RepoRoot ".nuget\packages"
|
||||
$nugetHttpCachePath = Join-Path $RepoRoot ".nuget\http-cache"
|
||||
|
||||
$env:DOTNET_CLI_HOME = $dotnetCliHome
|
||||
$env:NUGET_PACKAGES = $nugetPackages
|
||||
$env:NUGET_HTTP_CACHE_PATH = $nugetHttpCachePath
|
||||
$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1"
|
||||
$env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = "0"
|
||||
$env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "0"
|
||||
$env:DOTNET_CLI_TELEMETRY_OPTOUT = "1"
|
||||
$env:NUGET_CERT_REVOCATION_MODE = "offline"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $dotnetCliHome, $nugetPackages, $nugetHttpCachePath | Out-Null
|
||||
}
|
||||
|
||||
function Initialize-SdtPipEnv {
|
||||
param([Parameter(Mandatory = $true)][string]$RepoRoot)
|
||||
|
||||
$pipCacheDir = Join-Path $RepoRoot ".pip\cache"
|
||||
$pipTempDir = Join-Path $RepoRoot ".tmp\pip-temp"
|
||||
|
||||
$env:PIP_CACHE_DIR = $pipCacheDir
|
||||
$env:TEMP = $pipTempDir
|
||||
$env:TMP = $pipTempDir
|
||||
$env:PIP_DISABLE_PIP_VERSION_CHECK = "1"
|
||||
$env:PIP_DEFAULT_TIMEOUT = "30"
|
||||
$env:PIP_RETRIES = "2"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $pipCacheDir, $pipTempDir | Out-Null
|
||||
}
|
||||
|
||||
function Initialize-SdtHuggingFaceEnv {
|
||||
param([Parameter(Mandatory = $true)][string]$RepoRoot)
|
||||
|
||||
$hfHome = Join-Path $RepoRoot ".cache\huggingface"
|
||||
$hfHubCache = Join-Path $hfHome "hub"
|
||||
|
||||
$env:HF_HOME = $hfHome
|
||||
$env:HUGGINGFACE_HUB_CACHE = $hfHubCache
|
||||
$env:HF_HUB_DISABLE_SYMLINKS_WARNING = "1"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $hfHubCache | Out-Null
|
||||
}
|
||||
|
||||
# Backward-compatible aliases (legacy script calls)
|
||||
Set-Alias -Name Clear-JournalProxyEnv -Value Clear-SdtProxyEnv -Scope Script
|
||||
Set-Alias -Name Resolve-JournalRepoRoot -Value Resolve-SdtRepoRoot -Scope Script
|
||||
Set-Alias -Name Initialize-JournalDotnetEnv -Value Initialize-SdtDotnetEnv -Scope Script
|
||||
Set-Alias -Name Initialize-JournalPipEnv -Value Initialize-SdtPipEnv -Scope Script
|
||||
Set-Alias -Name Initialize-JournalHuggingFaceEnv -Value Initialize-SdtHuggingFaceEnv -Scope Script
|
||||
315
Journal.DevTool/scripts/script_common.py
Normal file
315
Journal.DevTool/scripts/script_common.py
Normal file
@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Dict, Iterable, List, Sequence
|
||||
|
||||
|
||||
PROXY_VARS = [
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"GIT_HTTP_PROXY",
|
||||
"GIT_HTTPS_PROXY",
|
||||
"PIP_NO_INDEX",
|
||||
]
|
||||
|
||||
|
||||
def resolve_repo_root(start: str | None = None) -> pathlib.Path:
|
||||
base = pathlib.Path(start or os.getcwd()).resolve()
|
||||
|
||||
# Preferred marker for SDT-managed projects.
|
||||
for cur in [base, *base.parents]:
|
||||
cfg = cur / "devtool.json"
|
||||
if cfg.exists():
|
||||
hints = load_project_root_hints(cur)
|
||||
if not hints:
|
||||
return cur
|
||||
if any(_hint_matches(cur, hint) for hint in hints):
|
||||
return cur
|
||||
|
||||
# Fall back to git root when available.
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(base), "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
git_root = proc.stdout.strip()
|
||||
if git_root:
|
||||
return pathlib.Path(git_root).resolve()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def load_project_root_hints(repo_root: pathlib.Path) -> list[str]:
|
||||
cfg = repo_root / "devtool.json"
|
||||
if not cfg.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(cfg.read_text(encoding="utf-8"))
|
||||
hints = data.get("project", {}).get("rootHints", [])
|
||||
return [str(x) for x in hints if isinstance(x, str) and x.strip()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def ensure_dirs(paths: List[pathlib.Path]) -> None:
|
||||
for p in paths:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def clean_proxy_env(env: Dict[str, str]) -> None:
|
||||
for k in PROXY_VARS:
|
||||
env.pop(k, None)
|
||||
|
||||
|
||||
def dotnet_env(repo_root: pathlib.Path) -> Dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
clean_proxy_env(env)
|
||||
dotnet_cli_home = repo_root / ".dotnet_home"
|
||||
nuget_packages = repo_root / ".nuget" / "packages"
|
||||
nuget_http_cache = repo_root / ".nuget" / "http-cache"
|
||||
ensure_dirs([dotnet_cli_home, nuget_packages, nuget_http_cache])
|
||||
env["DOTNET_CLI_HOME"] = str(dotnet_cli_home)
|
||||
env["NUGET_PACKAGES"] = str(nuget_packages)
|
||||
env["NUGET_HTTP_CACHE_PATH"] = str(nuget_http_cache)
|
||||
env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
|
||||
env["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "0"
|
||||
env["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "0"
|
||||
env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
|
||||
env["NUGET_CERT_REVOCATION_MODE"] = "offline"
|
||||
return env
|
||||
|
||||
|
||||
def pip_env(repo_root: pathlib.Path) -> Dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
clean_proxy_env(env)
|
||||
pip_cache = repo_root / ".pip" / "cache"
|
||||
pip_tmp = repo_root / ".tmp" / "pip-temp"
|
||||
ensure_dirs([pip_cache, pip_tmp])
|
||||
env["PIP_CACHE_DIR"] = str(pip_cache)
|
||||
env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
|
||||
env["PIP_DEFAULT_TIMEOUT"] = "30"
|
||||
env["PIP_RETRIES"] = "2"
|
||||
env["TEMP"] = str(pip_tmp)
|
||||
env["TMP"] = str(pip_tmp)
|
||||
return env
|
||||
|
||||
|
||||
def run(command: str, args: List[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> int:
|
||||
resolved = resolve_command(command)
|
||||
try:
|
||||
proc = subprocess.run([resolved, *args], cwd=str(cwd), env=env, check=False)
|
||||
return proc.returncode
|
||||
except FileNotFoundError:
|
||||
print(f"Command not found: {resolved}", file=sys.stderr)
|
||||
return 127
|
||||
|
||||
|
||||
def run_capture(command: str, args: Sequence[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> tuple[int, str, str]:
|
||||
resolved = resolve_command(command)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[resolved, *args],
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
except FileNotFoundError:
|
||||
return 127, "", f"Command not found: {resolved}"
|
||||
|
||||
|
||||
def resolve_command(command: str) -> str:
|
||||
if not command:
|
||||
return command
|
||||
|
||||
if os.name != "nt":
|
||||
return command
|
||||
|
||||
if any(sep in command for sep in ("\\", "/")):
|
||||
return command
|
||||
|
||||
if pathlib.Path(command).suffix:
|
||||
found = shutil.which(command)
|
||||
return found or command
|
||||
|
||||
candidates = []
|
||||
lowered = command.lower()
|
||||
if lowered in ("npm", "npx", "pnpm", "yarn", "tauri"):
|
||||
candidates.extend([f"{command}.cmd", f"{command}.exe", f"{command}.bat", command])
|
||||
else:
|
||||
candidates.append(command)
|
||||
|
||||
for c in candidates:
|
||||
found = _which_windows(c)
|
||||
if found:
|
||||
name = pathlib.Path(found).name.lower()
|
||||
if name in ("npm", "npx", "pnpm", "yarn", "tauri"):
|
||||
shim = pathlib.Path(found).with_name(name + ".cmd")
|
||||
if shim.exists():
|
||||
return str(shim)
|
||||
return found
|
||||
|
||||
if lowered in ("npm", "npx", "pnpm", "yarn"):
|
||||
node = _which_windows("node.exe") or _which_windows("node")
|
||||
if node:
|
||||
node_dir = pathlib.Path(node).parent
|
||||
shim = node_dir / f"{lowered}.cmd"
|
||||
if shim.exists():
|
||||
return str(shim)
|
||||
|
||||
return candidates[-1]
|
||||
|
||||
|
||||
def _hint_matches(root: pathlib.Path, hint: str) -> bool:
|
||||
h = hint.strip()
|
||||
if not h:
|
||||
return False
|
||||
|
||||
has_glob = any(ch in h for ch in ("*", "?", "["))
|
||||
if has_glob:
|
||||
# Match both anywhere in root and directly at root-level for common hints like "*.sln".
|
||||
if any(root.glob(h)):
|
||||
return True
|
||||
return any(root.rglob(h))
|
||||
|
||||
marker = root / h
|
||||
if marker.exists():
|
||||
return True
|
||||
|
||||
# If hint is just a filename marker, look bounded in tree.
|
||||
if not any(sep in h for sep in ("\\", "/")):
|
||||
return any(p.name == h for p in root.rglob(h))
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _expand_windows_path_segment(segment: str) -> str:
|
||||
expanded = segment
|
||||
# Expand %VAR% tokens repeatedly for nested references.
|
||||
for _ in range(4):
|
||||
next_value = os.path.expandvars(expanded)
|
||||
if next_value == expanded:
|
||||
break
|
||||
expanded = next_value
|
||||
return expanded
|
||||
|
||||
|
||||
def _which_windows(command: str) -> str | None:
|
||||
found = shutil.which(command)
|
||||
if found:
|
||||
return found
|
||||
|
||||
if os.name != "nt":
|
||||
return None
|
||||
|
||||
path_value = os.environ.get("PATH", "")
|
||||
pathext = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD")
|
||||
exts = [e.lower() for e in pathext.split(";") if e]
|
||||
|
||||
has_ext = pathlib.Path(command).suffix != ""
|
||||
names = [command] if has_ext else [command, *(command + e.lower() for e in exts)]
|
||||
|
||||
for raw_segment in path_value.split(os.pathsep):
|
||||
segment = _expand_windows_path_segment(raw_segment.strip())
|
||||
if not segment:
|
||||
continue
|
||||
base = pathlib.Path(segment)
|
||||
for name in names:
|
||||
candidate = base / name
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def sha256_files(paths: Iterable[pathlib.Path]) -> str:
|
||||
h = hashlib.sha256()
|
||||
for p in paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
h.update(p.read_bytes())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def first_existing(paths: Iterable[pathlib.Path]) -> pathlib.Path | None:
|
||||
for p in paths:
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def find_csproj(repo_root: pathlib.Path, hints: Sequence[str] | None = None) -> pathlib.Path | None:
|
||||
if hints:
|
||||
for hint in hints:
|
||||
candidate = (repo_root / hint).resolve()
|
||||
if candidate.exists() and candidate.suffix.lower() == ".csproj":
|
||||
return candidate
|
||||
|
||||
csprojs = sorted(repo_root.rglob("*.csproj"))
|
||||
if not csprojs:
|
||||
return None
|
||||
if len(csprojs) == 1:
|
||||
return csprojs[0]
|
||||
return None
|
||||
|
||||
|
||||
def find_csproj_by_keyword(repo_root: pathlib.Path, keywords: Sequence[str]) -> pathlib.Path | None:
|
||||
kws = [k.lower() for k in keywords]
|
||||
matches: list[pathlib.Path] = []
|
||||
for p in repo_root.rglob("*.csproj"):
|
||||
text = str(p).lower()
|
||||
if any(k in text for k in kws):
|
||||
matches.append(p)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
|
||||
def find_node_app_root(repo_root: pathlib.Path, preferred: str | None = None) -> pathlib.Path | None:
|
||||
if preferred:
|
||||
p = (repo_root / preferred).resolve()
|
||||
if (p / "package.json").exists():
|
||||
return p
|
||||
|
||||
direct = repo_root / "package.json"
|
||||
if direct.exists():
|
||||
return repo_root
|
||||
|
||||
tauri_candidates = []
|
||||
for package_json in repo_root.rglob("package.json"):
|
||||
d = package_json.parent
|
||||
if (d / "src-tauri" / "tauri.conf.json").exists():
|
||||
tauri_candidates.append(d)
|
||||
if len(tauri_candidates) == 1:
|
||||
return tauri_candidates[0]
|
||||
|
||||
all_candidates = [p.parent for p in repo_root.rglob("package.json")]
|
||||
if len(all_candidates) == 1:
|
||||
return all_candidates[0]
|
||||
return None
|
||||
|
||||
|
||||
def newest_file(search_root: pathlib.Path, pattern: str) -> pathlib.Path | None:
|
||||
if not search_root.exists():
|
||||
return None
|
||||
files = [p for p in search_root.rglob(pattern) if p.is_file() and "\\obj\\" not in str(p).replace("/", "\\")]
|
||||
if not files:
|
||||
return None
|
||||
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return files[0]
|
||||
82
Journal.DevTool/scripts/sync-output.py
Normal file
82
Journal.DevTool/scripts/sync-output.py
Normal file
@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from script_common import newest_file, resolve_repo_root
|
||||
|
||||
|
||||
def copy_tree_contents(src: Path, dst: Path) -> None:
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
for item in src.iterdir():
|
||||
target = dst / item.name
|
||||
if item.is_dir():
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
shutil.copytree(item, target)
|
||||
else:
|
||||
shutil.copy2(item, target)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Sync newest built assets into output folder")
|
||||
parser.add_argument("--repo-root", default=None)
|
||||
parser.add_argument("--output-dir", default="output")
|
||||
parser.add_argument("--web-build-dir", default=None, help="Path to web build output")
|
||||
parser.add_argument("--sidecar-bin-dir", default=None, help="Path to sidecar bin root")
|
||||
parser.add_argument("--gateway-bin-dir", default=None, help="Path to gateway bin root")
|
||||
parser.add_argument("--tauri-target-dir", default=None, help="Path to tauri target root")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = resolve_repo_root(args.repo_root)
|
||||
output_dir = (repo_root / args.output_dir).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
web_build = (repo_root / args.web_build_dir).resolve() if args.web_build_dir else None
|
||||
if web_build is None:
|
||||
web_build = next((p for p in repo_root.rglob("build") if (p.parent / "package.json").exists()), None)
|
||||
if web_build is not None and web_build.exists():
|
||||
web_out = output_dir / "webgateway" / "wwwroot"
|
||||
copy_tree_contents(web_build, web_out)
|
||||
print(f"Synced web assets -> {web_out}")
|
||||
|
||||
sidecar_bin = (repo_root / args.sidecar_bin_dir).resolve() if args.sidecar_bin_dir else None
|
||||
if sidecar_bin is None:
|
||||
sidecar_proj = next((p.parent for p in repo_root.rglob("*.csproj") if "sidecar" in str(p).lower()), None)
|
||||
sidecar_bin = sidecar_proj / "bin" if sidecar_proj else None
|
||||
if sidecar_bin is not None:
|
||||
sidecar_pattern = "*.exe" if os.name == "nt" else "*"
|
||||
sidecar_exe = newest_file(sidecar_bin, sidecar_pattern)
|
||||
if sidecar_exe is not None:
|
||||
copy_tree_contents(sidecar_exe.parent, output_dir)
|
||||
print(f"Synced sidecar -> {output_dir}")
|
||||
|
||||
gateway_bin = (repo_root / args.gateway_bin_dir).resolve() if args.gateway_bin_dir else None
|
||||
if gateway_bin is None:
|
||||
gateway_proj = next((p.parent for p in repo_root.rglob("*.csproj") if "gateway" in str(p).lower()), None)
|
||||
gateway_bin = gateway_proj / "bin" if gateway_proj else None
|
||||
if gateway_bin is not None:
|
||||
gateway_pattern = "*.exe" if os.name == "nt" else "*"
|
||||
gw_exe = newest_file(gateway_bin, gateway_pattern)
|
||||
if gw_exe is not None:
|
||||
gw_out = output_dir / "webgateway"
|
||||
copy_tree_contents(gw_exe.parent, gw_out)
|
||||
print(f"Synced gateway -> {gw_out}")
|
||||
|
||||
tauri_target = (repo_root / args.tauri_target_dir).resolve() if args.tauri_target_dir else None
|
||||
if tauri_target is None:
|
||||
tauri_target = next((p for p in repo_root.rglob("src-tauri") if (p / "target").exists()), None)
|
||||
tauri_target = tauri_target / "target" if tauri_target else None
|
||||
if tauri_target is not None:
|
||||
app_exe = newest_file(tauri_target, "*.exe")
|
||||
if app_exe is not None:
|
||||
shutil.copy2(app_exe, output_dir / app_exe.name)
|
||||
print(f"Synced desktop app ({app_exe.name}) -> {output_dir}")
|
||||
|
||||
print("Sync complete.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -0,0 +1,31 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class ActionRunnerLegacyPwshTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LegacyPwshTarget_ReroutesToPythonScript_WhenPs1Missing()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-actionrunner-" + Guid.NewGuid().ToString("N"));
|
||||
var scripts = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(scripts);
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('rerouted')");
|
||||
|
||||
var step = new WorkflowStep
|
||||
{
|
||||
Id = "legacy",
|
||||
Label = "legacy",
|
||||
Command = "pwsh",
|
||||
Args = ["-NoProfile", "-File", "scripts/publish-app.ps1", "-Target", "web"],
|
||||
WorkingDir = "."
|
||||
};
|
||||
|
||||
var runner = new ActionRunner();
|
||||
var run = await runner.RunStepAsync(step, root, (_, _) => { });
|
||||
|
||||
Assert.True(run.Success);
|
||||
}
|
||||
}
|
||||
124
Journal.DevTool/tests/DevTool.Tests/CommandResolverTests.cs
Normal file
124
Journal.DevTool/tests/DevTool.Tests/CommandResolverTests.cs
Normal file
@ -0,0 +1,124 @@
|
||||
using Sdt.Core;
|
||||
using Sdt.Config;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class CommandResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_Npm_OnWindows_UsesCmdShim()
|
||||
{
|
||||
var resolved = CommandResolver.Resolve("npm");
|
||||
if (OperatingSystem.IsWindows())
|
||||
Assert.True(
|
||||
resolved.EndsWith("npm.cmd", StringComparison.OrdinalIgnoreCase) ||
|
||||
resolved.EndsWith("\\npm", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(resolved, "npm.cmd", StringComparison.OrdinalIgnoreCase),
|
||||
$"Resolved npm path was '{resolved}'");
|
||||
else
|
||||
Assert.Equal("npm", resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_PathOrExtension_Unchanged()
|
||||
{
|
||||
Assert.Equal("C:\\tools\\npm.cmd", CommandResolver.Resolve("C:\\tools\\npm.cmd"));
|
||||
var resolved = CommandResolver.Resolve("dotnet.exe");
|
||||
if (OperatingSystem.IsWindows() && Path.IsPathRooted(resolved))
|
||||
Assert.EndsWith("dotnet.exe", resolved, StringComparison.OrdinalIgnoreCase);
|
||||
else
|
||||
Assert.Equal("dotnet.exe", resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Npm_PrefersCmdShim_WhenBothBareAndCmdExist()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return;
|
||||
|
||||
var temp = Path.Combine(Path.GetTempPath(), "sdt-cmdresolve-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(temp);
|
||||
File.WriteAllText(Path.Combine(temp, "npm"), "");
|
||||
File.WriteAllText(Path.Combine(temp, "npm.cmd"), "@echo off");
|
||||
|
||||
var originalPath = Environment.GetEnvironmentVariable("PATH");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("PATH", temp);
|
||||
var resolved = CommandResolver.Resolve("npm");
|
||||
Assert.EndsWith("npm.cmd", resolved, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("PATH", originalPath);
|
||||
try { Directory.Delete(temp, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithTrace_ConfiguredOverride_IsUsed()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return;
|
||||
|
||||
var temp = Path.Combine(Path.GetTempPath(), "sdt-cmdresolve-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(temp);
|
||||
var overridePath = Path.Combine(temp, "npm.cmd");
|
||||
File.WriteAllText(overridePath, "@echo off");
|
||||
|
||||
var cfg = new DevToolConfig
|
||||
{
|
||||
Tooling = new ToolingConfig
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ToolInstallDefinition
|
||||
{
|
||||
Tool = "npm",
|
||||
Executables = [overridePath]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var resolved = CommandResolver.ResolveWithTrace("npm", cfg, "npm");
|
||||
Assert.Equal(CommandResolutionSource.ConfiguredOverride, resolved.Source);
|
||||
Assert.EndsWith("npm.cmd", resolved.Resolved, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithTrace_ExpandsWindowsPathTokens()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return;
|
||||
|
||||
var nvmHome = Path.Combine(Path.GetTempPath(), "sdt-nvmhome-" + Guid.NewGuid().ToString("N"));
|
||||
var nvmLink = Path.Combine(Path.GetTempPath(), "sdt-nvmlink-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(nvmHome);
|
||||
Directory.CreateDirectory(nvmLink);
|
||||
File.WriteAllText(Path.Combine(nvmLink, "npm.cmd"), "@echo off");
|
||||
|
||||
var originalPath = Environment.GetEnvironmentVariable("PATH");
|
||||
var originalHome = Environment.GetEnvironmentVariable("NVM_HOME");
|
||||
var originalLink = Environment.GetEnvironmentVariable("NVM_SYMLINK");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("NVM_HOME", nvmHome);
|
||||
Environment.SetEnvironmentVariable("NVM_SYMLINK", nvmLink);
|
||||
Environment.SetEnvironmentVariable("PATH", "%NVM_HOME%;%NVM_SYMLINK%");
|
||||
|
||||
var result = CommandResolver.ResolveWithTrace("npm");
|
||||
Assert.True(result.Source is CommandResolutionSource.Shim or CommandResolutionSource.Path);
|
||||
Assert.EndsWith("npm.cmd", result.Resolved, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("PATH", originalPath);
|
||||
Environment.SetEnvironmentVariable("NVM_HOME", originalHome);
|
||||
Environment.SetEnvironmentVariable("NVM_SYMLINK", originalLink);
|
||||
try { Directory.Delete(nvmHome, recursive: true); } catch { }
|
||||
try { Directory.Delete(nvmLink, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
108
Journal.DevTool/tests/DevTool.Tests/ConfigBootstrapperTests.cs
Normal file
108
Journal.DevTool/tests/DevTool.Tests/ConfigBootstrapperTests.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using Sdt.Config;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class ConfigBootstrapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Scan_DetectsDotnetAndNode()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
File.WriteAllText(Path.Combine(root, "sample.sln"), "");
|
||||
File.WriteAllText(Path.Combine(root, "package.json"), "{}");
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(root);
|
||||
|
||||
Assert.Contains("dotnet", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("node", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("npm", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_DetectsPythonFromScriptsDirectory()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
var scripts = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(scripts);
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(root);
|
||||
Assert.Contains("python", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDefaultConfig_ProducesWorkflowsAndDebugSection()
|
||||
{
|
||||
var scan = new BootstrapScanResult(
|
||||
ProjectRoot: Path.GetTempPath(),
|
||||
ProjectName: "demo",
|
||||
ToolFamilies: ["dotnet", "git"],
|
||||
NodeWorkingDir: null,
|
||||
PythonRequirementsFile: null,
|
||||
HasDockerCompose: false,
|
||||
RootHints: ["*.sln"]);
|
||||
|
||||
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
|
||||
Assert.NotNull(cfg.Debug);
|
||||
Assert.Contains(cfg.Workflows, w => w.Id == "build");
|
||||
Assert.Contains(cfg.Workflows, w => w.Id == "repo-health");
|
||||
Assert.False(cfg.Debug!.Diagnostics.IncludeAllEnv);
|
||||
Assert.Contains("SDT_LOG_LEVEL", cfg.Debug.Diagnostics.CaptureEnvKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDefaultConfig_IncludesScriptDrivenWorkflow_WhenHelpersExist()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
var scripts = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(scripts);
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-sidecar.py"), "print('ok')");
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(root);
|
||||
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
|
||||
Assert.Contains(cfg.Workflows, w => w.Id == "web");
|
||||
Assert.Contains(cfg.Workflows, w => w.Id == "sidecar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteDefaultConfig_WritesDevtoolJson()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
var scan = new BootstrapScanResult(
|
||||
ProjectRoot: root,
|
||||
ProjectName: "demo",
|
||||
ToolFamilies: ["dotnet"],
|
||||
NodeWorkingDir: null,
|
||||
PythonRequirementsFile: null,
|
||||
HasDockerCompose: false,
|
||||
RootHints: ["*.sln"]);
|
||||
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
|
||||
var path = ConfigBootstrapper.WriteDefaultConfig(root, cfg);
|
||||
|
||||
Assert.True(File.Exists(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_IgnoresExcludedDirectories_ForToolDetection()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
var nodeModules = Path.Combine(root, "node_modules", "nested");
|
||||
Directory.CreateDirectory(nodeModules);
|
||||
File.WriteAllText(Path.Combine(nodeModules, "package.json"), "{}");
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(root);
|
||||
Assert.DoesNotContain("node", scan.ToolFamilies, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string CreateTempDir()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "sdt-bootstrap-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class ConfigDoctorAutoFixServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void FindMissingWorkingDirectories_ReturnsMissingPaths()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-autofix-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
Directory.CreateDirectory(Path.Combine(root, "exists"));
|
||||
|
||||
var cfg = new DevToolConfig
|
||||
{
|
||||
Workflows =
|
||||
[
|
||||
new WorkflowDefinition
|
||||
{
|
||||
Id = "build",
|
||||
Label = "Build",
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep { Id = "s1", Label = "S1", Action = "dotnet-build", WorkingDir = "exists" },
|
||||
new WorkflowStep { Id = "s2", Label = "S2", Action = "dotnet-build", WorkingDir = "missing/sub" }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var service = new ConfigDoctorAutoFixService();
|
||||
var missing = service.FindMissingWorkingDirectories(cfg, root);
|
||||
|
||||
Assert.Single(missing);
|
||||
Assert.EndsWith(Path.Combine("missing", "sub"), missing[0], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMissingWorkingDirectories_CreatesPaths()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-autofix-" + Guid.NewGuid().ToString("N"));
|
||||
var path = Path.Combine(root, "a", "b", "c");
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
var service = new ConfigDoctorAutoFixService();
|
||||
var result = service.CreateMissingWorkingDirectories([path]);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(Directory.Exists(path));
|
||||
Assert.Equal(1, result.CreatedDirectories);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class ConfigDoctorServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TargetsOnly_Config_IsFlaggedAsFail()
|
||||
{
|
||||
var config = new DevToolConfig
|
||||
{
|
||||
Targets =
|
||||
[
|
||||
new BuildTarget
|
||||
{
|
||||
Id = "build",
|
||||
Label = "Build",
|
||||
Command = "dotnet",
|
||||
Args = ["build"]
|
||||
}
|
||||
],
|
||||
Workflows = []
|
||||
};
|
||||
|
||||
var doctor = new ConfigDoctorService(new AlwaysAvailableProbe(), new RequirementResolver());
|
||||
var report = await doctor.RunAsync(config, Directory.GetCurrentDirectory());
|
||||
|
||||
Assert.Contains(report.Checks, c => c.Name == "Legacy schema" && c.Status == DoctorStatus.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingTool_IsReportedWithFix()
|
||||
{
|
||||
var config = new DevToolConfig
|
||||
{
|
||||
Workflows =
|
||||
[
|
||||
new WorkflowDefinition
|
||||
{
|
||||
Id = "build",
|
||||
Label = "Build",
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep { Id = "s1", Label = "Build", Command = "dotnet", Args = ["build"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var doctor = new ConfigDoctorService(new AlwaysMissingProbe(), new RequirementResolver());
|
||||
var report = await doctor.RunAsync(config, Directory.GetCurrentDirectory());
|
||||
|
||||
Assert.Contains(report.Checks, c =>
|
||||
c.Name.Equals("Tool: dotnet", StringComparison.OrdinalIgnoreCase) &&
|
||||
c.Status == DoctorStatus.Fail &&
|
||||
!string.IsNullOrWhiteSpace(c.Fix));
|
||||
}
|
||||
|
||||
private sealed class AlwaysAvailableProbe : IToolProbe
|
||||
{
|
||||
public Task<ProbeResult> ProbeAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProbeResult(tool, true, Version: "1.0.0"));
|
||||
}
|
||||
|
||||
private sealed class AlwaysMissingProbe : IToolProbe
|
||||
{
|
||||
public Task<ProbeResult> ProbeAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProbeResult(tool, false, Details: "Fallback: unresolved command"));
|
||||
}
|
||||
}
|
||||
40
Journal.DevTool/tests/DevTool.Tests/DebugConfigTests.cs
Normal file
40
Journal.DevTool/tests/DevTool.Tests/DebugConfigTests.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
using Sdt.Config;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class DebugConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void DebugSectionAbsent_DeserializesWithSafeDefaults()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"name": "Test",
|
||||
"version": "1.0.0",
|
||||
"workflows": []
|
||||
}
|
||||
""";
|
||||
|
||||
var cfg = JsonSerializer.Deserialize<DevToolConfig>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
Assert.NotNull(cfg);
|
||||
Assert.Null(cfg!.Debug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugDiagnostics_DefaultOutputDir_IsSdtDebug()
|
||||
{
|
||||
var options = new DebugDiagnosticsOptions();
|
||||
Assert.True(options.Enabled);
|
||||
Assert.True(options.BundleOnFailure);
|
||||
Assert.Equal(".sdt/debug", options.OutputDir);
|
||||
}
|
||||
}
|
||||
191
Journal.DevTool/tests/DevTool.Tests/DebugServicesTests.cs
Normal file
191
Journal.DevTool/tests/DevTool.Tests/DebugServicesTests.cs
Normal file
@ -0,0 +1,191 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Sdt.Core.Debug;
|
||||
using Sdt.Runner;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class DebugServicesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DebugRunner_MissingPrereqDeclined_ReturnsUserDeclined()
|
||||
{
|
||||
var runner = new DebugProfileRunner(
|
||||
new FakeProbeService(false),
|
||||
new FakeInstallerService(true));
|
||||
|
||||
var profile = new DebugProfileDefinition
|
||||
{
|
||||
Id = "p1",
|
||||
Label = "Profile",
|
||||
Type = "python",
|
||||
Command = "python",
|
||||
Args = ["--version"],
|
||||
Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }]
|
||||
};
|
||||
|
||||
var result = await runner.RunAsync(
|
||||
profile,
|
||||
new DevToolConfig(),
|
||||
Directory.GetCurrentDirectory(),
|
||||
verbose: false,
|
||||
confirmInstallAsync: (_, _) => Task.FromResult(false),
|
||||
onOutput: (_, _) => { });
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ExecutionStopReason.UserDeclined, result.StopReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiagnosticsBundle_WritesFiles()
|
||||
{
|
||||
var service = new DiagnosticsBundleService();
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
var request = new DiagnosticsBundleRequest(
|
||||
Category: "workflow",
|
||||
ProjectRoot: root,
|
||||
SummaryMessage: "failed",
|
||||
OutputLines: ["hello"],
|
||||
WorkflowSteps: [],
|
||||
Probes: [],
|
||||
DiagnosticsOptions: new DebugDiagnosticsOptions { OutputDir = ".sdt/debug" },
|
||||
Config: new DevToolConfig(),
|
||||
StopReason: ExecutionStopReason.CommandFailed);
|
||||
|
||||
var result = await service.WriteBundleAsync(request);
|
||||
Assert.True(result.Success);
|
||||
Assert.True(File.Exists(Path.Combine(result.BundleDirectory, "summary.json")));
|
||||
Assert.True(File.Exists(Path.Combine(result.BundleDirectory, "output.log")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiagnosticsBundle_EmptyAllowlist_CapturesNoEnvByDefault()
|
||||
{
|
||||
var service = new DiagnosticsBundleService();
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
var request = new DiagnosticsBundleRequest(
|
||||
Category: "workflow",
|
||||
ProjectRoot: root,
|
||||
SummaryMessage: "failed",
|
||||
OutputLines: [],
|
||||
WorkflowSteps: [],
|
||||
Probes: [],
|
||||
DiagnosticsOptions: new DebugDiagnosticsOptions
|
||||
{
|
||||
OutputDir = ".sdt/debug",
|
||||
IncludeAllEnv = false,
|
||||
CaptureEnvKeys = []
|
||||
},
|
||||
Config: new DevToolConfig(),
|
||||
StopReason: ExecutionStopReason.CommandFailed);
|
||||
|
||||
var result = await service.WriteBundleAsync(request);
|
||||
Assert.True(result.Success);
|
||||
|
||||
var envJson = await File.ReadAllTextAsync(Path.Combine(result.BundleDirectory, "env.json"));
|
||||
using var doc = JsonDocument.Parse(envJson);
|
||||
Assert.Empty(doc.RootElement.EnumerateObject());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiagnosticsBundle_Allowlist_CapturesOnlyListedKeys()
|
||||
{
|
||||
var service = new DiagnosticsBundleService();
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-diag-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
Environment.SetEnvironmentVariable("SDT_TEST_ENV_A", "A");
|
||||
Environment.SetEnvironmentVariable("SDT_TEST_ENV_B", "B");
|
||||
|
||||
var request = new DiagnosticsBundleRequest(
|
||||
Category: "workflow",
|
||||
ProjectRoot: root,
|
||||
SummaryMessage: "failed",
|
||||
OutputLines: [],
|
||||
WorkflowSteps: [],
|
||||
Probes: [],
|
||||
DiagnosticsOptions: new DebugDiagnosticsOptions
|
||||
{
|
||||
OutputDir = ".sdt/debug",
|
||||
IncludeAllEnv = false,
|
||||
CaptureEnvKeys = ["SDT_TEST_ENV_A"]
|
||||
},
|
||||
Config: new DevToolConfig(),
|
||||
StopReason: ExecutionStopReason.CommandFailed);
|
||||
|
||||
var result = await service.WriteBundleAsync(request);
|
||||
Assert.True(result.Success);
|
||||
|
||||
var envJson = await File.ReadAllTextAsync(Path.Combine(result.BundleDirectory, "env.json"));
|
||||
using var doc = JsonDocument.Parse(envJson);
|
||||
Assert.True(doc.RootElement.TryGetProperty("SDT_TEST_ENV_A", out _));
|
||||
Assert.False(doc.RootElement.TryGetProperty("SDT_TEST_ENV_B", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebugRunner_EmitsRunEvents()
|
||||
{
|
||||
var runner = new DebugProfileRunner(
|
||||
new FakeProbeService(true),
|
||||
new FakeInstallerService(true));
|
||||
|
||||
var profile = new DebugProfileDefinition
|
||||
{
|
||||
Id = "p1",
|
||||
Label = "Profile",
|
||||
Type = "python",
|
||||
Command = "python",
|
||||
Args = ["--version"],
|
||||
Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }]
|
||||
};
|
||||
|
||||
var events = new List<RunEvent>();
|
||||
var result = await runner.RunAsync(
|
||||
profile,
|
||||
new DevToolConfig(),
|
||||
Directory.GetCurrentDirectory(),
|
||||
verbose: false,
|
||||
confirmInstallAsync: (_, _) => Task.FromResult(false),
|
||||
onOutput: (_, _) => { },
|
||||
onEvent: events.Add);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains(events, e => e.Type == RunEventType.DebugStarted);
|
||||
Assert.Contains(events, e => e.Type == RunEventType.DebugCommandStarted);
|
||||
Assert.Contains(events, e => e.Type == RunEventType.DebugCommandCompleted);
|
||||
Assert.Contains(events, e => e.Type == RunEventType.DebugCompleted && e.Success == true);
|
||||
}
|
||||
|
||||
private sealed class FakeProbeService(bool isAvailable) : IToolProbe
|
||||
{
|
||||
public Task<ProbeResult> ProbeAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProbeResult(tool, isAvailable, Version: isAvailable ? "1.0.0" : null));
|
||||
}
|
||||
|
||||
private sealed class FakeInstallerService(bool success) : IPrereqInstaller
|
||||
{
|
||||
public Task<InstallPlan> GetInstallPlanAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new InstallPlan(tool, Supported: true, "test", [new InstallCommand("echo", ["ok"])]));
|
||||
|
||||
public Task<RunResult> RunInstallAsync(
|
||||
InstallCommand command,
|
||||
string projectRoot,
|
||||
Action<string, bool> onOutput,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new RunResult(success ? 0 : 1, TimeSpan.FromMilliseconds(5)));
|
||||
}
|
||||
}
|
||||
120
Journal.DevTool/tests/DevTool.Tests/DevShellScriptTests.cs
Normal file
120
Journal.DevTool/tests/DevTool.Tests/DevShellScriptTests.cs
Normal file
@ -0,0 +1,120 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class DevShellScriptTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("pwsh")]
|
||||
[InlineData("bash")]
|
||||
[InlineData("zsh")]
|
||||
[InlineData("cmd")]
|
||||
public async Task DevShellExport_ReturnsSuccess_ForSupportedShells(string shell)
|
||||
{
|
||||
var python = ResolvePython();
|
||||
var result = await RunAsync(
|
||||
python,
|
||||
["scripts/dev_shell.py", "export", "--shell", shell, "--json"]);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
using var doc = JsonDocument.Parse(result.StdOut);
|
||||
Assert.True(doc.RootElement.TryGetProperty("projectRoot", out _));
|
||||
Assert.True(doc.RootElement.TryGetProperty("env", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DevShellExport_InvalidShell_ReturnsExitCode3()
|
||||
{
|
||||
var python = ResolvePython();
|
||||
var result = await RunAsync(
|
||||
python,
|
||||
["scripts/dev_shell.py", "export", "--shell", "fish"]);
|
||||
|
||||
Assert.Equal(3, result.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DevShellDoctor_ReturnsJson()
|
||||
{
|
||||
var python = ResolvePython();
|
||||
var result = await RunAsync(
|
||||
python,
|
||||
["scripts/dev_shell.py", "doctor"]);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
using var doc = JsonDocument.Parse(result.StdOut);
|
||||
Assert.True(doc.RootElement.TryGetProperty("repo_root", out _));
|
||||
}
|
||||
|
||||
private static string ResolvePython()
|
||||
{
|
||||
var candidates = OperatingSystem.IsWindows()
|
||||
? new[] { "python", "py" }
|
||||
: new[] { "python3", "python" };
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = candidate,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
}
|
||||
};
|
||||
process.StartInfo.ArgumentList.Add("--version");
|
||||
process.Start();
|
||||
process.WaitForExit(2000);
|
||||
if (process.ExitCode == 0)
|
||||
return candidate;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Python executable not found.");
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string StdOut, string StdErr)> RunAsync(string command, IReadOnlyList<string> args)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = command,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = RepoRoot(),
|
||||
};
|
||||
foreach (var arg in args)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
return (process.ExitCode, stdout, stderr);
|
||||
}
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "devtool.json")))
|
||||
return dir.FullName;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not locate repo root.");
|
||||
}
|
||||
}
|
||||
18
Journal.DevTool/tests/DevTool.Tests/DevTool.Tests.csproj
Normal file
18
Journal.DevTool/tests/DevTool.Tests/DevTool.Tests.csproj
Normal file
@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\DevTool.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
88
Journal.DevTool/tests/DevTool.Tests/LegacyModeTests.cs
Normal file
88
Journal.DevTool/tests/DevTool.Tests/LegacyModeTests.cs
Normal file
@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
using Sdt.Config;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class LegacyModeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConfigLoader_StrictMode_TargetsOnly_FailsAndWritesPreview()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
WriteLegacyTargetsOnlyConfig(root);
|
||||
Environment.SetEnvironmentVariable("SDT_LEGACY_MODE", null);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => ConfigLoader.FindAndLoad(root));
|
||||
Assert.Contains("Strict mode requires workflows", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(File.Exists(Path.Combine(root, "devtool.generated.workflows.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigLoader_CompatMode_TargetsOnly_Loads()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
WriteLegacyTargetsOnlyConfig(root);
|
||||
Environment.SetEnvironmentVariable("SDT_LEGACY_MODE", "compat");
|
||||
try
|
||||
{
|
||||
var loaded = ConfigLoader.FindAndLoad(root);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Contains(loaded!.Warnings, w => w.Contains("legacy 'targets'", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SDT_LEGACY_MODE", null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLegacyTargetMigration_RewritesConfigAndCreatesBackup()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
WriteLegacyTargetsOnlyConfig(root);
|
||||
var path = Path.Combine(root, "devtool.json");
|
||||
|
||||
var result = ConfigLoader.ApplyLegacyTargetMigration(path, createBackup: true);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(File.Exists(path));
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.BackupPath));
|
||||
Assert.True(File.Exists(result.BackupPath!));
|
||||
|
||||
var loaded = ConfigLoader.FindAndLoad(root);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.NotEmpty(loaded!.Config.Workflows);
|
||||
Assert.Empty(loaded.Config.Targets);
|
||||
}
|
||||
|
||||
private static void WriteLegacyTargetsOnlyConfig(string root)
|
||||
{
|
||||
var cfg = new DevToolConfig
|
||||
{
|
||||
Name = "legacy",
|
||||
Version = "0.1.0",
|
||||
Targets =
|
||||
[
|
||||
new BuildTarget
|
||||
{
|
||||
Id = "build",
|
||||
Label = "Build",
|
||||
Command = "dotnet",
|
||||
Args = ["build"]
|
||||
}
|
||||
],
|
||||
Workflows = []
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true });
|
||||
File.WriteAllText(Path.Combine(root, "devtool.json"), json);
|
||||
}
|
||||
|
||||
private static string CreateTempDir()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "sdt-legacy-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class PrereqInstallerServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PreferredInstallCommands_AreUsedFirst()
|
||||
{
|
||||
var svc = new PrereqInstallerService();
|
||||
var cfg = new DevToolConfig
|
||||
{
|
||||
Tooling = new ToolingConfig
|
||||
{
|
||||
Tools =
|
||||
[
|
||||
new ToolInstallDefinition
|
||||
{
|
||||
Tool = "dotnet",
|
||||
PreferredInstallCommands =
|
||||
[
|
||||
"echo install dotnet",
|
||||
"dotnet --info"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var plan = await svc.GetInstallPlanAsync("dotnet", Directory.GetCurrentDirectory(), cfg);
|
||||
|
||||
Assert.True(plan.Supported);
|
||||
Assert.Equal("dotnet", plan.Tool);
|
||||
Assert.Equal(2, plan.Commands.Count);
|
||||
Assert.Equal("echo", plan.Commands[0].Command);
|
||||
Assert.Equal("dotnet", plan.Commands[1].Command);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiagInstallPlanFailure_FallsBackToTemplatePlan()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-prereq-" + Guid.NewGuid().ToString("N"));
|
||||
var scripts = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(scripts);
|
||||
await File.WriteAllTextAsync(Path.Combine(scripts, "diag.py"), "import sys\nsys.exit(2)\n");
|
||||
|
||||
var svc = new PrereqInstallerService();
|
||||
var plan = await svc.GetInstallPlanAsync("npm", root, new DevToolConfig());
|
||||
|
||||
Assert.True(plan.Supported);
|
||||
Assert.NotEmpty(plan.Commands);
|
||||
Assert.Contains("fallback", plan.Summary, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiagInstallPlanInvalidJson_FallsBackToTemplatePlan()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-prereq-" + Guid.NewGuid().ToString("N"));
|
||||
var scripts = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(scripts);
|
||||
await File.WriteAllTextAsync(Path.Combine(scripts, "diag.py"), "print('not-json')\n");
|
||||
|
||||
var svc = new PrereqInstallerService();
|
||||
var plan = await svc.GetInstallPlanAsync("dotnet", root, new DevToolConfig());
|
||||
|
||||
Assert.True(plan.Supported);
|
||||
Assert.NotEmpty(plan.Commands);
|
||||
Assert.Contains("fallback", plan.Summary, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TauriFallbackPlan_IsMultiStepAndClear()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-prereq-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
var svc = new PrereqInstallerService();
|
||||
var plan = await svc.GetInstallPlanAsync("tauri", root, new DevToolConfig());
|
||||
|
||||
Assert.True(plan.Supported);
|
||||
Assert.True(plan.Commands.Count >= 3);
|
||||
Assert.Contains("tauri", plan.Summary, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TauriFallbackPlan_IncludesRustAndCliCommands()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-prereq-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
var svc = new PrereqInstallerService();
|
||||
var plan = await svc.GetInstallPlanAsync("tauri", root, new DevToolConfig());
|
||||
|
||||
Assert.Contains(plan.Commands, c => c.Args.Any(a => a.Contains("rustup", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.Contains(plan.Commands, c => c.Args.Any(a => a.Contains("@tauri-apps/cli", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class RequirementResolverTests
|
||||
{
|
||||
private readonly RequirementResolver _resolver = new();
|
||||
|
||||
[Fact]
|
||||
public void TauriBuildAction_RequiresNodeNpmCargo_NotGlobalTauri()
|
||||
{
|
||||
var step = new WorkflowStep
|
||||
{
|
||||
Id = "tauri",
|
||||
Label = "tauri",
|
||||
Action = "tauri-build",
|
||||
};
|
||||
|
||||
var tools = _resolver.Resolve(step).Select(r => r.Tool).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("node", tools);
|
||||
Assert.Contains("npm", tools);
|
||||
Assert.Contains("cargo", tools);
|
||||
Assert.DoesNotContain("tauri", tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyPwshTarget_InferenceMatchesExpected()
|
||||
{
|
||||
var target = new BuildTarget
|
||||
{
|
||||
Id = "web",
|
||||
Label = "Web",
|
||||
Command = "pwsh",
|
||||
Args = ["-NoProfile", "-File", "scripts/publish-app.ps1", "-Target", "web"],
|
||||
};
|
||||
|
||||
var tools = _resolver.Resolve(target).Select(r => r.Tool).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("python", tools);
|
||||
Assert.Contains("node", tools);
|
||||
Assert.Contains("npm", tools);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
using System.Text.Json;
|
||||
using Sdt.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class RunEventJsonlRecorderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Recorder_WritesJsonlEvents()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-events-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
string path;
|
||||
using (var recorder = RunEventJsonlRecorder.Create(root, "workflow"))
|
||||
{
|
||||
path = recorder.FilePath;
|
||||
recorder.Write(new RunEvent("workflow", RunEventType.WorkflowStarted, "started", WorkflowId: "build"));
|
||||
recorder.Write(new RunEvent("workflow", RunEventType.WorkflowCompleted, "done", WorkflowId: "build", Success: true));
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(path));
|
||||
var lines = File.ReadAllLines(path);
|
||||
Assert.Equal(2, lines.Length);
|
||||
|
||||
using var doc = JsonDocument.Parse(lines[0]);
|
||||
Assert.Equal("workflow", doc.RootElement.GetProperty("category").GetString());
|
||||
Assert.Equal("WorkflowStarted", doc.RootElement.GetProperty("type").GetString());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
using Sdt.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class RunEventLogReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadEvents_ParsesValidJsonlLines()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-logreader-" + Guid.NewGuid().ToString("N"));
|
||||
var dir = Path.Combine(root, ".sdt", "events");
|
||||
Directory.CreateDirectory(dir);
|
||||
var file = Path.Combine(dir, "workflow-test.jsonl");
|
||||
File.WriteAllLines(file,
|
||||
[
|
||||
"""{"category":"workflow","type":"WorkflowStarted","message":"start","workflowId":"build","occurredAt":"2026-03-01T10:00:00Z"}""",
|
||||
"""{"category":"workflow","type":"WorkflowCompleted","message":"done","workflowId":"build","success":true,"exitCode":0,"occurredAt":"2026-03-01T10:00:01Z"}"""
|
||||
]);
|
||||
|
||||
var reader = new RunEventLogReader();
|
||||
var events = reader.ReadEvents(file);
|
||||
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Equal(RunEventType.WorkflowStarted, events[0].Type);
|
||||
Assert.Equal(RunEventType.WorkflowCompleted, events[1].Type);
|
||||
Assert.True(events[1].Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListEventFiles_ReturnsNewestFirst()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-logreader-" + Guid.NewGuid().ToString("N"));
|
||||
var dir = Path.Combine(root, ".sdt", "events");
|
||||
Directory.CreateDirectory(dir);
|
||||
var older = Path.Combine(dir, "older.jsonl");
|
||||
var newer = Path.Combine(dir, "newer.jsonl");
|
||||
File.WriteAllText(older, "{}");
|
||||
Thread.Sleep(20);
|
||||
File.WriteAllText(newer, "{}");
|
||||
|
||||
var reader = new RunEventLogReader();
|
||||
var files = reader.ListEventFiles(root);
|
||||
|
||||
Assert.True(files.Count >= 2);
|
||||
Assert.Equal("newer.jsonl", files[0].Name);
|
||||
}
|
||||
}
|
||||
166
Journal.DevTool/tests/DevTool.Tests/ScriptCommonTests.cs
Normal file
166
Journal.DevTool/tests/DevTool.Tests/ScriptCommonTests.cs
Normal file
@ -0,0 +1,166 @@
|
||||
using System.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class ScriptCommonTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResolveRepoRoot_UsesGlobRootHints()
|
||||
{
|
||||
var root = CreateTempDir("sdt-script-root-");
|
||||
var nested = Path.Combine(root, "src", "app");
|
||||
Directory.CreateDirectory(nested);
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "sample.sln"), "");
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "devtool.json"), """
|
||||
{
|
||||
"name": "demo",
|
||||
"version": "0.1.0",
|
||||
"workflows": [],
|
||||
"project": {
|
||||
"rootHints": ["*.sln"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var output = await RunPythonAsync(nested, "import script_common; print(script_common.resolve_repo_root(r'" + Escape(nested) + "'))");
|
||||
Assert.Equal(Path.GetFullPath(root), output.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveRepoRoot_UsesDirectoryMarkerHints()
|
||||
{
|
||||
var root = CreateTempDir("sdt-script-root-");
|
||||
var nested = Path.Combine(root, "child", "leaf");
|
||||
Directory.CreateDirectory(nested);
|
||||
Directory.CreateDirectory(Path.Combine(root, ".git"));
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "devtool.json"), """
|
||||
{
|
||||
"name": "demo",
|
||||
"version": "0.1.0",
|
||||
"workflows": [],
|
||||
"project": {
|
||||
"rootHints": [".git", "package.json"]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var output = await RunPythonAsync(nested, "import script_common; print(script_common.resolve_repo_root(r'" + Escape(nested) + "'))");
|
||||
Assert.Equal(Path.GetFullPath(root), output.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveCommand_ExpandsWindowsPathTokens()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return;
|
||||
|
||||
var root = CreateTempDir("sdt-script-cmd-");
|
||||
var shimDir = Path.Combine(root, "nodejs");
|
||||
Directory.CreateDirectory(shimDir);
|
||||
await File.WriteAllTextAsync(Path.Combine(shimDir, "npm.cmd"), "@echo off");
|
||||
|
||||
var output = await RunPythonAsync(
|
||||
root,
|
||||
"import script_common; print(script_common.resolve_command('npm'))",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["NVM_HOME"] = root,
|
||||
["NVM_SYMLINK"] = shimDir,
|
||||
["PATH"] = "%NVM_HOME%;%NVM_SYMLINK%",
|
||||
});
|
||||
|
||||
Assert.EndsWith("npm.cmd", output.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static async Task<string> RunPythonAsync(
|
||||
string workingDir,
|
||||
string script,
|
||||
IReadOnlyDictionary<string, string?>? env = null)
|
||||
{
|
||||
var python = ResolvePython();
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = python,
|
||||
WorkingDirectory = workingDir,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add("-c");
|
||||
psi.ArgumentList.Add($"import sys; sys.path.insert(0, r'{Escape(Path.Combine(ProjectRepoRoot(), "scripts"))}'); {script}");
|
||||
|
||||
if (env is not null)
|
||||
{
|
||||
foreach (var pair in env)
|
||||
psi.Environment[pair.Key] = pair.Value ?? string.Empty;
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException($"Python exited {process.ExitCode}: {stderr}");
|
||||
return stdout;
|
||||
}
|
||||
|
||||
private static string ResolvePython()
|
||||
{
|
||||
var candidates = OperatingSystem.IsWindows() ? new[] { "python", "py" } : new[] { "python3", "python" };
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = candidate,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
}
|
||||
};
|
||||
process.StartInfo.ArgumentList.Add("--version");
|
||||
process.Start();
|
||||
process.WaitForExit(2000);
|
||||
if (process.ExitCode == 0)
|
||||
return candidate;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Python executable not found.");
|
||||
}
|
||||
|
||||
private static string CreateTempDir(string prefix)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), prefix + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("'", "\\'");
|
||||
|
||||
private static string ProjectRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "devtool.json")) &&
|
||||
File.Exists(Path.Combine(dir.FullName, "scripts", "script_common.py")))
|
||||
{
|
||||
return dir.FullName;
|
||||
}
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not locate project repo root.");
|
||||
}
|
||||
}
|
||||
179
Journal.DevTool/tests/DevTool.Tests/ScriptSmokeTests.cs
Normal file
179
Journal.DevTool/tests/DevTool.Tests/ScriptSmokeTests.cs
Normal file
@ -0,0 +1,179 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class ScriptSmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DiagProbe_JsonContract_IsValid()
|
||||
{
|
||||
var python = ResolvePython();
|
||||
var result = await RunAsync(
|
||||
python,
|
||||
["scripts/diag.py", "probe", "--tool", "python", "--json"]);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
using var doc = JsonDocument.Parse(result.StdOut);
|
||||
Assert.True(doc.RootElement.TryGetProperty("tool", out _));
|
||||
Assert.True(doc.RootElement.TryGetProperty("available", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAction_InvalidRequirements_PropagatesNonZeroAndJson()
|
||||
{
|
||||
var python = ResolvePython();
|
||||
var missingReq = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".txt");
|
||||
|
||||
var result = await RunAsync(
|
||||
python,
|
||||
[
|
||||
"scripts/build.py",
|
||||
"python-pip-install",
|
||||
"--project-root",
|
||||
RepoRoot(),
|
||||
"--requirements",
|
||||
missingReq,
|
||||
"--json"
|
||||
]);
|
||||
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
var jsonText = ExtractLastJsonObject(result.StdOut);
|
||||
using var doc = JsonDocument.Parse(jsonText);
|
||||
Assert.True(doc.RootElement.TryGetProperty("exit_code", out var code));
|
||||
Assert.NotEqual(0, code.GetInt32());
|
||||
Assert.True(doc.RootElement.TryGetProperty("failure_reason", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAction_DotnetRestore_CommandNotFoundStillReturnsJson()
|
||||
{
|
||||
var python = ResolvePython();
|
||||
|
||||
var result = await RunAsync(
|
||||
python,
|
||||
[
|
||||
"scripts/build.py",
|
||||
"dotnet-restore",
|
||||
"--project-root",
|
||||
RepoRoot(),
|
||||
"--json"
|
||||
]);
|
||||
|
||||
var jsonText = ExtractLastJsonObject(result.StdOut);
|
||||
using var doc = JsonDocument.Parse(jsonText);
|
||||
Assert.True(doc.RootElement.TryGetProperty("exit_code", out _));
|
||||
Assert.True(doc.RootElement.TryGetProperty("status", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAction_DotnetBuild_AutoSelectsSlnTarget_WhenSingleSlnFound()
|
||||
{
|
||||
var python = ResolvePython();
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-dotnet-target-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
var sln = Path.Combine(tempRoot, "sample.sln");
|
||||
await File.WriteAllTextAsync(sln, "Microsoft Visual Studio Solution File, Format Version 12.00");
|
||||
|
||||
var result = await RunAsync(
|
||||
python,
|
||||
[
|
||||
"scripts/build.py",
|
||||
"dotnet-build",
|
||||
"--project-root",
|
||||
tempRoot,
|
||||
"--working-dir",
|
||||
".",
|
||||
"--json"
|
||||
]);
|
||||
|
||||
var jsonText = ExtractLastJsonObject(result.StdOut);
|
||||
using var doc = JsonDocument.Parse(jsonText);
|
||||
Assert.True(doc.RootElement.TryGetProperty("args", out var args));
|
||||
Assert.Contains(args.EnumerateArray().Select(x => x.GetString()), x => string.Equals(x, sln, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string ResolvePython()
|
||||
{
|
||||
var candidates = OperatingSystem.IsWindows()
|
||||
? new[] { "python", "py" }
|
||||
: new[] { "python3", "python" };
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = candidate,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
}
|
||||
};
|
||||
process.StartInfo.ArgumentList.Add("--version");
|
||||
process.Start();
|
||||
process.WaitForExit(2000);
|
||||
if (process.ExitCode == 0)
|
||||
return candidate;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Python executable not found for script smoke tests.");
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string StdOut, string StdErr)> RunAsync(string command, IReadOnlyList<string> args)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = command,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = RepoRoot(),
|
||||
};
|
||||
foreach (var arg in args)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
return (process.ExitCode, stdout, stderr);
|
||||
}
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "devtool.json")))
|
||||
return dir.FullName;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not locate repo root (devtool.json not found).");
|
||||
}
|
||||
|
||||
private static string ExtractLastJsonObject(string text)
|
||||
{
|
||||
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
for (var i = lines.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var line = lines[i];
|
||||
if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal))
|
||||
return line;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("No JSON object line found in script output.");
|
||||
}
|
||||
}
|
||||
311
Journal.DevTool/tests/DevTool.Tests/WorkflowExecutorTests.cs
Normal file
311
Journal.DevTool/tests/DevTool.Tests/WorkflowExecutorTests.cs
Normal file
@ -0,0 +1,311 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Sdt.Runner;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class WorkflowExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MissingPrereq_UserDeclines_ReturnsUserDeclined()
|
||||
{
|
||||
var executor = new WorkflowExecutor(
|
||||
new WorkflowPlanner(),
|
||||
new FakeProbeService(isAvailable: false),
|
||||
new FakeInstallerService(success: true),
|
||||
new FakeActionRunner(success: true),
|
||||
new RequirementResolver());
|
||||
|
||||
var cfg = new DevToolConfig();
|
||||
var wf = BuildSingleStepWorkflow("w", "dotnet");
|
||||
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
wf, map, cfg, ".", (_, _) => Task.FromResult(false), (_, _) => { });
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ExecutionStopReason.UserDeclined, result.StopReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingPrereq_InstallFails_ReturnsInstallFailed()
|
||||
{
|
||||
var executor = new WorkflowExecutor(
|
||||
new WorkflowPlanner(),
|
||||
new FakeProbeService(isAvailable: false),
|
||||
new FakeInstallerService(success: false),
|
||||
new FakeActionRunner(success: true),
|
||||
new RequirementResolver());
|
||||
|
||||
var cfg = new DevToolConfig();
|
||||
var wf = BuildSingleStepWorkflow("w", "dotnet");
|
||||
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
wf, map, cfg, ".", (_, _) => Task.FromResult(true), (_, _) => { });
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ExecutionStopReason.InstallFailed, result.StopReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StepFailure_StopsImmediately()
|
||||
{
|
||||
var executor = new WorkflowExecutor(
|
||||
new WorkflowPlanner(),
|
||||
new FakeProbeService(isAvailable: true),
|
||||
new FakeInstallerService(success: true),
|
||||
new FakeActionRunner(success: false),
|
||||
new RequirementResolver());
|
||||
|
||||
var wf = new WorkflowDefinition
|
||||
{
|
||||
Id = "w",
|
||||
Label = "W",
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep { Id = "s1", Label = "S1", Command = "dotnet", Args = ["build"] },
|
||||
new WorkflowStep { Id = "s2", Label = "S2", Command = "dotnet", Args = ["build"] },
|
||||
]
|
||||
};
|
||||
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(true), (_, _) => { });
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ExecutionStopReason.CommandFailed, result.StopReason);
|
||||
Assert.Single(result.Steps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyPwshScriptStep_MissingPrereq_PromptsBeforeRun()
|
||||
{
|
||||
var executor = new WorkflowExecutor(
|
||||
new WorkflowPlanner(),
|
||||
new FakeProbeService(isAvailable: false),
|
||||
new FakeInstallerService(success: true),
|
||||
new FakeActionRunner(success: true),
|
||||
new RequirementResolver());
|
||||
|
||||
var wf = new WorkflowDefinition
|
||||
{
|
||||
Id = "w",
|
||||
Label = "W",
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep
|
||||
{
|
||||
Id = "ps1",
|
||||
Label = "Legacy PS1",
|
||||
Command = "pwsh",
|
||||
Args = ["-NoProfile", "-File", "scripts/publish-app.ps1", "-Target", "web"],
|
||||
}
|
||||
]
|
||||
};
|
||||
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(false), (_, _) => { });
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ExecutionStopReason.UserDeclined, result.StopReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TauriBuild_DoesNotRequireGlobalTauri_WhenNodeNpmCargoAvailable()
|
||||
{
|
||||
var executor = new WorkflowExecutor(
|
||||
new WorkflowPlanner(),
|
||||
new ConditionalProbeService(),
|
||||
new FakeInstallerService(success: true),
|
||||
new FakeActionRunner(success: true),
|
||||
new RequirementResolver());
|
||||
|
||||
var wf = new WorkflowDefinition
|
||||
{
|
||||
Id = "w",
|
||||
Label = "W",
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep
|
||||
{
|
||||
Id = "tauri",
|
||||
Label = "Tauri Build",
|
||||
Action = "tauri-build",
|
||||
}
|
||||
]
|
||||
};
|
||||
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(true), (_, _) => { });
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.StopReason);
|
||||
Assert.Single(result.Steps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingPrereq_EmitsProbeDiagnosticsToOutput()
|
||||
{
|
||||
var executor = new WorkflowExecutor(
|
||||
new WorkflowPlanner(),
|
||||
new DetailedProbeService(),
|
||||
new FakeInstallerService(success: true),
|
||||
new FakeActionRunner(success: true),
|
||||
new RequirementResolver());
|
||||
|
||||
var wf = BuildSingleStepWorkflow("w", "dotnet");
|
||||
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
|
||||
var lines = new List<string>();
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(false), (line, _) => lines.Add(line));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(lines, l => l.Contains("Probe detail [dotnet]", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_EmitsRunEvents_ForStepLifecycle()
|
||||
{
|
||||
var executor = new WorkflowExecutor(
|
||||
new WorkflowPlanner(),
|
||||
new FakeProbeService(isAvailable: true),
|
||||
new FakeInstallerService(success: true),
|
||||
new FakeActionRunner(success: true),
|
||||
new RequirementResolver());
|
||||
|
||||
var wf = BuildSingleStepWorkflow("w", "dotnet");
|
||||
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf };
|
||||
var events = new List<RunEvent>();
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
wf, map, new DevToolConfig(), ".", (_, _) => Task.FromResult(true), (_, _) => { }, events.Add);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains(events, e => e.Type == RunEventType.WorkflowStarted);
|
||||
Assert.Contains(events, e => e.Type == RunEventType.WorkflowStepStarted);
|
||||
Assert.Contains(events, e => e.Type == RunEventType.WorkflowStepCompleted);
|
||||
Assert.Contains(events, e => e.Type == RunEventType.WorkflowCompleted && e.Success == true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AggregatorWorkflow_ExecutesDependenciesOnly()
|
||||
{
|
||||
var planner = new WorkflowPlanner();
|
||||
var dep = new WorkflowDefinition
|
||||
{
|
||||
Id = "dep",
|
||||
Label = "Dependency",
|
||||
Steps = [new WorkflowStep { Id = "s", Label = "S", Command = "dotnet", Args = ["build"] }]
|
||||
};
|
||||
var agg = new WorkflowDefinition
|
||||
{
|
||||
Id = "agg",
|
||||
Label = "Aggregator",
|
||||
DependsOn = ["dep"],
|
||||
Steps = []
|
||||
};
|
||||
|
||||
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[dep.Id] = dep,
|
||||
[agg.Id] = agg
|
||||
};
|
||||
|
||||
var plan = planner.ResolvePlan(agg, map);
|
||||
|
||||
Assert.Single(plan);
|
||||
Assert.Equal("dep", plan[0].Id);
|
||||
}
|
||||
|
||||
private static WorkflowDefinition BuildSingleStepWorkflow(string id, string tool)
|
||||
{
|
||||
return new WorkflowDefinition
|
||||
{
|
||||
Id = id,
|
||||
Label = id,
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep
|
||||
{
|
||||
Id = "step",
|
||||
Label = "step",
|
||||
Command = tool,
|
||||
Args = ["--version"],
|
||||
Requires = [new ToolRequirement { Tool = tool, InstallPolicy = InstallPolicy.Prompt }],
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeProbeService(bool isAvailable) : IToolProbe
|
||||
{
|
||||
public Task<ProbeResult> ProbeAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProbeResult(tool, isAvailable, Version: isAvailable ? "1.0.0" : null));
|
||||
}
|
||||
|
||||
private sealed class FakeInstallerService(bool success) : IPrereqInstaller
|
||||
{
|
||||
public Task<InstallPlan> GetInstallPlanAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new InstallPlan(tool, Supported: true, "test", [new InstallCommand("echo", ["ok"])]));
|
||||
|
||||
public Task<RunResult> RunInstallAsync(
|
||||
InstallCommand command,
|
||||
string projectRoot,
|
||||
Action<string, bool> onOutput,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new RunResult(success ? 0 : 1, TimeSpan.FromMilliseconds(5)));
|
||||
}
|
||||
|
||||
private sealed class FakeActionRunner(bool success) : IActionRunner
|
||||
{
|
||||
public Task<RunResult> RunStepAsync(
|
||||
WorkflowStep step,
|
||||
string projectRoot,
|
||||
Action<string, bool> onOutput,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new RunResult(success ? 0 : 2, TimeSpan.FromMilliseconds(10)));
|
||||
}
|
||||
|
||||
private sealed class ConditionalProbeService : IToolProbe
|
||||
{
|
||||
public Task<ProbeResult> ProbeAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var available = tool.ToLowerInvariant() switch
|
||||
{
|
||||
"node" => true,
|
||||
"npm" => true,
|
||||
"cargo" => true,
|
||||
"tauri" => false,
|
||||
_ => true
|
||||
};
|
||||
return Task.FromResult(new ProbeResult(tool, available, Version: available ? "1.0.0" : null));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DetailedProbeService : IToolProbe
|
||||
{
|
||||
public Task<ProbeResult> ProbeAsync(
|
||||
string tool,
|
||||
string projectRoot,
|
||||
DevToolConfig? config = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new ProbeResult(tool, false, Details: "Fallback: unresolved command"));
|
||||
}
|
||||
}
|
||||
149
Journal.DevTool/tests/DevTool.Tests/WorkflowModelBuilderTests.cs
Normal file
149
Journal.DevTool/tests/DevTool.Tests/WorkflowModelBuilderTests.cs
Normal file
@ -0,0 +1,149 @@
|
||||
using Sdt.Config;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class WorkflowModelBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void TargetsOnly_Strict_ThrowsMigrationError()
|
||||
{
|
||||
var cfg = new DevToolConfig
|
||||
{
|
||||
Targets =
|
||||
[
|
||||
new BuildTarget
|
||||
{
|
||||
Id = "build",
|
||||
Label = "Build",
|
||||
Command = "dotnet",
|
||||
Args = ["build"],
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => WorkflowModelBuilder.Normalize(cfg, LegacyMode.Strict));
|
||||
Assert.Contains("Legacy 'targets' are not allowed in strict mode", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TargetsOnly_Compat_ProducesWarningAndConvertedWorkflow()
|
||||
{
|
||||
var cfg = new DevToolConfig
|
||||
{
|
||||
Targets =
|
||||
[
|
||||
new BuildTarget
|
||||
{
|
||||
Id = "build",
|
||||
Label = "Build",
|
||||
Command = "dotnet",
|
||||
Args = ["build"],
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = WorkflowModelBuilder.Normalize(cfg, LegacyMode.Compat);
|
||||
|
||||
Assert.Single(result.Workflows);
|
||||
Assert.Contains(result.Warnings, w => w.Contains("legacy 'targets'", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkflowsOnly_HasNoLegacyWarning()
|
||||
{
|
||||
var cfg = new DevToolConfig
|
||||
{
|
||||
Workflows =
|
||||
[
|
||||
new WorkflowDefinition
|
||||
{
|
||||
Id = "build",
|
||||
Label = "Build",
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep
|
||||
{
|
||||
Id = "run",
|
||||
Label = "Run",
|
||||
Command = "dotnet",
|
||||
Args = ["build"],
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = WorkflowModelBuilder.Normalize(cfg, LegacyMode.Strict);
|
||||
|
||||
Assert.Single(result.Workflows);
|
||||
Assert.DoesNotContain(result.Warnings, w => w.Contains("legacy 'targets'", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_PrefersWorkflowsDeterministically()
|
||||
{
|
||||
var cfg = new DevToolConfig
|
||||
{
|
||||
Targets =
|
||||
[
|
||||
new BuildTarget
|
||||
{
|
||||
Id = "legacy",
|
||||
Label = "Legacy",
|
||||
Command = "dotnet",
|
||||
Args = ["build"],
|
||||
}
|
||||
],
|
||||
Workflows =
|
||||
[
|
||||
new WorkflowDefinition
|
||||
{
|
||||
Id = "new",
|
||||
Label = "New",
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep
|
||||
{
|
||||
Id = "step",
|
||||
Label = "Step",
|
||||
Command = "dotnet",
|
||||
Args = ["build"],
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = WorkflowModelBuilder.Normalize(cfg, LegacyMode.Strict);
|
||||
|
||||
Assert.Single(result.Workflows);
|
||||
Assert.Equal("new", result.Workflows[0].Id);
|
||||
Assert.Contains(result.Warnings, w => w.Contains("Both 'workflows' and legacy 'targets'", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyPwshTarget_InfersToolRequirements_FromScript()
|
||||
{
|
||||
var cfg = new DevToolConfig
|
||||
{
|
||||
Targets =
|
||||
[
|
||||
new BuildTarget
|
||||
{
|
||||
Id = "web",
|
||||
Label = "Web",
|
||||
Command = "pwsh",
|
||||
Args = ["-NoProfile", "-File", "scripts/publish-app.ps1", "-Target", "web"],
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = WorkflowModelBuilder.Normalize(cfg, LegacyMode.Compat);
|
||||
var step = Assert.Single(result.Workflows).Steps.Single();
|
||||
|
||||
Assert.Contains(step.Requires, r => r.Tool == "python");
|
||||
Assert.Contains(step.Requires, r => r.Tool == "node");
|
||||
Assert.Contains(step.Requires, r => r.Tool == "npm");
|
||||
}
|
||||
}
|
||||
141
Journal.DevTool/tests/DevTool.Tests/WorkspaceDefaultsTests.cs
Normal file
141
Journal.DevTool/tests/DevTool.Tests/WorkspaceDefaultsTests.cs
Normal file
@ -0,0 +1,141 @@
|
||||
using Sdt.Config;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class WorkspaceDefaultsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConfigLoader_AppliesWorkspaceDefaults_FromAncestorDirectory()
|
||||
{
|
||||
var workspaceRoot = CreateTempDir("sdt-ws-defaults-");
|
||||
var projectRoot = Path.Combine(workspaceRoot, "proj-a");
|
||||
Directory.CreateDirectory(projectRoot);
|
||||
File.WriteAllText(Path.Combine(workspaceRoot, WorkspaceLoader.FileName), """
|
||||
{
|
||||
"name": "Test Workspace",
|
||||
"projects": []
|
||||
}
|
||||
""");
|
||||
|
||||
File.WriteAllText(Path.Combine(workspaceRoot, ConfigLoader.WorkspaceDefaultsFileName), """
|
||||
{
|
||||
"toolchains": {
|
||||
"node": {
|
||||
"packageManager": "pnpm",
|
||||
"workingDir": "frontend"
|
||||
}
|
||||
},
|
||||
"env": [
|
||||
{ "key": "DOTNET_ENVIRONMENT", "description": "default env", "default": "Development", "options": ["Development", "Production"] }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
File.WriteAllText(Path.Combine(projectRoot, "devtool.json"), """
|
||||
{
|
||||
"name": "Project A",
|
||||
"version": "1.0.0",
|
||||
"workflows": [
|
||||
{
|
||||
"id": "build",
|
||||
"label": "Build",
|
||||
"description": "Build app",
|
||||
"group": "Build",
|
||||
"dependsOn": [],
|
||||
"steps": [
|
||||
{ "id": "build-step", "label": "dotnet build", "command": "dotnet", "args": ["build"], "workingDir": "." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var loaded = ConfigLoader.FindAndLoad(projectRoot);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("pnpm", loaded!.Config.Toolchains?.Node?.PackageManager);
|
||||
Assert.Equal("frontend", loaded.Config.Toolchains?.Node?.WorkingDir);
|
||||
Assert.Single(loaded.Config.Env);
|
||||
Assert.Contains(loaded.Warnings, w => w.Contains("Applied workspace defaults", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigLoader_ProjectValuesOverrideWorkspaceDefaults()
|
||||
{
|
||||
var workspaceRoot = CreateTempDir("sdt-ws-override-");
|
||||
var projectRoot = Path.Combine(workspaceRoot, "proj-b");
|
||||
Directory.CreateDirectory(projectRoot);
|
||||
File.WriteAllText(Path.Combine(workspaceRoot, WorkspaceLoader.FileName), """
|
||||
{
|
||||
"name": "Test Workspace",
|
||||
"projects": []
|
||||
}
|
||||
""");
|
||||
|
||||
File.WriteAllText(Path.Combine(workspaceRoot, ConfigLoader.WorkspaceDefaultsFileName), """
|
||||
{
|
||||
"name": "Workspace Defaults",
|
||||
"workflows": [
|
||||
{
|
||||
"id": "from-defaults",
|
||||
"label": "Defaults Workflow",
|
||||
"description": "",
|
||||
"group": "General",
|
||||
"dependsOn": [],
|
||||
"steps": []
|
||||
}
|
||||
],
|
||||
"debug": {
|
||||
"diagnostics": {
|
||||
"enabled": true,
|
||||
"outputDir": ".sdt/workspace-debug",
|
||||
"includeAllEnv": true,
|
||||
"bundleOnFailure": true
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
File.WriteAllText(Path.Combine(projectRoot, "devtool.json"), """
|
||||
{
|
||||
"name": "Project B",
|
||||
"version": "1.0.0",
|
||||
"workflows": [
|
||||
{
|
||||
"id": "project-workflow",
|
||||
"label": "Project Workflow",
|
||||
"description": "Only this one should remain",
|
||||
"group": "Build",
|
||||
"dependsOn": [],
|
||||
"steps": []
|
||||
}
|
||||
],
|
||||
"debug": {
|
||||
"diagnostics": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var loaded = ConfigLoader.FindAndLoad(projectRoot);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Project B", loaded!.Config.Name);
|
||||
Assert.Single(loaded.Config.Workflows);
|
||||
Assert.Equal("project-workflow", loaded.Config.Workflows[0].Id);
|
||||
Assert.NotNull(loaded.Config.Debug);
|
||||
Assert.NotNull(loaded.Config.Debug!.Diagnostics);
|
||||
Assert.False(loaded.Config.Debug.Diagnostics.Enabled);
|
||||
Assert.Equal(".sdt/workspace-debug", loaded.Config.Debug.Diagnostics.OutputDir);
|
||||
Assert.True(loaded.Config.Debug.Diagnostics.IncludeAllEnv);
|
||||
}
|
||||
|
||||
private static string CreateTempDir(string prefix)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), prefix + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
56
Journal.DevTool/tests/DevTool.Tests/WorkspaceLoaderTests.cs
Normal file
56
Journal.DevTool/tests/DevTool.Tests/WorkspaceLoaderTests.cs
Normal file
@ -0,0 +1,56 @@
|
||||
using Sdt.Config;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class WorkspaceLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void FindAndLoad_DoesNotThrow_WhenAutoDiscoverHitsStrictLegacyProject()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sdt-workspace-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
File.WriteAllText(Path.Combine(root, "devtool.json"), """
|
||||
{
|
||||
"name": "legacy",
|
||||
"version": "0.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"id": "build",
|
||||
"label": "Build",
|
||||
"group": "Build",
|
||||
"command": "dotnet",
|
||||
"args": ["build"],
|
||||
"workingDir": ".",
|
||||
"dependsOn": []
|
||||
}
|
||||
],
|
||||
"workflows": []
|
||||
}
|
||||
""");
|
||||
|
||||
Environment.SetEnvironmentVariable("SDT_LEGACY_MODE", null);
|
||||
var result = WorkspaceLoader.FindAndLoad(root);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveProjectRoot_AcceptsAbsolutePaths()
|
||||
{
|
||||
var root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "ws-" + Guid.NewGuid().ToString("N")));
|
||||
var abs = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "proj-" + Guid.NewGuid().ToString("N")));
|
||||
var project = new WorkspaceProject { Path = abs };
|
||||
|
||||
var resolved = WorkspaceLoader.ResolveProjectRoot(root, project);
|
||||
Assert.Equal(abs, resolved, ignoreCase: OperatingSystem.IsWindows());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkspaceProject_AdditionalFields_DefaultsAreSafe()
|
||||
{
|
||||
var project = new WorkspaceProject();
|
||||
Assert.Empty(project.Tags);
|
||||
Assert.Empty(project.ToolFamilies);
|
||||
Assert.False(project.Disabled);
|
||||
}
|
||||
}
|
||||
1032
devtool.json
1032
devtool.json
File diff suppressed because it is too large
Load Diff
105
justfile
105
justfile
@ -1,105 +0,0 @@
|
||||
# SDT — Project Journal Justfile
|
||||
# Install just: https://just.systems/man/en/packages.html
|
||||
|
||||
set windows-shell := ["pwsh", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"]
|
||||
set shell := ["pwsh", "-c"]
|
||||
|
||||
# Detect runtime from OS
|
||||
runtime := if os() == "windows" { "win-x64" } else { "linux-x64" }
|
||||
|
||||
# ── Default: list available recipes ────────────────────────────────────────────
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Build Journal.Sidecar as self-contained single-file exe
|
||||
sidecar:
|
||||
& ./scripts/publish-sidecar.ps1 -Configuration Release -Runtime {{runtime}}
|
||||
|
||||
# Build SvelteKit web bundle (output: Journal.App/build/)
|
||||
web:
|
||||
& ./scripts/publish-app.ps1 -Target web
|
||||
|
||||
# Publish WebGateway with embedded web UI (depends: web)
|
||||
webgateway: web
|
||||
& ./scripts/publish-webgateway.ps1 -Configuration Release -Runtime {{runtime}}
|
||||
|
||||
# Build Tauri desktop exe — no installer (depends: sidecar)
|
||||
tauri: sidecar
|
||||
& ./scripts/publish-app.ps1 -Target tauri -TauriBundles none
|
||||
|
||||
# Build Tauri with NSIS installer (depends: sidecar)
|
||||
tauri-nsis: sidecar
|
||||
& ./scripts/publish-app.ps1 -Target tauri -TauriBundles nsis
|
||||
|
||||
# Build Tauri with MSI installer (depends: sidecar)
|
||||
tauri-msi: sidecar
|
||||
& ./scripts/publish-app.ps1 -Target tauri -TauriBundles msi
|
||||
|
||||
# Full release build — everything in correct order
|
||||
all: sidecar web webgateway tauri
|
||||
|
||||
# ── Dev ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Run WebGateway dev server (http://localhost:5180)
|
||||
run:
|
||||
& ./scripts/run-webgateway.ps1
|
||||
|
||||
# Run WebGateway with pinned project root (avoids multi-clone ambiguity)
|
||||
run-pinned:
|
||||
& ./scripts/run-webgateway.ps1 -ProjectRoot {{justfile_directory()}} -Urls http://0.0.0.0:5180
|
||||
|
||||
# SvelteKit dev server only (http://localhost:1420)
|
||||
dev-app:
|
||||
Set-Location Journal.App; npm run dev
|
||||
|
||||
# Tauri dev mode (desktop window + hot reload)
|
||||
dev-tauri: sidecar
|
||||
Set-Location Journal.App; npm run tauri dev
|
||||
|
||||
# ── Test ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Run all smoke tests (~80 integration tests)
|
||||
test:
|
||||
dotnet run --project Journal.SmokeTests/Journal.SmokeTests.csproj
|
||||
|
||||
# Full migration gate (build + smoke + parity)
|
||||
gate:
|
||||
& ./scripts/migration-gate.ps1
|
||||
|
||||
# Migration gate — skip smoke tests
|
||||
gate-fast:
|
||||
& ./scripts/migration-gate.ps1 -SkipSmoke
|
||||
|
||||
# Migration gate — skip API contract tests
|
||||
gate-no-api:
|
||||
& ./scripts/migration-gate.ps1 -SkipApi
|
||||
|
||||
# ── .NET ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# dotnet build all projects
|
||||
build:
|
||||
dotnet build
|
||||
|
||||
# dotnet build with resilient NuGet defaults (use in restricted environments)
|
||||
build-safe:
|
||||
& ./scripts/dotnet-min.ps1 build Journal.Core/Journal.Core.csproj
|
||||
& ./scripts/dotnet-min.ps1 build Journal.Sidecar/Journal.Sidecar.csproj
|
||||
& ./scripts/dotnet-min.ps1 build Journal.WebGateway/Journal.WebGateway.csproj
|
||||
|
||||
# ── NuGet Cache ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Export NuGet cache to zip for offline/transfer use
|
||||
nuget-export zip="nuget-cache-export.zip":
|
||||
& ./scripts/nuget-export-cache.ps1 -OutputZip {{zip}}
|
||||
|
||||
# Import NuGet cache zip and validate restore
|
||||
nuget-import zip="nuget-cache-export.zip":
|
||||
& ./scripts/nuget-import-cache.ps1 -InputZip {{zip}}
|
||||
|
||||
# ── SDT ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Launch SDT dev tool TUI
|
||||
sdt:
|
||||
dotnet run --project Journal.DevTool/Journal.DevTool.csproj
|
||||
@ -1,328 +0,0 @@
|
||||
# Scripts Reference
|
||||
|
||||
This folder contains PowerShell wrappers for repeatable local development, build, publish, and cache operations.
|
||||
|
||||
## Scope
|
||||
|
||||
These scripts target the repository rooted at `E:\stansshit\csharp\journal-master\journal`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- PowerShell 7+ (`pwsh` recommended)
|
||||
- .NET SDK (current repo uses `net10.0` targets)
|
||||
- Python (for `pip-min.ps1`, `migration-gate.ps1`)
|
||||
- Node.js + npm (for `publish-app.ps1`)
|
||||
|
||||
## Execution Policy
|
||||
|
||||
If script execution is blocked on Windows, run commands with one of:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\<script>.ps1
|
||||
```
|
||||
|
||||
or in-session:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
```
|
||||
|
||||
## Script Overview
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `dev-shell.ps1` | Initializes current shell with repo-local .NET/pip/HuggingFace cache env vars. |
|
||||
| `script-common.ps1` | Shared helper functions used by most scripts. |
|
||||
| `dotnet-min.ps1` | Minimal `dotnet` wrapper with local NuGet cache and safe defaults. |
|
||||
| `pip-min.ps1` | Minimal `pip` wrapper with repo-local target/cache and Windows compatibility mapping. |
|
||||
| `pip_safe.py` | Python wrapper used by `pip-min.ps1` to patch temporary-directory behavior. |
|
||||
| `publish-app.ps1` | Builds frontend web bundle or Tauri desktop app. |
|
||||
| `publish-sidecar.ps1` | Publishes `Journal.Sidecar` single-file executable to `output/`. |
|
||||
| `publish-webgateway.ps1` | Publishes `Journal.WebGateway` and optionally copies web assets into `wwwroot`. |
|
||||
| `run-webgateway.ps1` | Runs `Journal.WebGateway` with configurable URLs and project root. |
|
||||
| `migration-gate.ps1` | End-to-end migration/parity gate (build + smoke/parity/API checks). |
|
||||
| `nuget-export-cache.ps1` | Primes and exports `.nuget` cache to zip for transfer. |
|
||||
| `nuget-import-cache.ps1` | Imports exported cache zip and validates restore. |
|
||||
|
||||
## Common Patterns Used Across Scripts
|
||||
|
||||
- Proxy cleanup: `Clear-JournalProxyEnv`
|
||||
- Repo root detection: `Resolve-JournalRepoRoot`
|
||||
- Repo-local caches:
|
||||
- `.dotnet_home`
|
||||
- `.nuget\packages`
|
||||
- `.nuget\http-cache`
|
||||
- `.pip\cache`
|
||||
- `.tmp\*`
|
||||
- `.npm\cache`
|
||||
|
||||
## Detailed Reference
|
||||
|
||||
### `dev-shell.ps1`
|
||||
|
||||
Dot-source this script to configure your *current shell*.
|
||||
|
||||
```powershell
|
||||
. .\scripts\dev-shell.ps1
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
- loads `script-common.ps1`
|
||||
- resolves repo root
|
||||
- clears proxy env vars
|
||||
- initializes repo-local .NET, pip, and HuggingFace env vars
|
||||
|
||||
Use this once per shell session before running development commands manually.
|
||||
|
||||
### `script-common.ps1`
|
||||
|
||||
Shared helper functions:
|
||||
|
||||
- `Clear-JournalProxyEnv`
|
||||
- `Resolve-JournalRepoRoot`
|
||||
- `Initialize-JournalDotnetEnv`
|
||||
- `Initialize-JournalPipEnv`
|
||||
- `Initialize-JournalHuggingFaceEnv`
|
||||
- `Resolve-JournalSidecarProjectPath`
|
||||
- `Resolve-JournalAppRoot`
|
||||
- `Resolve-JournalWebGatewayProjectPath`
|
||||
|
||||
Also supports optional override:
|
||||
|
||||
- `JOURNAL_REPO_ROOT`
|
||||
|
||||
### `dotnet-min.ps1`
|
||||
|
||||
Wrapper around `dotnet` with resilient restore/build defaults.
|
||||
|
||||
Usage:
|
||||
|
||||
```powershell
|
||||
.\scripts\dotnet-min.ps1 <dotnet args>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
.\scripts\dotnet-min.ps1 build Journal.Sidecar/Journal.Sidecar.csproj
|
||||
.\scripts\dotnet-min.ps1 restore Journal.WebGateway/Journal.WebGateway.csproj
|
||||
.\scripts\dotnet-min.ps1 run --project Journal.Sidecar/Journal.Sidecar.csproj
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- sets repo-local `DOTNET_CLI_HOME` and NuGet cache paths
|
||||
- clears proxy env vars
|
||||
- adds for common commands (`restore`, `build`, `run`, `test`, `publish`, `pack`):
|
||||
- `-p:RestoreIgnoreFailedSources=true`
|
||||
- `-p:NuGetAudit=false`
|
||||
- for `restore`, also adds `--ignore-failed-sources`
|
||||
|
||||
### `pip-min.ps1`
|
||||
|
||||
Wrapper around `python -m pip` focused on constrained hosts.
|
||||
|
||||
Usage:
|
||||
|
||||
```powershell
|
||||
.\scripts\pip-min.ps1 <pip args>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
.\scripts\pip-min.ps1 install requests
|
||||
.\scripts\pip-min.ps1 install --index-url https://pypi.org/simple faster-whisper
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- initializes repo-local pip cache/temp paths
|
||||
- clears proxy env vars
|
||||
- for `install` without target/prefix:
|
||||
- strips `--user`
|
||||
- installs into `.pydeps\py314`
|
||||
- on Windows, maps `pyaudio` to `pyaudiowpatch`
|
||||
- uses `pip_safe.py` if present
|
||||
|
||||
### `pip_safe.py`
|
||||
|
||||
Compatibility wrapper for pip on some Windows + Python 3.14 setups.
|
||||
|
||||
Behavior:
|
||||
|
||||
- monkey-patches `tempfile.mkdtemp` to create writable temp directories (`0o777`)
|
||||
- then forwards to pip internal CLI
|
||||
|
||||
### `publish-app.ps1`
|
||||
|
||||
Builds the frontend (`web`) or Tauri desktop app (`tauri`).
|
||||
|
||||
Parameters:
|
||||
|
||||
- `-Target web|tauri` (default `web`)
|
||||
- `-Configuration Release|Debug` (default `Release`)
|
||||
- `-TauriBundles none|nsis|msi` (default `none`)
|
||||
- `-InstallDeps`
|
||||
- `-SkipInstall`
|
||||
- `-DryRun`
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-app.ps1 -Target web
|
||||
.\scripts\publish-app.ps1 -Target tauri -TauriBundles none
|
||||
.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- uses repo-local npm cache/temp paths
|
||||
- `-TauriBundles none` maps to `tauri build --no-bundle` (raw exe build)
|
||||
- expected web output: `Journal.App\build`
|
||||
- expected tauri exe location: `Journal.App\src-tauri\target\release\journalapp.exe` (Release)
|
||||
|
||||
### `publish-sidecar.ps1`
|
||||
|
||||
Publishes `Journal.Sidecar` as a self-contained single-file executable.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `-Configuration` (default `Release`)
|
||||
- `-Runtime` (default `win-x64`)
|
||||
|
||||
Example:
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-sidecar.ps1 -Configuration Release -Runtime win-x64
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
- `output\Journal.Sidecar.exe` (for Windows runtime)
|
||||
|
||||
### `publish-webgateway.ps1`
|
||||
|
||||
Publishes `Journal.WebGateway` and optionally embeds built web assets.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `-Configuration Release|Debug` (default `Release`)
|
||||
- `-Runtime` (default `win-x64`)
|
||||
- `-SelfContained` (switch)
|
||||
- `-SkipWebAssets` (switch)
|
||||
|
||||
Example:
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-app.ps1 -Target web
|
||||
.\scripts\publish-webgateway.ps1 -Configuration Release -Runtime win-x64
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
- `output\webgateway\`
|
||||
- optional `output\webgateway\wwwroot\` from `Journal.App\build`
|
||||
|
||||
### `run-webgateway.ps1`
|
||||
|
||||
Runs `Journal.WebGateway` directly with controlled environment.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `-Configuration Release|Debug` (default `Release`)
|
||||
- `-Urls` (default `http://0.0.0.0:5180`)
|
||||
- `-ProjectRoot` (optional; if omitted, repo root)
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
.\scripts\run-webgateway.ps1
|
||||
.\scripts\run-webgateway.ps1 -Urls http://127.0.0.1:5180
|
||||
.\scripts\run-webgateway.ps1 -ProjectRoot E:\stansshit\csharp\journal-master\journal
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- sets `JOURNAL_PROJECT_ROOT` for the gateway process
|
||||
- useful when multiple repo clones exist and you need deterministic data/vault paths
|
||||
|
||||
### `migration-gate.ps1`
|
||||
|
||||
Runs migration quality gate.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `-SkipSmoke`
|
||||
- `-SkipApi`
|
||||
|
||||
Current behavior:
|
||||
|
||||
1. builds `Journal.Sidecar`
|
||||
2. optionally runs `Journal.SmokeTests`
|
||||
3. runs Python parity harness test discovery
|
||||
4. optionally runs API contract test discovery
|
||||
|
||||
If `tests/` is absent, Python parity and API contract steps are skipped automatically with a clear message — the sidecar build and smoke tests still run.
|
||||
|
||||
### `nuget-export-cache.ps1`
|
||||
|
||||
Primes and exports NuGet cache to zip.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `-OutputZip` (default `nuget-cache-export.zip`)
|
||||
- `-IncludeDotnetHome`
|
||||
|
||||
Behavior:
|
||||
|
||||
- runs restore for selected projects via `dotnet-min.ps1`
|
||||
- copies `.nuget` (and optional `.dotnet_home`) to staging
|
||||
- writes `nuget-cache-manifest.txt`
|
||||
- outputs zip
|
||||
|
||||
Primes restore for `Journal.Sidecar`, `Journal.WebGateway`, and `Journal.SmokeTests`.
|
||||
|
||||
### `nuget-import-cache.ps1`
|
||||
|
||||
Imports exported cache zip and validates by restore.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `-InputZip` (default `nuget-cache-export.zip`)
|
||||
|
||||
Behavior:
|
||||
|
||||
- extracts zip into repo root
|
||||
- runs restore for selected projects via `dotnet-min.ps1`
|
||||
|
||||
Validates restore for `Journal.Sidecar`, `Journal.WebGateway`, and `Journal.SmokeTests`.
|
||||
|
||||
## Environment Variables Used
|
||||
|
||||
- `JOURNAL_REPO_ROOT` (optional override for repo detection)
|
||||
- `JOURNAL_PROJECT_ROOT` (runtime project root for gateway/sidecar config)
|
||||
- `DOTNET_CLI_HOME`
|
||||
- `NUGET_PACKAGES`
|
||||
- `NUGET_HTTP_CACHE_PATH`
|
||||
- `NUGET_CERT_REVOCATION_MODE`
|
||||
- `PIP_CACHE_DIR`
|
||||
- `HF_HOME`
|
||||
- `HUGGINGFACE_HUB_CACHE`
|
||||
|
||||
## Output and Cache Directories
|
||||
|
||||
- `output/`
|
||||
- `output/webgateway/`
|
||||
- `.dotnet_home/`
|
||||
- `.nuget/packages/`
|
||||
- `.nuget/http-cache/`
|
||||
- `.npm/cache/`
|
||||
- `.pip/cache/`
|
||||
- `.tmp/`
|
||||
- `.pydeps/`
|
||||
|
||||
## See Also
|
||||
|
||||
- [`WORKFLOWS.md`](./WORKFLOWS.md) for copy/paste command recipes.
|
||||
@ -1,140 +0,0 @@
|
||||
# Script Workflows
|
||||
|
||||
Practical command recipes for common tasks.
|
||||
|
||||
## 1) Start a Clean Dev Shell
|
||||
|
||||
```powershell
|
||||
cd E:\stansshit\csharp\journal-master\journal
|
||||
. .\scripts\dev-shell.ps1
|
||||
```
|
||||
|
||||
Use this when you want the current shell configured with repo-local cache paths.
|
||||
|
||||
## 2) Build .NET Projects With Safe Defaults
|
||||
|
||||
```powershell
|
||||
.\scripts\dotnet-min.ps1 build Journal.Sidecar/Journal.Sidecar.csproj
|
||||
.\scripts\dotnet-min.ps1 build Journal.WebGateway/Journal.WebGateway.csproj
|
||||
```
|
||||
|
||||
## 3) Build Frontend or Desktop App
|
||||
|
||||
Web build:
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-app.ps1 -Target web
|
||||
```
|
||||
|
||||
Tauri raw exe (no installer):
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-app.ps1 -Target tauri -TauriBundles none
|
||||
```
|
||||
|
||||
Tauri installer bundles:
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis
|
||||
.\scripts\publish-app.ps1 -Target tauri -TauriBundles msi
|
||||
```
|
||||
|
||||
## 4) Publish Sidecar
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-sidecar.ps1 -Configuration Release -Runtime win-x64
|
||||
```
|
||||
|
||||
Expected output under `output/`.
|
||||
|
||||
## 5) Publish WebGateway Package
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-app.ps1 -Target web
|
||||
.\scripts\publish-webgateway.ps1 -Configuration Release -Runtime win-x64
|
||||
```
|
||||
|
||||
If you only want gateway binary refresh:
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-webgateway.ps1 -SkipWebAssets
|
||||
```
|
||||
|
||||
## 6) Run WebGateway Against Explicit Root
|
||||
|
||||
When multiple clones exist, always pin project root:
|
||||
|
||||
```powershell
|
||||
.\scripts\run-webgateway.ps1 -ProjectRoot E:\stansshit\csharp\journal-master\journal -Urls http://0.0.0.0:5180
|
||||
```
|
||||
|
||||
Quick health check:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod http://127.0.0.1:5180/api/health
|
||||
```
|
||||
|
||||
Inspect active backend config:
|
||||
|
||||
```powershell
|
||||
$body = @{ action = 'config.get' } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri http://127.0.0.1:5180/api/command -Method Post -ContentType 'application/json' -Body $body
|
||||
```
|
||||
|
||||
## 7) Python Package Installs in Repo-Local Target
|
||||
|
||||
```powershell
|
||||
.\scripts\pip-min.ps1 install requests
|
||||
```
|
||||
|
||||
Packages go to `.pydeps\py314` unless you pass your own `--target`/`--prefix`.
|
||||
|
||||
## 8) Migration Gate (When Full Test Assets Exist)
|
||||
|
||||
```powershell
|
||||
.\scripts\migration-gate.ps1
|
||||
```
|
||||
|
||||
Partial run:
|
||||
|
||||
```powershell
|
||||
.\scripts\migration-gate.ps1 -SkipSmoke -SkipApi
|
||||
```
|
||||
|
||||
If `tests/` is absent, Python parity and API contract steps are skipped with a clear message.
|
||||
|
||||
## 9) NuGet Cache Export/Import (Offline-ish Restore)
|
||||
|
||||
Export:
|
||||
|
||||
```powershell
|
||||
.\scripts\nuget-export-cache.ps1 -OutputZip .\nuget-cache-export.zip
|
||||
```
|
||||
|
||||
Import:
|
||||
|
||||
```powershell
|
||||
.\scripts\nuget-import-cache.ps1 -InputZip .\nuget-cache-export.zip
|
||||
```
|
||||
|
||||
Validates restore for `Journal.Sidecar`, `Journal.WebGateway`, and `Journal.SmokeTests`.
|
||||
|
||||
## 10) Quick Troubleshooting
|
||||
|
||||
Script blocked by execution policy:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File .\scripts\publish-app.ps1 -Target web
|
||||
```
|
||||
|
||||
Unexpected data/vault mismatch in WebGateway:
|
||||
|
||||
1. Run with explicit `-ProjectRoot`.
|
||||
2. Verify with `config.get` via `/api/command`.
|
||||
3. Verify sidecar root via `/api/sidecar/root`.
|
||||
|
||||
NuGet restore flakiness:
|
||||
|
||||
1. Use `dotnet-min.ps1` wrappers.
|
||||
2. Confirm proxy vars are cleared (`HTTP_PROXY`, `HTTPS_PROXY`, etc.).
|
||||
3. Ensure `.nuget` directories are writable.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user