From 96b9b6d79770febb906fc8b90651c24eefdd070c Mon Sep 17 00:00:00 2001 From: stan44 Date: Sun, 1 Mar 2026 13:00:19 -0600 Subject: [PATCH] feat: Introduce Journal.DevTool project with core services, scripts, and tests for development and workflow management. --- .gitignore | 14 +- Journal.App/package-lock.json | 20 +- Journal.DevTool/.gitignore | 15 + Journal.DevTool/Config/ConfigBootstrapper.cs | 626 ++++++++++ Journal.DevTool/Config/ConfigLoader.cs | 209 +++- Journal.DevTool/Config/DevToolConfig.cs | 99 ++ .../Config/WorkflowModelBuilder.cs | 100 ++ Journal.DevTool/Config/WorkspaceConfig.cs | 5 +- Journal.DevTool/Config/WorkspaceLoader.cs | 124 +- Journal.DevTool/Core/ActionRunner.cs | 207 ++++ Journal.DevTool/Core/CommandResolver.cs | 173 +++ .../Core/ConfigDoctorAutoFixService.cs | 67 ++ Journal.DevTool/Core/ConfigDoctorService.cs | 270 +++++ Journal.DevTool/Core/Contracts.cs | 87 ++ Journal.DevTool/Core/Debug/DebugContracts.cs | 52 + .../Core/Debug/DebugProfileRunner.cs | 185 +++ .../Core/Debug/DiagnosticsBundleService.cs | 99 ++ .../Core/LegacyScriptRequirementResolver.cs | 59 + .../Core/PrereqInstallerService.cs | 303 +++++ Journal.DevTool/Core/PythonResolver.cs | 46 + Journal.DevTool/Core/RequirementResolver.cs | 67 ++ Journal.DevTool/Core/RunEventJsonlRecorder.cs | 65 ++ Journal.DevTool/Core/RunEventLogReader.cs | 99 ++ Journal.DevTool/Core/RunEvents.cs | 34 + Journal.DevTool/Core/ScriptLocator.cs | 19 + Journal.DevTool/Core/ToolProbeService.cs | 122 ++ Journal.DevTool/Core/WorkflowExecutor.cs | 273 +++++ Journal.DevTool/Core/WorkflowPlanner.cs | 35 + Journal.DevTool/DevTool.csproj | 37 + Journal.DevTool/Program.cs | 134 ++- Journal.DevTool/README.md | 350 +++--- Journal.DevTool/ROADMAP.md | 50 + Journal.DevTool/Runner/ProcessRunner.cs | 9 +- Journal.DevTool/Tui/App.cs | 673 +++++++++-- Journal.DevTool/Tui/EventsScreen.cs | 126 ++ Journal.DevTool/Tui/ToolchainScreen.cs | 44 +- Journal.DevTool/Tui/WorkspaceScreen.cs | 209 ++-- Journal.DevTool/package-lock.json | 31 + Journal.DevTool/package.json | 5 + Journal.DevTool/scripts/README.md | 63 + Journal.DevTool/scripts/WORKFLOWS.md | 57 + Journal.DevTool/scripts/_pwsh-python-shim.ps1 | 39 + Journal.DevTool/scripts/build.py | 419 +++++++ Journal.DevTool/scripts/dev-shell.cmd | 17 + Journal.DevTool/scripts/dev-shell.ps1 | 21 + Journal.DevTool/scripts/dev-shell.sh | 16 + Journal.DevTool/scripts/dev_shell.py | 148 +++ Journal.DevTool/scripts/diag.py | 128 ++ Journal.DevTool/scripts/dotnet-min.py | 34 + .../scripts/legacy/dotnet-min.legacy.ps1 | 0 .../scripts/legacy/migration-gate.legacy.ps1 | 0 .../scripts/legacy/npm-clean.legacy.ps1 | 0 .../legacy/nuget-export-cache.legacy.ps1 | 0 .../legacy/nuget-import-cache.legacy.ps1 | 0 .../scripts/legacy/pip-min.legacy.ps1 | 0 .../scripts/legacy/publish-app.legacy.ps1 | 0 .../scripts/legacy/publish-output.legacy.ps1 | 0 .../scripts/legacy/publish-sidecar.legacy.ps1 | 0 .../legacy/publish-webgateway.legacy.ps1 | 0 .../scripts/legacy/run-webgateway.legacy.ps1 | 0 .../scripts/legacy/sync-output.legacy.ps1 | 0 Journal.DevTool/scripts/migration-gate.py | 44 + Journal.DevTool/scripts/npm-clean.py | 35 + Journal.DevTool/scripts/nuget-export-cache.py | 42 + Journal.DevTool/scripts/nuget-import-cache.py | 28 + Journal.DevTool/scripts/pip-min.py | 35 + Journal.DevTool/scripts/pip_safe.py | 46 + Journal.DevTool/scripts/publish-app.py | 91 ++ Journal.DevTool/scripts/publish-output.py | 90 ++ Journal.DevTool/scripts/publish-sidecar.ps1 | 10 + Journal.DevTool/scripts/publish-sidecar.py | 58 + Journal.DevTool/scripts/publish-webgateway.py | 78 ++ Journal.DevTool/scripts/run-webgateway.py | 60 + Journal.DevTool/scripts/script-common.ps1 | 124 ++ Journal.DevTool/scripts/script_common.py | 315 +++++ Journal.DevTool/scripts/sync-output.py | 82 ++ .../ActionRunnerLegacyPwshTests.cs | 31 + .../DevTool.Tests/CommandResolverTests.cs | 124 ++ .../DevTool.Tests/ConfigBootstrapperTests.cs | 108 ++ .../ConfigDoctorAutoFixServiceTests.cs | 54 + .../DevTool.Tests/ConfigDoctorServiceTests.cs | 80 ++ .../tests/DevTool.Tests/DebugConfigTests.cs | 40 + .../tests/DevTool.Tests/DebugServicesTests.cs | 191 +++ .../DevTool.Tests/DevShellScriptTests.cs | 120 ++ .../tests/DevTool.Tests/DevTool.Tests.csproj | 18 + .../tests/DevTool.Tests/LegacyModeTests.cs | 88 ++ .../PrereqInstallerServiceTests.cs | 99 ++ .../DevTool.Tests/RequirementResolverTests.cs | 44 + .../RunEventJsonlRecorderTests.cs | 31 + .../DevTool.Tests/RunEventLogReaderTests.cs | 48 + .../tests/DevTool.Tests/ScriptCommonTests.cs | 166 +++ .../tests/DevTool.Tests/ScriptSmokeTests.cs | 179 +++ .../DevTool.Tests/WorkflowExecutorTests.cs | 311 +++++ .../WorkflowModelBuilderTests.cs | 149 +++ .../DevTool.Tests/WorkspaceDefaultsTests.cs | 141 +++ .../DevTool.Tests/WorkspaceLoaderTests.cs | 56 + devtool.json | 1032 +++++++++++------ justfile | 105 -- scripts/README.md | 328 ------ scripts/WORKFLOWS.md | 140 --- scripts/_pwsh-python-shim.ps1 | 39 + scripts/build.py | 419 +++++++ scripts/dev-shell.cmd | 17 + scripts/dev-shell.ps1 | 33 +- scripts/dev-shell.sh | 16 + scripts/dev_shell.py | 148 +++ scripts/diag.py | 128 ++ scripts/dotnet-min.py | 34 + scripts/migration-gate.py | 44 + scripts/npm-clean.py | 35 + scripts/nuget-export-cache.py | 42 + scripts/nuget-import-cache.py | 28 + scripts/pip-min.py | 35 + scripts/publish-app.py | 91 ++ scripts/publish-output.py | 90 ++ scripts/publish-sidecar.py | 58 + scripts/publish-webgateway.py | 78 ++ scripts/run-webgateway.py | 60 + scripts/script-common.ps1 | 240 ---- scripts/script_common.py | 315 +++++ scripts/sync-output.py | 82 ++ sdt-workspace.json | 10 - 122 files changed, 11345 insertions(+), 1602 deletions(-) create mode 100644 Journal.DevTool/.gitignore create mode 100644 Journal.DevTool/Config/ConfigBootstrapper.cs create mode 100644 Journal.DevTool/Config/WorkflowModelBuilder.cs create mode 100644 Journal.DevTool/Core/ActionRunner.cs create mode 100644 Journal.DevTool/Core/CommandResolver.cs create mode 100644 Journal.DevTool/Core/ConfigDoctorAutoFixService.cs create mode 100644 Journal.DevTool/Core/ConfigDoctorService.cs create mode 100644 Journal.DevTool/Core/Contracts.cs create mode 100644 Journal.DevTool/Core/Debug/DebugContracts.cs create mode 100644 Journal.DevTool/Core/Debug/DebugProfileRunner.cs create mode 100644 Journal.DevTool/Core/Debug/DiagnosticsBundleService.cs create mode 100644 Journal.DevTool/Core/LegacyScriptRequirementResolver.cs create mode 100644 Journal.DevTool/Core/PrereqInstallerService.cs create mode 100644 Journal.DevTool/Core/PythonResolver.cs create mode 100644 Journal.DevTool/Core/RequirementResolver.cs create mode 100644 Journal.DevTool/Core/RunEventJsonlRecorder.cs create mode 100644 Journal.DevTool/Core/RunEventLogReader.cs create mode 100644 Journal.DevTool/Core/RunEvents.cs create mode 100644 Journal.DevTool/Core/ScriptLocator.cs create mode 100644 Journal.DevTool/Core/ToolProbeService.cs create mode 100644 Journal.DevTool/Core/WorkflowExecutor.cs create mode 100644 Journal.DevTool/Core/WorkflowPlanner.cs create mode 100644 Journal.DevTool/DevTool.csproj create mode 100644 Journal.DevTool/ROADMAP.md create mode 100644 Journal.DevTool/Tui/EventsScreen.cs create mode 100644 Journal.DevTool/package-lock.json create mode 100644 Journal.DevTool/package.json create mode 100644 Journal.DevTool/scripts/README.md create mode 100644 Journal.DevTool/scripts/WORKFLOWS.md create mode 100644 Journal.DevTool/scripts/_pwsh-python-shim.ps1 create mode 100644 Journal.DevTool/scripts/build.py create mode 100644 Journal.DevTool/scripts/dev-shell.cmd create mode 100644 Journal.DevTool/scripts/dev-shell.ps1 create mode 100644 Journal.DevTool/scripts/dev-shell.sh create mode 100644 Journal.DevTool/scripts/dev_shell.py create mode 100644 Journal.DevTool/scripts/diag.py create mode 100644 Journal.DevTool/scripts/dotnet-min.py rename scripts/dotnet-min.ps1 => Journal.DevTool/scripts/legacy/dotnet-min.legacy.ps1 (100%) rename scripts/migration-gate.ps1 => Journal.DevTool/scripts/legacy/migration-gate.legacy.ps1 (100%) rename scripts/npm-clean.ps1 => Journal.DevTool/scripts/legacy/npm-clean.legacy.ps1 (100%) rename scripts/nuget-export-cache.ps1 => Journal.DevTool/scripts/legacy/nuget-export-cache.legacy.ps1 (100%) rename scripts/nuget-import-cache.ps1 => Journal.DevTool/scripts/legacy/nuget-import-cache.legacy.ps1 (100%) rename scripts/pip-min.ps1 => Journal.DevTool/scripts/legacy/pip-min.legacy.ps1 (100%) rename scripts/publish-app.ps1 => Journal.DevTool/scripts/legacy/publish-app.legacy.ps1 (100%) rename scripts/publish-output.ps1 => Journal.DevTool/scripts/legacy/publish-output.legacy.ps1 (100%) rename scripts/publish-sidecar.ps1 => Journal.DevTool/scripts/legacy/publish-sidecar.legacy.ps1 (100%) rename scripts/publish-webgateway.ps1 => Journal.DevTool/scripts/legacy/publish-webgateway.legacy.ps1 (100%) rename scripts/run-webgateway.ps1 => Journal.DevTool/scripts/legacy/run-webgateway.legacy.ps1 (100%) rename scripts/sync-output.ps1 => Journal.DevTool/scripts/legacy/sync-output.legacy.ps1 (100%) create mode 100644 Journal.DevTool/scripts/migration-gate.py create mode 100644 Journal.DevTool/scripts/npm-clean.py create mode 100644 Journal.DevTool/scripts/nuget-export-cache.py create mode 100644 Journal.DevTool/scripts/nuget-import-cache.py create mode 100644 Journal.DevTool/scripts/pip-min.py create mode 100644 Journal.DevTool/scripts/pip_safe.py create mode 100644 Journal.DevTool/scripts/publish-app.py create mode 100644 Journal.DevTool/scripts/publish-output.py create mode 100644 Journal.DevTool/scripts/publish-sidecar.ps1 create mode 100644 Journal.DevTool/scripts/publish-sidecar.py create mode 100644 Journal.DevTool/scripts/publish-webgateway.py create mode 100644 Journal.DevTool/scripts/run-webgateway.py create mode 100644 Journal.DevTool/scripts/script-common.ps1 create mode 100644 Journal.DevTool/scripts/script_common.py create mode 100644 Journal.DevTool/scripts/sync-output.py create mode 100644 Journal.DevTool/tests/DevTool.Tests/ActionRunnerLegacyPwshTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/CommandResolverTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/ConfigBootstrapperTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/ConfigDoctorAutoFixServiceTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/ConfigDoctorServiceTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/DebugConfigTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/DebugServicesTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/DevShellScriptTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/DevTool.Tests.csproj create mode 100644 Journal.DevTool/tests/DevTool.Tests/LegacyModeTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/PrereqInstallerServiceTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/RequirementResolverTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/RunEventJsonlRecorderTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/RunEventLogReaderTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/ScriptCommonTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/ScriptSmokeTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/WorkflowExecutorTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/WorkflowModelBuilderTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/WorkspaceDefaultsTests.cs create mode 100644 Journal.DevTool/tests/DevTool.Tests/WorkspaceLoaderTests.cs delete mode 100644 justfile delete mode 100644 scripts/README.md delete mode 100644 scripts/WORKFLOWS.md create mode 100644 scripts/_pwsh-python-shim.ps1 create mode 100644 scripts/build.py create mode 100644 scripts/dev-shell.cmd create mode 100644 scripts/dev-shell.sh create mode 100644 scripts/dev_shell.py create mode 100644 scripts/diag.py create mode 100644 scripts/dotnet-min.py create mode 100644 scripts/migration-gate.py create mode 100644 scripts/npm-clean.py create mode 100644 scripts/nuget-export-cache.py create mode 100644 scripts/nuget-import-cache.py create mode 100644 scripts/pip-min.py create mode 100644 scripts/publish-app.py create mode 100644 scripts/publish-output.py create mode 100644 scripts/publish-sidecar.py create mode 100644 scripts/publish-webgateway.py create mode 100644 scripts/run-webgateway.py delete mode 100644 scripts/script-common.ps1 create mode 100644 scripts/script_common.py create mode 100644 scripts/sync-output.py delete mode 100644 sdt-workspace.json diff --git a/.gitignore b/.gitignore index 2d2c4a5..7ae3e83 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file +.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 diff --git a/Journal.App/package-lock.json b/Journal.App/package-lock.json index 47db5f0..0407a46 100644 --- a/Journal.App/package-lock.json +++ b/Journal.App/package-lock.json @@ -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", diff --git a/Journal.DevTool/.gitignore b/Journal.DevTool/.gitignore new file mode 100644 index 0000000..d148d21 --- /dev/null +++ b/Journal.DevTool/.gitignore @@ -0,0 +1,15 @@ +bin/ +obj/ +__pycache__/ +.cache/ +.vscode/ +.idea/ +.vs/ +.git/ +.pip +.tmp +.venv +.dotnet_home +.nuget +publish-test/ + diff --git a/Journal.DevTool/Config/ConfigBootstrapper.cs b/Journal.DevTool/Config/ConfigBootstrapper.cs new file mode 100644 index 0000000..2b65e39 --- /dev/null +++ b/Journal.DevTool/Config/ConfigBootstrapper.cs @@ -0,0 +1,626 @@ +using System.Text.Json; + +namespace Sdt.Config; + +public sealed record BootstrapScanResult( + string ProjectRoot, + string ProjectName, + IReadOnlyList ToolFamilies, + string? NodeWorkingDir, + string? PythonRequirementsFile, + bool HasDockerCompose, + IReadOnlyList RootHints); + +public static class ConfigBootstrapper +{ + private const int MaxScanDepth = 4; + + private static readonly HashSet ExcludedDirectories = new(StringComparer.OrdinalIgnoreCase) + { + ".git", + "node_modules", + ".venv", + "venv", + "bin", + "obj", + ".idea", + ".vscode", + "dist", + "build", + ".sdt", + }; + + private static readonly string[] RequirementCandidates = + [ + "requirements.txt", + "requirements-dev.txt", + "requirements_cpu_only.txt", + "requirements_gpu.txt", + ]; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public static BootstrapScanResult Scan(string startDir) + { + var root = FindProjectRoot(startDir); + var toolFamilies = new HashSet(StringComparer.OrdinalIgnoreCase); + var rootHints = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (Directory.Exists(Path.Combine(root, ".git"))) + { + toolFamilies.Add("git"); + rootHints.Add(".git"); + } + + var hasTopLevelSln = Directory.EnumerateFiles(root, "*.sln", SearchOption.TopDirectoryOnly).Any(); + var hasCsproj = hasTopLevelSln || EnumerateFilesBounded(root, "*.csproj", MaxScanDepth).Any(); + if (hasCsproj) + { + toolFamilies.Add("dotnet"); + rootHints.Add("*.sln"); + } + + var topLevelPackageJson = Path.Combine(root, "package.json"); + var packageJson = File.Exists(topLevelPackageJson) + ? topLevelPackageJson + : EnumerateFilesBounded(root, "package.json", MaxScanDepth) + .OrderBy(p => p.Length) + .FirstOrDefault(); + string? nodeWorkingDir = null; + if (packageJson is not null) + { + toolFamilies.Add("node"); + toolFamilies.Add("npm"); + nodeWorkingDir = Path.GetRelativePath(root, Path.GetDirectoryName(packageJson)!); + rootHints.Add("package.json"); + } + + string? requirements = RequirementCandidates + .Select(name => Path.Combine(root, name)) + .FirstOrDefault(File.Exists); + if (requirements is null) + { + var pyproject = Path.Combine(root, "pyproject.toml"); + if (File.Exists(pyproject)) + { + toolFamilies.Add("python"); + rootHints.Add("pyproject.toml"); + } + } + else + { + toolFamilies.Add("python"); + rootHints.Add(Path.GetFileName(requirements)!); + } + + var hasCargo = File.Exists(Path.Combine(root, "Cargo.toml")) || + EnumerateFilesBounded(root, "Cargo.toml", MaxScanDepth).Any(); + if (hasCargo) + { + toolFamilies.Add("cargo"); + rootHints.Add("Cargo.toml"); + } + + var hasTauri = File.Exists(Path.Combine(root, "tauri.conf.json")) || + EnumerateFilesBounded(root, "tauri.conf.json", MaxScanDepth).Any(); + if (hasTauri) + { + toolFamilies.Add("tauri"); + toolFamilies.Add("cargo"); + toolFamilies.Add("node"); + toolFamilies.Add("npm"); + rootHints.Add("tauri.conf.json"); + } + + var hasDockerCompose = File.Exists(Path.Combine(root, "docker-compose.yml")) || + File.Exists(Path.Combine(root, "docker-compose.yaml")); + if (hasDockerCompose || File.Exists(Path.Combine(root, "Dockerfile"))) + { + toolFamilies.Add("docker"); + rootHints.Add(hasDockerCompose ? "docker-compose.yml" : "Dockerfile"); + } + + var scriptsDir = Path.Combine(root, "scripts"); + if (Directory.Exists(scriptsDir) && + Directory.EnumerateFiles(scriptsDir, "*.py", SearchOption.TopDirectoryOnly).Any()) + { + toolFamilies.Add("python"); + rootHints.Add("scripts"); + } + + if (rootHints.Count == 0) + rootHints.Add("devtool.json"); + + return new BootstrapScanResult( + ProjectRoot: root, + ProjectName: new DirectoryInfo(root).Name, + ToolFamilies: toolFamilies.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(), + NodeWorkingDir: nodeWorkingDir, + PythonRequirementsFile: requirements is null ? null : Path.GetRelativePath(root, requirements), + HasDockerCompose: hasDockerCompose, + RootHints: rootHints.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList()); + } + + public static DevToolConfig BuildDefaultConfig(BootstrapScanResult scan) + { + var workflows = BuildWorkflows(scan).ToList(); + + var toolingTools = scan.ToolFamilies + .Select(t => new ToolInstallDefinition { Tool = t, PreferredInstallCommands = [] }) + .ToList(); + + var toolchains = new ToolchainConfig + { + Python = scan.ToolFamilies.Contains("python", StringComparer.OrdinalIgnoreCase) + ? new PythonToolchain + { + Executable = "python", + WindowsExecutable = "py", + VenvDir = ".venv", + Profiles = [] + } + : null, + Node = scan.ToolFamilies.Contains("node", StringComparer.OrdinalIgnoreCase) + ? new NodeToolchain + { + PackageManager = "npm", + WorkingDir = string.IsNullOrWhiteSpace(scan.NodeWorkingDir) ? "." : scan.NodeWorkingDir + } + : null + }; + + var debugProfiles = BuildDebugProfiles(scan).ToList(); + + return new DevToolConfig + { + Name = scan.ProjectName, + Version = "0.1.0", + Project = new ProjectMetadata + { + Type = "generic", + RootHints = scan.RootHints.ToList(), + Artifacts = ["bin", "obj", ".sdt/debug"] + }, + Toolchains = toolchains, + Tooling = new ToolingConfig { Tools = toolingTools }, + Workflows = workflows, + Debug = new DebugConfig + { + Profiles = debugProfiles, + Diagnostics = new DebugDiagnosticsOptions + { + Enabled = true, + OutputDir = ".sdt/debug", + IncludeAllEnv = false, + CaptureEnvKeys = + [ + "SDT_LOG_LEVEL", + "DOTNET_CLI_HOME", + "NUGET_PACKAGES", + "PIP_CACHE_DIR", + "NVM_HOME", + "NVM_SYMLINK", + ], + BundleOnFailure = true + } + }, + Env = + [ + new EnvVarDef + { + Key = "SDT_LOG_LEVEL", + Description = "CLI log verbosity", + DefaultValue = "information", + Options = ["trace", "debug", "information", "warning", "error", "critical"] + } + ] + }; + } + + public static string ToJson(DevToolConfig config) => JsonSerializer.Serialize(config, JsonOptions) + Environment.NewLine; + + public static string WriteDefaultConfig(string projectRoot, DevToolConfig config, bool overwrite = false) + { + var path = Path.Combine(projectRoot, "devtool.json"); + if (File.Exists(path) && !overwrite) + throw new InvalidOperationException($"devtool.json already exists at {path}"); + + File.WriteAllText(path, ToJson(config)); + return path; + } + + private static IEnumerable BuildWorkflows(BootstrapScanResult scan) + { + var has = new Func(tool => scan.ToolFamilies.Contains(tool, StringComparer.OrdinalIgnoreCase)); + var scripts = DetectScriptHelpers(scan.ProjectRoot); + + if (scripts.Contains("publish-sidecar.py") || + scripts.Contains("publish-app.py") || + scripts.Contains("publish-webgateway.py") || + scripts.Contains("publish-output.py") || + scripts.Contains("sync-output.py") || + scripts.Contains("run-webgateway.py")) + { + foreach (var workflow in BuildScriptDrivenWorkflows(scripts)) + yield return workflow; + } + + var buildSteps = new List(); + if (has("dotnet")) buildSteps.Add(StepAction("dotnet-build", "dotnet build", "dotnet-build")); + if (has("npm")) buildSteps.Add(StepAction("npm-build", "npm run build", "npm-build", scan.NodeWorkingDir)); + if (has("cargo")) buildSteps.Add(StepAction("cargo-build", "cargo build", "cargo-build")); + if (has("tauri")) buildSteps.Add(StepAction("tauri-build", "tauri build", "tauri-build", scan.NodeWorkingDir ?? ".")); + if (buildSteps.Count > 0) + { + yield return new WorkflowDefinition + { + Id = "build", + Label = "Build", + Description = "Build detected project stacks", + Group = "Build", + Steps = buildSteps + }; + } + + var depsSteps = new List(); + if (has("dotnet")) depsSteps.Add(StepAction("dotnet-restore", "dotnet restore", "dotnet-restore")); + if (has("npm")) depsSteps.Add(StepAction("npm-ci", "npm ci", "npm-ci", scan.NodeWorkingDir)); + if (has("python") && !string.IsNullOrWhiteSpace(scan.PythonRequirementsFile)) + { + depsSteps.Add(StepAction("python-pip-sync", "python pip sync", "python-pip-sync", ".", ["--requirements", scan.PythonRequirementsFile!])); + } + if (depsSteps.Count > 0) + { + yield return new WorkflowDefinition + { + Id = "deps-refresh", + Label = "Refresh Dependencies", + Description = "Restore/install dependency stacks", + Group = "Deps", + Steps = depsSteps + }; + } + + var testSteps = new List(); + if (has("dotnet")) testSteps.Add(StepAction("dotnet-test", "dotnet test", "dotnet-test")); + if (has("npm")) testSteps.Add(StepAction("npm-test", "npm test", "npm-test", scan.NodeWorkingDir)); + if (has("python")) testSteps.Add(StepAction("python-pytest", "python -m pytest", "python-pytest")); + if (has("cargo")) testSteps.Add(StepAction("cargo-test", "cargo test", "cargo-test")); + if (testSteps.Count > 0) + { + yield return new WorkflowDefinition + { + Id = "test", + Label = "Run Tests", + Description = "Run detected test stacks", + Group = "Test", + Steps = testSteps + }; + } + + if (has("git")) + { + yield return new WorkflowDefinition + { + Id = "repo-health", + Label = "Repo Health", + Description = "Check repo status and fetch remotes", + Group = "Repo", + Steps = + [ + StepAction("git-status", "git status", "git-status"), + StepAction("git-fetch", "git fetch", "git-fetch") + ] + }; + } + + if (has("docker")) + { + yield return new WorkflowDefinition + { + Id = "containers", + Label = "Containers", + Description = scan.HasDockerCompose ? "Manage docker compose stack" : "Build docker image", + Group = "Containers", + Steps = scan.HasDockerCompose + ? [StepAction("docker-compose-up", "docker compose up -d", "docker-compose-up")] + : [StepAction("docker-build", "docker build .", "docker-build")] + }; + } + } + + private static IEnumerable BuildDebugProfiles(BootstrapScanResult scan) + { + var has = new Func(tool => scan.ToolFamilies.Contains(tool, StringComparer.OrdinalIgnoreCase)); + if (has("dotnet")) + { + yield return new DebugProfileDefinition + { + Id = "dotnet-run", + Label = "Run .NET app", + Type = "dotnet", + Command = "dotnet", + Args = ["run"], + WorkingDir = ".", + Requires = [new ToolRequirement { Tool = "dotnet", InstallPolicy = InstallPolicy.Prompt }], + Attach = new DebugAttachConfig + { + Kind = "manual", + Note = "Attach your IDE debugger to the running dotnet process." + } + }; + } + + if (has("npm")) + { + yield return new DebugProfileDefinition + { + Id = "npm-dev", + Label = "Run npm dev server", + Type = "node", + Command = "npm", + Args = ["run", "dev"], + WorkingDir = string.IsNullOrWhiteSpace(scan.NodeWorkingDir) ? "." : scan.NodeWorkingDir!, + Requires = + [ + new ToolRequirement { Tool = "node", InstallPolicy = InstallPolicy.Prompt }, + new ToolRequirement { Tool = "npm", InstallPolicy = InstallPolicy.Prompt } + ] + }; + } + } + + private static WorkflowStep StepAction( + string id, + string label, + string action, + string? workingDir = null, + IReadOnlyList? actionArgs = null) + { + return new WorkflowStep + { + Id = id, + Label = label, + Action = action, + ActionArgs = actionArgs?.ToList() ?? [], + WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? "." : workingDir + }; + } + + private static HashSet DetectScriptHelpers(string projectRoot) + { + var scriptsDir = Path.Combine(projectRoot, "scripts"); + if (!Directory.Exists(scriptsDir)) + return new HashSet(StringComparer.OrdinalIgnoreCase); + + return Directory.EnumerateFiles(scriptsDir, "*.py", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Where(f => !string.IsNullOrWhiteSpace(f)) + .Cast() + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static IEnumerable BuildScriptDrivenWorkflows(HashSet scripts) + { + static WorkflowStep ScriptStep(string id, string label, params string[] scriptArgs) => new() + { + Id = id, + Label = label, + Command = "python", + Args = scriptArgs.ToList(), + WorkingDir = ".", + Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }] + }; + + if (scripts.Contains("publish-sidecar.py")) + { + yield return new WorkflowDefinition + { + Id = "sidecar", + Label = "Publish Sidecar", + Description = "Publish sidecar service", + Group = "Build", + Steps = [ScriptStep("sidecar:run", "python scripts/publish-sidecar.py", "scripts/publish-sidecar.py")] + }; + } + + if (scripts.Contains("publish-app.py")) + { + yield return new WorkflowDefinition + { + Id = "web", + Label = "Build Web UI", + Description = "Build frontend assets", + Group = "Build", + Steps = + [ + ScriptStep("web:run", "python scripts/publish-app.py --target web", "scripts/publish-app.py", "--target", "web") + ] + }; + + yield return new WorkflowDefinition + { + Id = "tauri", + Label = "Build Tauri Desktop App", + Description = "Build desktop binary", + Group = "Build", + DependsOn = scripts.Contains("publish-sidecar.py") ? ["sidecar"] : [], + Steps = + [ + ScriptStep("tauri:run", "python scripts/publish-app.py --target tauri --tauri-bundles none", + "scripts/publish-app.py", "--target", "tauri", "--tauri-bundles", "none") + ] + }; + } + + if (scripts.Contains("publish-webgateway.py")) + { + yield return new WorkflowDefinition + { + Id = "webgateway", + Label = "Publish WebGateway", + Description = "Publish ASP.NET gateway", + Group = "Build", + DependsOn = scripts.Contains("publish-app.py") ? ["web"] : [], + Steps = [ScriptStep("webgateway:run", "python scripts/publish-webgateway.py", "scripts/publish-webgateway.py")] + }; + } + + if (scripts.Contains("sync-output.py")) + { + yield return new WorkflowDefinition + { + Id = "sync-output", + Label = "Sync Output", + Description = "Sync newest artifacts to output", + Group = "Build", + Steps = [ScriptStep("sync-output:run", "python scripts/sync-output.py", "scripts/sync-output.py")] + }; + } + + if (scripts.Contains("publish-output.py")) + { + yield return new WorkflowDefinition + { + Id = "stage-output", + Label = "Stage Output Bundle", + Description = "Publish and stage distributable output", + Group = "Build", + Steps = [ScriptStep("stage-output:run", "python scripts/publish-output.py", "scripts/publish-output.py")] + }; + } + + if (scripts.Contains("run-webgateway.py")) + { + yield return new WorkflowDefinition + { + Id = "run-gateway-dev", + Label = "Run WebGateway Server (Dev)", + Description = "Run gateway in development mode", + Group = "Dev", + Steps = + [ + ScriptStep("run-gateway-dev:run", "python scripts/run-webgateway.py --mode Dev", + "scripts/run-webgateway.py", "--mode", "Dev") + ] + }; + } + } + + private static string FindProjectRoot(string startDir) + { + var start = Path.GetFullPath(startDir); + var gitRoot = TryGetGitRoot(start); + if (!string.IsNullOrWhiteSpace(gitRoot)) + return gitRoot!; + + var best = start; + var bestScore = ScoreRoot(start); + var cursor = new DirectoryInfo(start); + while (cursor.Parent is not null) + { + cursor = cursor.Parent; + var score = ScoreRoot(cursor.FullName); + if (score > bestScore) + { + best = cursor.FullName; + bestScore = score; + } + } + + return best; + } + + private static int ScoreRoot(string path) + { + var score = 0; + if (File.Exists(Path.Combine(path, "package.json"))) score += 2; + if (File.Exists(Path.Combine(path, "pyproject.toml"))) score += 2; + if (File.Exists(Path.Combine(path, "Cargo.toml"))) score += 2; + if (File.Exists(Path.Combine(path, "docker-compose.yml")) || + File.Exists(Path.Combine(path, "docker-compose.yaml")) || + File.Exists(Path.Combine(path, "Dockerfile"))) score += 1; + if (Directory.EnumerateFiles(path, "*.sln", SearchOption.TopDirectoryOnly).Any()) score += 3; + if (Directory.Exists(Path.Combine(path, ".git"))) score += 1; + return score; + } + + private static string? TryGetGitRoot(string start) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "git", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = start + }; + psi.ArgumentList.Add("rev-parse"); + psi.ArgumentList.Add("--show-toplevel"); + using var process = System.Diagnostics.Process.Start(psi); + if (process is null) + return null; + var stdout = process.StandardOutput.ReadToEnd(); + process.WaitForExit(2000); + if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(stdout)) + return stdout.Trim(); + return null; + } + catch + { + return null; + } + } + + private static IEnumerable EnumerateFilesBounded(string root, string pattern, int maxDepth) + { + var queue = new Queue<(string Dir, int Depth)>(); + queue.Enqueue((root, 0)); + + while (queue.Count > 0) + { + var (dir, depth) = queue.Dequeue(); + IEnumerable files = []; + try + { + files = Directory.EnumerateFiles(dir, pattern, SearchOption.TopDirectoryOnly); + } + catch + { + // Ignore unreadable directories. + } + + foreach (var file in files) + yield return file; + + if (depth >= maxDepth) + continue; + + IEnumerable subdirs = []; + try + { + subdirs = Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly); + } + catch + { + // Ignore unreadable directories. + } + + foreach (var subdir in subdirs) + { + var name = Path.GetFileName(subdir); + if (ExcludedDirectories.Contains(name)) + continue; + + queue.Enqueue((subdir, depth + 1)); + } + } + } +} diff --git a/Journal.DevTool/Config/ConfigLoader.cs b/Journal.DevTool/Config/ConfigLoader.cs index 471003d..507c9d6 100644 --- a/Journal.DevTool/Config/ConfigLoader.cs +++ b/Journal.DevTool/Config/ConfigLoader.cs @@ -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 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 (or CWD) until it finds devtool.json. /// Returns null if not found. /// - 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(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(); + 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(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(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; + } } diff --git a/Journal.DevTool/Config/DevToolConfig.cs b/Journal.DevTool/Config/DevToolConfig.cs index fbb91a5..4a4c108 100644 --- a/Journal.DevTool/Config/DevToolConfig.cs +++ b/Journal.DevTool/Config/DevToolConfig.cs @@ -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 Targets { get; init; } = []; + public List Workflows { get; init; } = []; public List 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 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 DependsOn { get; init; } = []; + public List Steps { get; init; } = []; +} + +public sealed class WorkflowStep +{ + public string Id { get; init; } = ""; + public string Label { get; init; } = ""; + public string? Command { get; init; } + public List Args { get; init; } = []; + public string WorkingDir { get; init; } = "."; + public string? Action { get; init; } + public List ActionArgs { get; init; } = []; + public List 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 Tools { get; init; } = []; +} + +public sealed class ToolInstallDefinition +{ + public string Tool { get; init; } = ""; + public List PreferredInstallCommands { get; init; } = []; + public List Executables { get; init; } = []; +} + +public sealed class ProjectMetadata +{ + public string Type { get; init; } = ""; + public List RootHints { get; init; } = []; + public List Artifacts { get; init; } = []; +} + +public sealed class DebugConfig +{ + public List 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 Args { get; init; } = []; + public string WorkingDir { get; init; } = "."; + public Dictionary Env { get; init; } = new(StringComparer.OrdinalIgnoreCase); + public List 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 CaptureEnvKeys { get; init; } = []; + public bool BundleOnFailure { get; init; } = true; +} + // ── Toolchain config ────────────────────────────────────────────────────────── public sealed class ToolchainConfig diff --git a/Journal.DevTool/Config/WorkflowModelBuilder.cs b/Journal.DevTool/Config/WorkflowModelBuilder.cs new file mode 100644 index 0000000..6501835 --- /dev/null +++ b/Journal.DevTool/Config/WorkflowModelBuilder.cs @@ -0,0 +1,100 @@ +using Sdt.Core; + +namespace Sdt.Config; + +public enum LegacyMode +{ + Strict, + Compat, +} + +public sealed record WorkflowNormalizationResult( + IReadOnlyList Workflows, + IReadOnlyList 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(); + + 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 ConvertLegacyTargets( + IReadOnlyList targets, + IRequirementResolver requirementResolver) + { + var workflows = new List(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; + } +} diff --git a/Journal.DevTool/Config/WorkspaceConfig.cs b/Journal.DevTool/Config/WorkspaceConfig.cs index fce9cb7..92c9b57 100644 --- a/Journal.DevTool/Config/WorkspaceConfig.cs +++ b/Journal.DevTool/Config/WorkspaceConfig.cs @@ -12,8 +12,11 @@ public sealed class WorkspaceProject public string Description { get; init; } = ""; /// - /// 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). /// public string Path { get; init; } = ""; + public List Tags { get; init; } = []; + public List ToolFamilies { get; init; } = []; + public bool Disabled { get; init; } = false; } diff --git a/Journal.DevTool/Config/WorkspaceLoader.cs b/Journal.DevTool/Config/WorkspaceLoader.cs index a01f324..90cc7a5 100644 --- a/Journal.DevTool/Config/WorkspaceLoader.cs +++ b/Journal.DevTool/Config/WorkspaceLoader.cs @@ -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()); } /// /// Resolves the absolute project root for a workspace project entry. /// 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(); + 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 DiscoverProjectRoots(string workspaceRoot, string currentRoot) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + var excluded = new HashSet(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(); + } } diff --git a/Journal.DevTool/Core/ActionRunner.cs b/Journal.DevTool/Core/ActionRunner.cs new file mode 100644 index 0000000..735415b --- /dev/null +++ b/Journal.DevTool/Core/ActionRunner.cs @@ -0,0 +1,207 @@ +using Sdt.Config; +using Sdt.Runner; + +namespace Sdt.Core; + +public sealed class ActionRunner : IActionRunner +{ + public async Task RunStepAsync( + WorkflowStep step, + string projectRoot, + Action 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 + { + 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 TryRunLegacyPwshScriptViaPythonAsync( + WorkflowStep step, + string projectRoot, + string workingDir, + Action 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 { 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 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 TranslatePowerShellArgsToPython(IEnumerable inputArgs) + { + var result = new List(); + 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(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()); + } +} diff --git a/Journal.DevTool/Core/CommandResolver.cs b/Journal.DevTool/Core/CommandResolver.cs new file mode 100644 index 0000000..262b6fa --- /dev/null +++ b/Journal.DevTool/Core/CommandResolver.cs @@ -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 BuildWindowsCandidates(string command, string normalized) + { + var candidates = new List(); + 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; + } +} diff --git a/Journal.DevTool/Core/ConfigDoctorAutoFixService.cs b/Journal.DevTool/Core/ConfigDoctorAutoFixService.cs new file mode 100644 index 0000000..d7bca94 --- /dev/null +++ b/Journal.DevTool/Core/ConfigDoctorAutoFixService.cs @@ -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 FindMissingWorkingDirectories(DevToolConfig config, string projectRoot) + { + var missing = new HashSet(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 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); + } +} diff --git a/Journal.DevTool/Core/ConfigDoctorService.cs b/Journal.DevTool/Core/ConfigDoctorService.cs new file mode 100644 index 0000000..2b6fe91 --- /dev/null +++ b/Journal.DevTool/Core/ConfigDoctorService.cs @@ -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 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 RunAsync( + DevToolConfig config, + string projectRoot, + CancellationToken cancellationToken = default) + { + var checks = new List(); + 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 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 workflows, + IReadOnlyDictionary workflowMap, + string projectRoot, + List 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(); + 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(); + var missingWorkingDirs = new List(); + 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 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 checks, + CancellationToken cancellationToken) + { + var requiredTools = new HashSet(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.")); + } + } + } +} diff --git a/Journal.DevTool/Core/Contracts.cs b/Journal.DevTool/Core/Contracts.cs new file mode 100644 index 0000000..eb19cd0 --- /dev/null +++ b/Journal.DevTool/Core/Contracts.cs @@ -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 Args); + +public sealed record InstallPlan( + string Tool, + bool Supported, + string Summary, + IReadOnlyList 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 Steps); + +public interface IToolProbe +{ + Task ProbeAsync( + string tool, + string projectRoot, + DevToolConfig? config = null, + CancellationToken cancellationToken = default); +} + +public interface IPrereqInstaller +{ + Task GetInstallPlanAsync( + string tool, + string projectRoot, + DevToolConfig? config = null, + CancellationToken cancellationToken = default); + + Task RunInstallAsync( + InstallCommand command, + string projectRoot, + Action onOutput, + CancellationToken cancellationToken = default); +} + +public interface IActionRunner +{ + Task RunStepAsync( + WorkflowStep step, + string projectRoot, + Action onOutput, + CancellationToken cancellationToken = default); +} + +public interface IWorkflowPlanner +{ + List ResolvePlan( + WorkflowDefinition workflow, + IReadOnlyDictionary allWorkflows); +} + +public interface IRequirementResolver +{ + List Resolve(WorkflowStep step); + List Resolve(BuildTarget target); +} diff --git a/Journal.DevTool/Core/Debug/DebugContracts.cs b/Journal.DevTool/Core/Debug/DebugContracts.cs new file mode 100644 index 0000000..c4c57fa --- /dev/null +++ b/Journal.DevTool/Core/Debug/DebugContracts.cs @@ -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 OutputLines, + IReadOnlyList Probes); + +public sealed record DiagnosticsBundleResult( + bool Success, + string BundleDirectory, + string? ZipPath, + string Message); + +public sealed record DiagnosticsBundleRequest( + string Category, + string ProjectRoot, + string SummaryMessage, + IReadOnlyList OutputLines, + IReadOnlyList WorkflowSteps, + IReadOnlyList Probes, + DebugDiagnosticsOptions DiagnosticsOptions, + DevToolConfig Config, + ExecutionStopReason? StopReason = null, + RunResult? DebugRun = null, + DebugProfileDefinition? DebugProfile = null); + +public interface IDebugProfileRunner +{ + Task RunAsync( + DebugProfileDefinition profile, + DevToolConfig config, + string projectRoot, + bool verbose, + Func> confirmInstallAsync, + Action onOutput, + Action? onEvent = null, + CancellationToken cancellationToken = default); +} + +public interface IDiagnosticsBundleService +{ + Task WriteBundleAsync( + DiagnosticsBundleRequest request, + CancellationToken cancellationToken = default); +} diff --git a/Journal.DevTool/Core/Debug/DebugProfileRunner.cs b/Journal.DevTool/Core/Debug/DebugProfileRunner.cs new file mode 100644 index 0000000..06e3b0c --- /dev/null +++ b/Journal.DevTool/Core/Debug/DebugProfileRunner.cs @@ -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 RunAsync( + DebugProfileDefinition profile, + DevToolConfig config, + string projectRoot, + bool verbose, + Func> confirmInstallAsync, + Action onOutput, + Action? onEvent = null, + CancellationToken cancellationToken = default) + { + var probes = new List(); + var output = new List(); + 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 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 }], + }; + } +} diff --git a/Journal.DevTool/Core/Debug/DiagnosticsBundleService.cs b/Journal.DevTool/Core/Debug/DiagnosticsBundleService.cs new file mode 100644 index 0000000..0056912 --- /dev/null +++ b/Journal.DevTool/Core/Debug/DiagnosticsBundleService.cs @@ -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 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 CaptureEnvironment(DebugDiagnosticsOptions options) + { + if (options.IncludeAllEnv) + { + return Environment.GetEnvironmentVariables() + .Cast() + .ToDictionary(e => e.Key?.ToString() ?? "", e => e.Value?.ToString() ?? "", StringComparer.OrdinalIgnoreCase); + } + + if (options.CaptureEnvKeys.Count == 0) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var captured = new Dictionary(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); + } +} diff --git a/Journal.DevTool/Core/LegacyScriptRequirementResolver.cs b/Journal.DevTool/Core/LegacyScriptRequirementResolver.cs new file mode 100644 index 0000000..39057de --- /dev/null +++ b/Journal.DevTool/Core/LegacyScriptRequirementResolver.cs @@ -0,0 +1,59 @@ +using Sdt.Config; + +namespace Sdt.Core; + +internal static class LegacyScriptRequirementResolver +{ + public static List InferForPowerShellArgs(IReadOnlyList 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 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 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; + } +} diff --git a/Journal.DevTool/Core/PrereqInstallerService.cs b/Journal.DevTool/Core/PrereqInstallerService.cs new file mode 100644 index 0000000..8f87f0f --- /dev/null +++ b/Journal.DevTool/Core/PrereqInstallerService.cs @@ -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 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(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 RunInstallAsync( + InstallCommand command, + string projectRoot, + Action 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(); + 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 SplitShellLike(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return []; + + var tokens = new List(); + 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 Commands { get; init; } = []; + } + + private sealed class InstallCommandJson + { + public string? Command { get; init; } + public List? Args { get; init; } + } +} diff --git a/Journal.DevTool/Core/PythonResolver.cs b/Journal.DevTool/Core/PythonResolver.cs new file mode 100644 index 0000000..928c79a --- /dev/null +++ b/Journal.DevTool/Core/PythonResolver.cs @@ -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; + } + } +} diff --git a/Journal.DevTool/Core/RequirementResolver.cs b/Journal.DevTool/Core/RequirementResolver.cs new file mode 100644 index 0000000..169d070 --- /dev/null +++ b/Journal.DevTool/Core/RequirementResolver.cs @@ -0,0 +1,67 @@ +using Sdt.Config; + +namespace Sdt.Core; + +public sealed class RequirementResolver : IRequirementResolver +{ + public List 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 Resolve(BuildTarget target) + { + if (string.IsNullOrWhiteSpace(target.Command)) + return []; + + return InferCommandRequirements(target.Command, target.Args); + } + + private static List 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 InferCommandRequirements(string command, IReadOnlyList 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, + }; +} diff --git a/Journal.DevTool/Core/RunEventJsonlRecorder.cs b/Journal.DevTool/Core/RunEventJsonlRecorder.cs new file mode 100644 index 0000000..526255e --- /dev/null +++ b/Journal.DevTool/Core/RunEventJsonlRecorder.cs @@ -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(); + } + } +} diff --git a/Journal.DevTool/Core/RunEventLogReader.cs b/Journal.DevTool/Core/RunEventLogReader.cs new file mode 100644 index 0000000..f2c223a --- /dev/null +++ b/Journal.DevTool/Core/RunEventLogReader.cs @@ -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 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 ReadEvents(string filePath) + { + var results = new List(); + 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(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; + } + } +} diff --git a/Journal.DevTool/Core/RunEvents.cs b/Journal.DevTool/Core/RunEvents.cs new file mode 100644 index 0000000..582b52b --- /dev/null +++ b/Journal.DevTool/Core/RunEvents.cs @@ -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; +} diff --git a/Journal.DevTool/Core/ScriptLocator.cs b/Journal.DevTool/Core/ScriptLocator.cs new file mode 100644 index 0000000..953da1e --- /dev/null +++ b/Journal.DevTool/Core/ScriptLocator.cs @@ -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; + } +} diff --git a/Journal.DevTool/Core/ToolProbeService.cs b/Journal.DevTool/Core/ToolProbeService.cs new file mode 100644 index 0000000..d2e6b98 --- /dev/null +++ b/Journal.DevTool/Core/ToolProbeService.cs @@ -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 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(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 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; } + } +} diff --git a/Journal.DevTool/Core/WorkflowExecutor.cs b/Journal.DevTool/Core/WorkflowExecutor.cs new file mode 100644 index 0000000..55c08e6 --- /dev/null +++ b/Journal.DevTool/Core/WorkflowExecutor.cs @@ -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 ExecuteAsync( + WorkflowDefinition rootWorkflow, + IReadOnlyDictionary allWorkflows, + DevToolConfig config, + string projectRoot, + Func> confirmInstallAsync, + Action onOutput, + Action? onEvent = null, + CancellationToken cancellationToken = default) + { + var results = new List(); + 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); + } +} diff --git a/Journal.DevTool/Core/WorkflowPlanner.cs b/Journal.DevTool/Core/WorkflowPlanner.cs new file mode 100644 index 0000000..d677df6 --- /dev/null +++ b/Journal.DevTool/Core/WorkflowPlanner.cs @@ -0,0 +1,35 @@ +using Sdt.Config; + +namespace Sdt.Core; + +public sealed class WorkflowPlanner : IWorkflowPlanner +{ + public List ResolvePlan( + WorkflowDefinition workflow, + IReadOnlyDictionary allWorkflows) + { + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var plan = new List(); + Visit(workflow, allWorkflows, visited, plan); + return plan; + } + + private static void Visit( + WorkflowDefinition workflow, + IReadOnlyDictionary allWorkflows, + HashSet visited, + List 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); + } +} diff --git a/Journal.DevTool/DevTool.csproj b/Journal.DevTool/DevTool.csproj new file mode 100644 index 0000000..9bd6978 --- /dev/null +++ b/Journal.DevTool/DevTool.csproj @@ -0,0 +1,37 @@ + + + + Exe + net10.0 + enable + enable + sdt + Sdt + false + + + + + + + + + + + + + + scripts\%(Filename)%(Extension) + scripts\%(Filename)%(Extension) + PreserveNewest + PreserveNewest + + + scripts\%(Filename)%(Extension) + scripts\%(Filename)%(Extension) + PreserveNewest + PreserveNewest + + + + diff --git a/Journal.DevTool/Program.cs b/Journal.DevTool/Program.cs index 756f812..a6b938a 100644 --- a/Journal.DevTool/Program.cs +++ b/Journal.DevTool/Program.cs @@ -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; } diff --git a/Journal.DevTool/README.md b/Journal.DevTool/README.md index 1f03773..f36acd4 100644 --- a/Journal.DevTool/README.md +++ b/Journal.DevTool/README.md @@ -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 ` 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 +``` diff --git a/Journal.DevTool/ROADMAP.md b/Journal.DevTool/ROADMAP.md new file mode 100644 index 0000000..bb3044d --- /dev/null +++ b/Journal.DevTool/ROADMAP.md @@ -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 diff --git a/Journal.DevTool/Runner/ProcessRunner.cs b/Journal.DevTool/Runner/ProcessRunner.cs index ced1d32..3dbb3a5 100644 --- a/Journal.DevTool/Runner/ProcessRunner.cs +++ b/Journal.DevTool/Runner/ProcessRunner.cs @@ -18,11 +18,12 @@ public static class ProcessRunner IEnumerable args, string workingDir, Action onOutput, + IReadOnlyDictionary? 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); diff --git a/Journal.DevTool/Tui/App.cs b/Journal.DevTool/Tui/App.cs index 36abb65..97f5461 100644 --- a/Journal.DevTool/Tui/App.cs +++ b/Journal.DevTool/Tui/App.cs @@ -1,28 +1,65 @@ using Sdt.Config; -using Sdt.Runner; +using Sdt.Core; +using Sdt.Core.Debug; using Spectre.Console; namespace Sdt.Tui; -/// Thin wrapper used in Spectre.Console selection prompts. 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 _workflows; + private readonly List _warnings; + private readonly IDebugProfileRunner _debugRunner; + private readonly IDiagnosticsBundleService _diagnostics; + private readonly IRequirementResolver _requirementResolver = new RequirementResolver(); - private IReadOnlyDictionary 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 WorkflowMap => + _workflows.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase); + + public App( + DevToolConfig config, + string projectRoot, + IReadOnlyList? 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 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() @@ -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(); + 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 { + 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 targetMap) + private async Task RunWorkflowAsync( + WorkflowDefinition workflow, + IReadOnlyDictionary 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(); + 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 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 workflowMap, + WorkflowExecutionResult result, + IReadOnlyList 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> SnapshotWorkflowToolsAsync( + WorkflowDefinition workflow, + IReadOnlyDictionary workflowMap) + { + var plan = new WorkflowPlanner().ResolvePlan(workflow, workflowMap); + var tools = new HashSet(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(); + foreach (var tool in tools) + probes.Add(await probeService.ProbeAsync(tool, _projectRoot, _config)); + return probes; + } + + private Task 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() { diff --git a/Journal.DevTool/Tui/EventsScreen.cs b/Journal.DevTool/Tui/EventsScreen.cs new file mode 100644 index 0000000..7af2a9a --- /dev/null +++ b/Journal.DevTool/Tui/EventsScreen.cs @@ -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() + .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"); + } +} diff --git a/Journal.DevTool/Tui/ToolchainScreen.cs b/Journal.DevTool/Tui/ToolchainScreen.cs index 96a8ec4..28bfa0f 100644 --- a/Journal.DevTool/Tui/ToolchainScreen.cs +++ b/Journal.DevTool/Tui/ToolchainScreen.cs @@ -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 { "-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 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)); + } } diff --git a/Journal.DevTool/Tui/WorkspaceScreen.cs b/Journal.DevTool/Tui/WorkspaceScreen.cs index 38d6a37..55302cf 100644 --- a/Journal.DevTool/Tui/WorkspaceScreen.cs +++ b/Journal.DevTool/Tui/WorkspaceScreen.cs @@ -15,86 +15,159 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR /// 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(); + 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() + .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($"[{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(); - 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() - .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); diff --git a/Journal.DevTool/package-lock.json b/Journal.DevTool/package-lock.json new file mode 100644 index 0000000..0e746ed --- /dev/null +++ b/Journal.DevTool/package-lock.json @@ -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" + } + } + } +} diff --git a/Journal.DevTool/package.json b/Journal.DevTool/package.json new file mode 100644 index 0000000..59d0978 --- /dev/null +++ b/Journal.DevTool/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "tauri-plugin-mic-recorder-api": "^2.0.0" + } +} diff --git a/Journal.DevTool/scripts/README.md b/Journal.DevTool/scripts/README.md new file mode 100644 index 0000000..8a7d099 --- /dev/null +++ b/Journal.DevTool/scripts/README.md @@ -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 +``` diff --git a/Journal.DevTool/scripts/WORKFLOWS.md b/Journal.DevTool/scripts/WORKFLOWS.md new file mode 100644 index 0000000..67852d2 --- /dev/null +++ b/Journal.DevTool/scripts/WORKFLOWS.md @@ -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 +``` diff --git a/Journal.DevTool/scripts/_pwsh-python-shim.ps1 b/Journal.DevTool/scripts/_pwsh-python-shim.ps1 new file mode 100644 index 0000000..b07c4dc --- /dev/null +++ b/Journal.DevTool/scripts/_pwsh-python-shim.ps1 @@ -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 +} diff --git a/Journal.DevTool/scripts/build.py b/Journal.DevTool/scripts/build.py new file mode 100644 index 0000000..d71b5cc --- /dev/null +++ b/Journal.DevTool/scripts/build.py @@ -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()) diff --git a/Journal.DevTool/scripts/dev-shell.cmd b/Journal.DevTool/scripts/dev-shell.cmd new file mode 100644 index 0000000..b1614b7 --- /dev/null +++ b/Journal.DevTool/scripts/dev-shell.cmd @@ -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. diff --git a/Journal.DevTool/scripts/dev-shell.ps1 b/Journal.DevTool/scripts/dev-shell.ps1 new file mode 100644 index 0000000..7f4a3f7 --- /dev/null +++ b/Journal.DevTool/scripts/dev-shell.ps1 @@ -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." diff --git a/Journal.DevTool/scripts/dev-shell.sh b/Journal.DevTool/scripts/dev-shell.sh new file mode 100644 index 0000000..83468f7 --- /dev/null +++ b/Journal.DevTool/scripts/dev-shell.sh @@ -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." diff --git a/Journal.DevTool/scripts/dev_shell.py b/Journal.DevTool/scripts/dev_shell.py new file mode 100644 index 0000000..1a5d8ea --- /dev/null +++ b/Journal.DevTool/scripts/dev_shell.py @@ -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()) diff --git a/Journal.DevTool/scripts/diag.py b/Journal.DevTool/scripts/diag.py new file mode 100644 index 0000000..20bf41b --- /dev/null +++ b/Journal.DevTool/scripts/diag.py @@ -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()) diff --git a/Journal.DevTool/scripts/dotnet-min.py b/Journal.DevTool/scripts/dotnet-min.py new file mode 100644 index 0000000..c8aa0f4 --- /dev/null +++ b/Journal.DevTool/scripts/dotnet-min.py @@ -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 ", 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()) diff --git a/scripts/dotnet-min.ps1 b/Journal.DevTool/scripts/legacy/dotnet-min.legacy.ps1 similarity index 100% rename from scripts/dotnet-min.ps1 rename to Journal.DevTool/scripts/legacy/dotnet-min.legacy.ps1 diff --git a/scripts/migration-gate.ps1 b/Journal.DevTool/scripts/legacy/migration-gate.legacy.ps1 similarity index 100% rename from scripts/migration-gate.ps1 rename to Journal.DevTool/scripts/legacy/migration-gate.legacy.ps1 diff --git a/scripts/npm-clean.ps1 b/Journal.DevTool/scripts/legacy/npm-clean.legacy.ps1 similarity index 100% rename from scripts/npm-clean.ps1 rename to Journal.DevTool/scripts/legacy/npm-clean.legacy.ps1 diff --git a/scripts/nuget-export-cache.ps1 b/Journal.DevTool/scripts/legacy/nuget-export-cache.legacy.ps1 similarity index 100% rename from scripts/nuget-export-cache.ps1 rename to Journal.DevTool/scripts/legacy/nuget-export-cache.legacy.ps1 diff --git a/scripts/nuget-import-cache.ps1 b/Journal.DevTool/scripts/legacy/nuget-import-cache.legacy.ps1 similarity index 100% rename from scripts/nuget-import-cache.ps1 rename to Journal.DevTool/scripts/legacy/nuget-import-cache.legacy.ps1 diff --git a/scripts/pip-min.ps1 b/Journal.DevTool/scripts/legacy/pip-min.legacy.ps1 similarity index 100% rename from scripts/pip-min.ps1 rename to Journal.DevTool/scripts/legacy/pip-min.legacy.ps1 diff --git a/scripts/publish-app.ps1 b/Journal.DevTool/scripts/legacy/publish-app.legacy.ps1 similarity index 100% rename from scripts/publish-app.ps1 rename to Journal.DevTool/scripts/legacy/publish-app.legacy.ps1 diff --git a/scripts/publish-output.ps1 b/Journal.DevTool/scripts/legacy/publish-output.legacy.ps1 similarity index 100% rename from scripts/publish-output.ps1 rename to Journal.DevTool/scripts/legacy/publish-output.legacy.ps1 diff --git a/scripts/publish-sidecar.ps1 b/Journal.DevTool/scripts/legacy/publish-sidecar.legacy.ps1 similarity index 100% rename from scripts/publish-sidecar.ps1 rename to Journal.DevTool/scripts/legacy/publish-sidecar.legacy.ps1 diff --git a/scripts/publish-webgateway.ps1 b/Journal.DevTool/scripts/legacy/publish-webgateway.legacy.ps1 similarity index 100% rename from scripts/publish-webgateway.ps1 rename to Journal.DevTool/scripts/legacy/publish-webgateway.legacy.ps1 diff --git a/scripts/run-webgateway.ps1 b/Journal.DevTool/scripts/legacy/run-webgateway.legacy.ps1 similarity index 100% rename from scripts/run-webgateway.ps1 rename to Journal.DevTool/scripts/legacy/run-webgateway.legacy.ps1 diff --git a/scripts/sync-output.ps1 b/Journal.DevTool/scripts/legacy/sync-output.legacy.ps1 similarity index 100% rename from scripts/sync-output.ps1 rename to Journal.DevTool/scripts/legacy/sync-output.legacy.ps1 diff --git a/Journal.DevTool/scripts/migration-gate.py b/Journal.DevTool/scripts/migration-gate.py new file mode 100644 index 0000000..398cf09 --- /dev/null +++ b/Journal.DevTool/scripts/migration-gate.py @@ -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()) diff --git a/Journal.DevTool/scripts/npm-clean.py b/Journal.DevTool/scripts/npm-clean.py new file mode 100644 index 0000000..0ea899f --- /dev/null +++ b/Journal.DevTool/scripts/npm-clean.py @@ -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()) diff --git a/Journal.DevTool/scripts/nuget-export-cache.py b/Journal.DevTool/scripts/nuget-export-cache.py new file mode 100644 index 0000000..17720f7 --- /dev/null +++ b/Journal.DevTool/scripts/nuget-export-cache.py @@ -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()) diff --git a/Journal.DevTool/scripts/nuget-import-cache.py b/Journal.DevTool/scripts/nuget-import-cache.py new file mode 100644 index 0000000..ec0fee1 --- /dev/null +++ b/Journal.DevTool/scripts/nuget-import-cache.py @@ -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()) diff --git a/Journal.DevTool/scripts/pip-min.py b/Journal.DevTool/scripts/pip-min.py new file mode 100644 index 0000000..fd03343 --- /dev/null +++ b/Journal.DevTool/scripts/pip-min.py @@ -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 ", 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()) diff --git a/Journal.DevTool/scripts/pip_safe.py b/Journal.DevTool/scripts/pip_safe.py new file mode 100644 index 0000000..a02a307 --- /dev/null +++ b/Journal.DevTool/scripts/pip_safe.py @@ -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:])) + diff --git a/Journal.DevTool/scripts/publish-app.py b/Journal.DevTool/scripts/publish-app.py new file mode 100644 index 0000000..b85ada7 --- /dev/null +++ b/Journal.DevTool/scripts/publish-app.py @@ -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()) diff --git a/Journal.DevTool/scripts/publish-output.py b/Journal.DevTool/scripts/publish-output.py new file mode 100644 index 0000000..bb87c9c --- /dev/null +++ b/Journal.DevTool/scripts/publish-output.py @@ -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()) diff --git a/Journal.DevTool/scripts/publish-sidecar.ps1 b/Journal.DevTool/scripts/publish-sidecar.ps1 new file mode 100644 index 0000000..692ff55 --- /dev/null +++ b/Journal.DevTool/scripts/publish-sidecar.ps1 @@ -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 diff --git a/Journal.DevTool/scripts/publish-sidecar.py b/Journal.DevTool/scripts/publish-sidecar.py new file mode 100644 index 0000000..c25a44a --- /dev/null +++ b/Journal.DevTool/scripts/publish-sidecar.py @@ -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 .") + 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()) diff --git a/Journal.DevTool/scripts/publish-webgateway.py b/Journal.DevTool/scripts/publish-webgateway.py new file mode 100644 index 0000000..6a2c9c0 --- /dev/null +++ b/Journal.DevTool/scripts/publish-webgateway.py @@ -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 .") + 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()) diff --git a/Journal.DevTool/scripts/run-webgateway.py b/Journal.DevTool/scripts/run-webgateway.py new file mode 100644 index 0000000..3f35dbc --- /dev/null +++ b/Journal.DevTool/scripts/run-webgateway.py @@ -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 .") + 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()) diff --git a/Journal.DevTool/scripts/script-common.ps1 b/Journal.DevTool/scripts/script-common.ps1 new file mode 100644 index 0000000..36e681e --- /dev/null +++ b/Journal.DevTool/scripts/script-common.ps1 @@ -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 diff --git a/Journal.DevTool/scripts/script_common.py b/Journal.DevTool/scripts/script_common.py new file mode 100644 index 0000000..76ac87d --- /dev/null +++ b/Journal.DevTool/scripts/script_common.py @@ -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] diff --git a/Journal.DevTool/scripts/sync-output.py b/Journal.DevTool/scripts/sync-output.py new file mode 100644 index 0000000..8a28a2b --- /dev/null +++ b/Journal.DevTool/scripts/sync-output.py @@ -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()) diff --git a/Journal.DevTool/tests/DevTool.Tests/ActionRunnerLegacyPwshTests.cs b/Journal.DevTool/tests/DevTool.Tests/ActionRunnerLegacyPwshTests.cs new file mode 100644 index 0000000..af62e45 --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/ActionRunnerLegacyPwshTests.cs @@ -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); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/CommandResolverTests.cs b/Journal.DevTool/tests/DevTool.Tests/CommandResolverTests.cs new file mode 100644 index 0000000..ac995eb --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/CommandResolverTests.cs @@ -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 { } + } + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/ConfigBootstrapperTests.cs b/Journal.DevTool/tests/DevTool.Tests/ConfigBootstrapperTests.cs new file mode 100644 index 0000000..e0124f5 --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/ConfigBootstrapperTests.cs @@ -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; + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/ConfigDoctorAutoFixServiceTests.cs b/Journal.DevTool/tests/DevTool.Tests/ConfigDoctorAutoFixServiceTests.cs new file mode 100644 index 0000000..9e51eec --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/ConfigDoctorAutoFixServiceTests.cs @@ -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); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/ConfigDoctorServiceTests.cs b/Journal.DevTool/tests/DevTool.Tests/ConfigDoctorServiceTests.cs new file mode 100644 index 0000000..0b05562 --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/ConfigDoctorServiceTests.cs @@ -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 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 ProbeAsync( + string tool, + string projectRoot, + DevToolConfig? config = null, + CancellationToken cancellationToken = default) + => Task.FromResult(new ProbeResult(tool, false, Details: "Fallback: unresolved command")); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/DebugConfigTests.cs b/Journal.DevTool/tests/DevTool.Tests/DebugConfigTests.cs new file mode 100644 index 0000000..e77a53a --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/DebugConfigTests.cs @@ -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(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); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/DebugServicesTests.cs b/Journal.DevTool/tests/DevTool.Tests/DebugServicesTests.cs new file mode 100644 index 0000000..31b5f7f --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/DebugServicesTests.cs @@ -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(); + 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 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 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 RunInstallAsync( + InstallCommand command, + string projectRoot, + Action onOutput, + CancellationToken cancellationToken = default) + => Task.FromResult(new RunResult(success ? 0 : 1, TimeSpan.FromMilliseconds(5))); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/DevShellScriptTests.cs b/Journal.DevTool/tests/DevTool.Tests/DevShellScriptTests.cs new file mode 100644 index 0000000..9e5e582 --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/DevShellScriptTests.cs @@ -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 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."); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/DevTool.Tests.csproj b/Journal.DevTool/tests/DevTool.Tests/DevTool.Tests.csproj new file mode 100644 index 0000000..957d8c0 --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/DevTool.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + diff --git a/Journal.DevTool/tests/DevTool.Tests/LegacyModeTests.cs b/Journal.DevTool/tests/DevTool.Tests/LegacyModeTests.cs new file mode 100644 index 0000000..f4f5a1b --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/LegacyModeTests.cs @@ -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(() => 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; + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/PrereqInstallerServiceTests.cs b/Journal.DevTool/tests/DevTool.Tests/PrereqInstallerServiceTests.cs new file mode 100644 index 0000000..3bc0186 --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/PrereqInstallerServiceTests.cs @@ -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))); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/RequirementResolverTests.cs b/Journal.DevTool/tests/DevTool.Tests/RequirementResolverTests.cs new file mode 100644 index 0000000..9404dbe --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/RequirementResolverTests.cs @@ -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); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/RunEventJsonlRecorderTests.cs b/Journal.DevTool/tests/DevTool.Tests/RunEventJsonlRecorderTests.cs new file mode 100644 index 0000000..a085a3f --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/RunEventJsonlRecorderTests.cs @@ -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()); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/RunEventLogReaderTests.cs b/Journal.DevTool/tests/DevTool.Tests/RunEventLogReaderTests.cs new file mode 100644 index 0000000..f4b68aa --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/RunEventLogReaderTests.cs @@ -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); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/ScriptCommonTests.cs b/Journal.DevTool/tests/DevTool.Tests/ScriptCommonTests.cs new file mode 100644 index 0000000..727ab34 --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/ScriptCommonTests.cs @@ -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 + { + ["NVM_HOME"] = root, + ["NVM_SYMLINK"] = shimDir, + ["PATH"] = "%NVM_HOME%;%NVM_SYMLINK%", + }); + + Assert.EndsWith("npm.cmd", output.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private static async Task RunPythonAsync( + string workingDir, + string script, + IReadOnlyDictionary? 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."); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/ScriptSmokeTests.cs b/Journal.DevTool/tests/DevTool.Tests/ScriptSmokeTests.cs new file mode 100644 index 0000000..17e4ed7 --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/ScriptSmokeTests.cs @@ -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 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."); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/WorkflowExecutorTests.cs b/Journal.DevTool/tests/DevTool.Tests/WorkflowExecutorTests.cs new file mode 100644 index 0000000..ee2c35f --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/WorkflowExecutorTests.cs @@ -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(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(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(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(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(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(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf }; + var lines = new List(); + + 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(StringComparer.OrdinalIgnoreCase) { [wf.Id] = wf }; + var events = new List(); + + 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(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 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 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 RunInstallAsync( + InstallCommand command, + string projectRoot, + Action onOutput, + CancellationToken cancellationToken = default) + => Task.FromResult(new RunResult(success ? 0 : 1, TimeSpan.FromMilliseconds(5))); + } + + private sealed class FakeActionRunner(bool success) : IActionRunner + { + public Task RunStepAsync( + WorkflowStep step, + string projectRoot, + Action onOutput, + CancellationToken cancellationToken = default) + => Task.FromResult(new RunResult(success ? 0 : 2, TimeSpan.FromMilliseconds(10))); + } + + private sealed class ConditionalProbeService : IToolProbe + { + public Task 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 ProbeAsync( + string tool, + string projectRoot, + DevToolConfig? config = null, + CancellationToken cancellationToken = default) + => Task.FromResult(new ProbeResult(tool, false, Details: "Fallback: unresolved command")); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/WorkflowModelBuilderTests.cs b/Journal.DevTool/tests/DevTool.Tests/WorkflowModelBuilderTests.cs new file mode 100644 index 0000000..96c4f4b --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/WorkflowModelBuilderTests.cs @@ -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(() => 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"); + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/WorkspaceDefaultsTests.cs b/Journal.DevTool/tests/DevTool.Tests/WorkspaceDefaultsTests.cs new file mode 100644 index 0000000..99d4880 --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/WorkspaceDefaultsTests.cs @@ -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; + } +} diff --git a/Journal.DevTool/tests/DevTool.Tests/WorkspaceLoaderTests.cs b/Journal.DevTool/tests/DevTool.Tests/WorkspaceLoaderTests.cs new file mode 100644 index 0000000..54be0fe --- /dev/null +++ b/Journal.DevTool/tests/DevTool.Tests/WorkspaceLoaderTests.cs @@ -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); + } +} diff --git a/devtool.json b/devtool.json index 57504e0..0ef1cdd 100644 --- a/devtool.json +++ b/devtool.json @@ -1,369 +1,677 @@ { - "name": "Project Journal", - "version": "0.1.0", - "toolchains": { - "python": { - "executable": "python3.14", - "windowsExecutable": "py", - "launcherVersion": "-3.14", - "venvDir": ".venv", - "pipScript": "scripts/pip-min.ps1", - "profiles": [ - { - "id": "cpu", - "label": "CPU only (default)", - "requirementsFile": "requirements_cpu_only.txt", - "extraIndexUrl": "https://download.pytorch.org/whl/cpu" - }, - { - "id": "gpu", - "label": "GPU / CUDA", - "requirementsFile": "requirements_gpu.txt" - }, - { - "id": "nlp", - "label": "NLP / spaCy (optional)", - "requirementsFile": "requirements_nlp_optional.txt", - "postInstallCommands": [ - "spacy download en_core_web_sm" - ] - } - ] - }, - "node": { - "packageManager": "npm", - "workingDir": "Journal.App" + "name": "Project Journal", + "version": "0.1.0", + "targets": [], + "workflows": [ + { + "id": "sidecar", + "label": "Publish Sidecar", + "description": "Build Journal.Sidecar as self-contained exe \u2192 output/", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "sidecar:run", + "label": "Publish Sidecar", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-sidecar.ps1", + "-Configuration", + "Release", + "-Runtime", + "win-x64" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] } + ] }, - "targets": [ + { + "id": "web", + "label": "Build Web UI", + "description": "Build SvelteKit bundle \u2192 Journal.App/build/", + "group": "Build", + "dependsOn": [], + "steps": [ { - "id": "sidecar", - "label": "Publish Sidecar", - "description": "Build Journal.Sidecar as self-contained exe → output/", - "group": "Build", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/publish-sidecar.ps1", - "-Configuration", - "Release", - "-Runtime", - "win-x64" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "web", - "label": "Build Web UI", - "description": "Build SvelteKit bundle → Journal.App/build/", - "group": "Build", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/publish-app.ps1", - "-Target", - "web" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "sync-output", - "label": "Sync Build Assets to Output", - "description": "Sweep repo for newest builds (Web/Sidecar/Gateway/Tauri) and copy them to the output dir", - "group": "Build", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/sync-output.ps1" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "webgateway", - "label": "Publish WebGateway", - "description": "Publish ASP.NET host with embedded web UI → output/webgateway/", - "group": "Build", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/publish-webgateway.ps1", - "-Configuration", - "Release", - "-Runtime", - "win-x64" - ], - "workingDir": ".", - "dependsOn": [ - "web" - ] - }, - { - "id": "tauri", - "label": "Build Tauri Desktop App", - "description": "Build desktop exe (no installer) → Journal.App/src-tauri/target/release/", - "group": "Build", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/publish-app.ps1", - "-Target", - "tauri", - "-TauriBundles", - "none" - ], - "workingDir": ".", - "dependsOn": [ - "sidecar" - ] - }, - { - "id": "tauri-nsis", - "label": "Build Tauri + NSIS Installer", - "description": "Build desktop exe with NSIS installer package", - "group": "Build", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/publish-app.ps1", - "-Target", - "tauri", - "-TauriBundles", - "nsis" - ], - "workingDir": ".", - "dependsOn": [ - "sidecar" - ] - }, - { - "id": "build-dotnet", - "label": "Build .NET Projects", - "description": "dotnet build — all C# projects in solution", - "group": "Build", - "command": "dotnet", - "args": [ - "build" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "all", - "label": "Full Release Build ✦", - "description": "Sidecar → Web → WebGateway → Tauri, in dependency order", - "group": "Build", - "command": null, - "args": [], - "workingDir": ".", - "dependsOn": [ - "sidecar", - "web", - "webgateway", - "tauri" - ] - }, - { - "id": "run-gateway-dev", - "label": "Run WebGateway Server (Dev)", - "description": "Start HTTP gateway via 'dotnet run' at http://localhost:5180", - "group": "Dev", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/run-webgateway.ps1", - "-Mode", - "Dev" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "run-gateway-prod", - "label": "Run WebGateway Server (Output)", - "description": "Start compiled gateway from output/webgateway at http://localhost:5180", - "group": "Dev", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/run-webgateway.ps1", - "-Mode", - "Output" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "test", - "label": "Run Smoke Tests", - "description": "Run all ~80 integration tests in Journal.SmokeTests", - "group": "Test", - "command": "dotnet", - "args": [ - "run", - "--project", - "Journal.SmokeTests/Journal.SmokeTests.csproj" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "gate", - "label": "Run Migration Gate", - "description": "Full build + smoke tests + parity check", - "group": "Test", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/migration-gate.ps1" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "nuget-export", - "label": "Export NuGet Cache", - "description": "Prime and export .nuget cache to zip for offline use", - "group": "Cache", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/nuget-export-cache.ps1" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "nuget-import", - "label": "Import NuGet Cache", - "description": "Import cache zip and validate restore", - "group": "Cache", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/nuget-import-cache.ps1" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "npm-clean", - "label": "Clean Node Modules", - "description": "Remove Journal.App node_modules (kills node/tauri first)", - "group": "System", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/npm-clean.ps1" - ], - "workingDir": ".", - "dependsOn": [] - }, - { - "id": "stage-output", - "label": "Stage Output Bundle", - "description": "Publish sidecar + web + webgateway + tauri, then stage journalapp.exe into output/", - "group": "Build", - "command": "pwsh", - "args": [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "scripts/publish-output.ps1" - ], - "workingDir": ".", - "dependsOn": [] + "id": "web:run", + "label": "Build Web UI", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "web" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + } + ] } - ], - "env": [ + ] + }, + { + "id": "sync-output", + "label": "Sync Build Assets to Output", + "description": "Sweep repo for newest builds (Web/Sidecar/Gateway/Tauri) and copy them to the output dir", + "group": "Build", + "dependsOn": [], + "steps": [ { - "key": "JOURNAL_AI_PROVIDER", - "description": "AI provider bridge mode", - "default": "none", - "options": [ - "none", - "python-sidecar" - ] - }, - { - "key": "JOURNAL_LOG_LEVEL", - "description": "Log verbosity for C# backend", - "default": "warning", - "options": [ - "trace", - "debug", - "information", - "warning", - "error", - "critical" - ] - }, - { - "key": "JOURNAL_NLP_BACKEND", - "description": "Python NLP backend selection", - "default": "auto", - "options": [ - "auto", - "spacy", - "fallback" - ] - }, - { - "key": "JOURNAL_PROJECT_ROOT", - "description": "Override project root path (blank = auto-detect)", - "default": "", - "options": [] - }, - { - "key": "JOURNAL_VAULT_DIR", - "description": "Override vault directory path", - "default": "", - "options": [] - }, - { - "key": "JOURNAL_DATA_DIR", - "description": "Override decrypted data directory path", - "default": "", - "options": [] + "id": "sync-output:run", + "label": "Sync Build Assets to Output", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/sync-output.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + } + ] } - ] -} \ No newline at end of file + ] + }, + { + "id": "webgateway", + "label": "Publish WebGateway", + "description": "Publish ASP.NET host with embedded web UI \u2192 output/webgateway/", + "group": "Build", + "dependsOn": [ + "web" + ], + "steps": [ + { + "id": "webgateway:run", + "label": "Publish WebGateway", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-webgateway.ps1", + "-Configuration", + "Release", + "-Runtime", + "win-x64" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "tauri", + "label": "Build Tauri Desktop App", + "description": "Build desktop exe (no installer) \u2192 Journal.App/src-tauri/target/release/", + "group": "Build", + "dependsOn": [ + "sidecar" + ], + "steps": [ + { + "id": "tauri:run", + "label": "Build Tauri Desktop App", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "tauri", + "-TauriBundles", + "none" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + }, + { + "tool": "cargo", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "tauri-nsis", + "label": "Build Tauri \u002B NSIS Installer", + "description": "Build desktop exe with NSIS installer package", + "group": "Build", + "dependsOn": [ + "sidecar" + ], + "steps": [ + { + "id": "tauri-nsis:run", + "label": "Build Tauri \u002B NSIS Installer", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "tauri", + "-TauriBundles", + "nsis" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + }, + { + "tool": "cargo", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "build-dotnet", + "label": "Build .NET Projects", + "description": "dotnet build \u2014 all C# projects in solution", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "build-dotnet:run", + "label": "Build .NET Projects", + "command": "dotnet", + "args": [ + "build" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "all", + "label": "Full Release Build \u2726", + "description": "Sidecar \u2192 Web \u2192 WebGateway \u2192 Tauri, in dependency order", + "group": "Build", + "dependsOn": [ + "sidecar", + "web", + "webgateway", + "tauri" + ], + "steps": [] + }, + { + "id": "run-gateway-dev", + "label": "Run WebGateway Server (Dev)", + "description": "Start HTTP gateway via \u0027dotnet run\u0027 at http://localhost:5180", + "group": "Dev", + "dependsOn": [], + "steps": [ + { + "id": "run-gateway-dev:run", + "label": "Run WebGateway Server (Dev)", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/run-webgateway.ps1", + "-Mode", + "Dev" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "run-gateway-prod", + "label": "Run WebGateway Server (Output)", + "description": "Start compiled gateway from output/webgateway at http://localhost:5180", + "group": "Dev", + "dependsOn": [], + "steps": [ + { + "id": "run-gateway-prod:run", + "label": "Run WebGateway Server (Output)", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/run-webgateway.ps1", + "-Mode", + "Output" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "test", + "label": "Run Smoke Tests", + "description": "Run all ~80 integration tests in Journal.SmokeTests", + "group": "Test", + "dependsOn": [], + "steps": [ + { + "id": "test:run", + "label": "Run Smoke Tests", + "command": "dotnet", + "args": [ + "run", + "--project", + "Journal.SmokeTests/Journal.SmokeTests.csproj" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "gate", + "label": "Run Migration Gate", + "description": "Full build \u002B smoke tests \u002B parity check", + "group": "Test", + "dependsOn": [], + "steps": [ + { + "id": "gate:run", + "label": "Run Migration Gate", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/migration-gate.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "nuget-export", + "label": "Export NuGet Cache", + "description": "Prime and export .nuget cache to zip for offline use", + "group": "Cache", + "dependsOn": [], + "steps": [ + { + "id": "nuget-export:run", + "label": "Export NuGet Cache", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/nuget-export-cache.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "nuget-import", + "label": "Import NuGet Cache", + "description": "Import cache zip and validate restore", + "group": "Cache", + "dependsOn": [], + "steps": [ + { + "id": "nuget-import:run", + "label": "Import NuGet Cache", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/nuget-import-cache.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "npm-clean", + "label": "Clean Node Modules", + "description": "Remove Journal.App node_modules (kills node/tauri first)", + "group": "System", + "dependsOn": [], + "steps": [ + { + "id": "npm-clean:run", + "label": "Clean Node Modules", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/npm-clean.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + } + ] + } + ] + }, + { + "id": "stage-output", + "label": "Stage Output Bundle", + "description": "Publish sidecar \u002B web \u002B webgateway \u002B tauri, then stage journalapp.exe into output/", + "group": "Build", + "dependsOn": [], + "steps": [ + { + "id": "stage-output:run", + "label": "Stage Output Bundle", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-output.ps1" + ], + "workingDir": ".", + "action": null, + "actionArgs": [], + "requires": [ + { + "tool": "python", + "installPolicy": "Prompt" + }, + { + "tool": "dotnet", + "installPolicy": "Prompt" + }, + { + "tool": "node", + "installPolicy": "Prompt" + }, + { + "tool": "npm", + "installPolicy": "Prompt" + }, + { + "tool": "cargo", + "installPolicy": "Prompt" + } + ] + } + ] + } + ], + "env": [ + { + "key": "JOURNAL_AI_PROVIDER", + "description": "AI provider bridge mode", + "default": "none", + "options": [ + "none", + "python-sidecar" + ] + }, + { + "key": "JOURNAL_LOG_LEVEL", + "description": "Log verbosity for C# backend", + "default": "warning", + "options": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical" + ] + }, + { + "key": "JOURNAL_NLP_BACKEND", + "description": "Python NLP backend selection", + "default": "auto", + "options": [ + "auto", + "spacy", + "fallback" + ] + }, + { + "key": "JOURNAL_PROJECT_ROOT", + "description": "Override project root path (blank = auto-detect)", + "default": "", + "options": [] + }, + { + "key": "JOURNAL_VAULT_DIR", + "description": "Override vault directory path", + "default": "", + "options": [] + }, + { + "key": "JOURNAL_DATA_DIR", + "description": "Override decrypted data directory path", + "default": "", + "options": [] + } + ], + "toolchains": { + "python": { + "executable": "python3.14", + "windowsExecutable": "py", + "launcherVersion": "-3.14", + "venvDir": ".venv", + "profiles": [ + { + "id": "cpu", + "label": "CPU only (default)", + "requirementsFile": "requirements_cpu_only.txt", + "extraIndexUrl": "https://download.pytorch.org/whl/cpu", + "postInstallCommands": [] + }, + { + "id": "gpu", + "label": "GPU / CUDA", + "requirementsFile": "requirements_gpu.txt", + "extraIndexUrl": null, + "postInstallCommands": [] + }, + { + "id": "nlp", + "label": "NLP / spaCy (optional)", + "requirementsFile": "requirements_nlp_optional.txt", + "extraIndexUrl": null, + "postInstallCommands": [ + "spacy download en_core_web_sm" + ] + } + ], + "pipScript": "scripts/pip-min.ps1" + }, + "node": { + "packageManager": "npm", + "workingDir": "Journal.App" + } + }, + "tooling": null, + "project": null, + "debug": null +} diff --git a/justfile b/justfile deleted file mode 100644 index 151bb9f..0000000 --- a/justfile +++ /dev/null @@ -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 diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index 997637a..0000000 --- a/scripts/README.md +++ /dev/null @@ -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\