From 104c8eab91129623e89c4b2c0282ed526dbe9147 Mon Sep 17 00:00:00 2001 From: stan44 Date: Wed, 4 Mar 2026 16:40:57 -0600 Subject: [PATCH] several bug fixes, --- Program.cs | 14 +- ROADMAP.md | 5 +- devtool.json | 487 -------------- devtool.json.bak-20260301-182536 | 477 -------------- scripts/build.py | 238 +++---- scripts/publish-app.py | 96 +-- scripts/publish-output.py | 103 ++- scripts/publish-sidecar.py | 59 +- scripts/publish-webgateway.py | 85 ++- scripts/script-common.ps1 | 15 +- scripts/script_common.py | 601 ++++++++---------- scripts/sync-output.py | 37 +- scripts/verify-workflow-routes.py | 82 +-- .../Config/ConfigBootstrapper.cs | 189 ++++-- src/DevTool.Engine/Config/ConfigLoader.cs | 16 +- src/DevTool.Engine/Config/DevToolConfig.cs | 4 + src/DevTool.Engine/Config/WorkspaceConfig.cs | 3 +- .../Config/WorkspaceInventoryService.cs | 9 +- .../Core/PrereqInstallerService.cs | 1 + src/DevTool.Engine/Core/ProjectScaffolder.cs | 247 +++++++ .../Core/RequirementResolver.cs | 14 +- src/DevTool.Engine/Core/ToolProbeService.cs | 17 +- src/DevTool.Engine/Core/WorkflowExecutor.cs | 102 ++- src/DevTool.Host.Bridge/BridgeStdioServer.cs | 8 +- src/DevTool.Host.Tui/Tui/App.cs | 28 +- src/DevTool.Host.Tui/Tui/SetupWizardScreen.cs | 69 +- src/DevTool.Host.Tui/Tui/ToolchainScreen.cs | 2 +- src/DevTool.Host.Tui/Tui/WorkspaceScreen.cs | 26 +- .../DevTool.Tests/ConfigBootstrapperTests.cs | 18 + tests/DevTool.Tests/HeadlessExecutionTests.cs | 2 +- tests/DevTool.Tests/ScriptCommonTests.cs | 4 +- tests/DevTool.Tests/WorkspaceDefaultsTests.cs | 4 +- .../WorkspaceInventoryServiceTests.cs | 6 +- tests/DevTool.Tests/WorkspaceLoaderTests.cs | 4 +- 34 files changed, 1274 insertions(+), 1798 deletions(-) delete mode 100644 devtool.json delete mode 100644 devtool.json.bak-20260301-182536 create mode 100644 src/DevTool.Engine/Core/ProjectScaffolder.cs diff --git a/Program.cs b/Program.cs index afd1005..1124086 100644 --- a/Program.cs +++ b/Program.cs @@ -36,13 +36,13 @@ try if (projectResult is null) { - AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No devtool.json found[/] in current directory or any parent."); + AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No SDT project config found[/] (expected `sdtconfig-*.json` or `devtool.json`) in current directory or any parent."); var bootstrap = AnsiConsole.Confirm( - $"[{Theme.Amber}]Generate a default devtool.json for this project now?[/]", + $"[{Theme.Amber}]Generate a default SDT project config for this project now?[/]", defaultValue: true); if (!bootstrap) { - AnsiConsole.MarkupLine(Theme.Faint("Create a devtool.json in your project root to get started.")); + AnsiConsole.MarkupLine(Theme.Faint("Create an sdtconfig-.json (or devtool.json) in your project root to get started.")); return 1; } @@ -53,10 +53,10 @@ try 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)); + AnsiConsole.Write(new Panel(Markup.Escape(preview)).Header("Generated project config preview").BorderStyle(Theme.DimStyle)); var confirmWrite = AnsiConsole.Confirm( - $"[{Theme.Amber}]Write generated devtool.json to {scan.ProjectRoot}?[/]", + $"[{Theme.Amber}]Write generated project config to {scan.ProjectRoot}?[/]", defaultValue: true); if (!confirmWrite) return 1; @@ -110,7 +110,7 @@ try } if (loaded is null) { - AnsiConsole.MarkupLine(Theme.Fail($"No devtool.json found at: {result.NewProjectRoot}")); + AnsiConsole.MarkupLine(Theme.Fail($"No SDT project config found at: {result.NewProjectRoot}")); AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project...")); Console.ReadKey(intercept: true); continue; @@ -289,7 +289,7 @@ static async Task RunHeadlessAsync(IReadOnlyList cliArgs, string ki var loaded = ConfigLoader.FindAndLoad(startDir); if (loaded is null) { - Console.WriteLine("{\"success\":false,\"message\":\"No devtool.json found for headless command.\"}"); + Console.WriteLine("{\"success\":false,\"message\":\"No SDT project config found (expected sdtconfig-*.json or devtool.json).\"}"); return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); } diff --git a/ROADMAP.md b/ROADMAP.md index e0eb53e..7292c8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,7 +47,7 @@ ## In Progress (next focus) -- [ ] Execute full OS matrix verification on Windows/Linux/macOS runners +- [ ] Execute full OS matrix verification on Linux runners - [ ] Native GUI shell over headless core services (Tauri-first in v1.x; Avalonia re-evaluate later) - [x] GUI parity phase 1 bridge foundation shipped (`sdt bridge --stdio` + `DevTool.Host.Bridge`) - [x] GUI debug + failure UX slice shipped (debug run, failure card, run context/lifecycle panel) @@ -66,7 +66,6 @@ - [x] Bootstrap first Tauri command bridge: `sdt workspace scan --json` - [x] Structural refactor to domain-separated projects under `src/` (`DevTool.Engine`, `DevTool.Runtime`, `DevTool.Host.Tui`) - [x] Add second Tauri bridge command: `sdt run --json` with live stream panel -- [ ] Remove legacy PowerShell wrappers in v2 - [x] Add workspace project inventory model (all `.slnx/.sln/.csproj`) for GUI/TUI multi-project selector - [x] Expand GUI command palette coverage across workspace/run/setup/history/events/favorites actions - [x] Add full GUI env var definition editor parity (`env[]` model editing with validation) @@ -88,7 +87,7 @@ - [x] Add project-type matrix coverage tests (`dotnet`, `node/npm`, `tauri/cargo`) - [x] Add deterministic headless stop-reason/exit-code tests -- [ ] Execute full OS matrix verification on Windows/Linux/macOS runners +- [ ] Execute Full OS matrix verification on Linux runners because the linux runner in theory should be able to be able to build/publish/release to windows/linux/ macos (but fuck apple.) - [x] Publish reliability matrix runbook + results artifact in docs - Blocked in current local workspace: no `.git` repo context and no `gh` CLI/auth; execute from Git-connected checkout. - v1.4 policy: ship Windows/Linux, macOS delegated with checklist in `docs/matrix-status.md`. diff --git a/devtool.json b/devtool.json deleted file mode 100644 index 3920575..0000000 --- a/devtool.json +++ /dev/null @@ -1,487 +0,0 @@ -{ - "name": "DevTool-master", - "version": "0.1.0", - "targets": [], - "workflows": [ - { - "id": "sidecar", - "label": "Publish Sidecar", - "description": "Publish sidecar service", - "group": "Build", - "dependsOn": [], - "steps": [ - { - "id": "sidecar:run", - "label": "python scripts/publish-sidecar.py", - "command": "python", - "args": [ - "scripts/publish-sidecar.py" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "web", - "label": "Build Web UI", - "description": "Build frontend assets", - "group": "Build", - "dependsOn": [], - "steps": [ - { - "id": "web:run", - "label": "python scripts/publish-app.py --target web", - "command": "python", - "args": [ - "scripts/publish-app.py", - "--target", - "web" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "tauri", - "label": "Build Tauri Desktop App", - "description": "Build desktop binary", - "group": "Build", - "dependsOn": [ - "sidecar" - ], - "steps": [ - { - "id": "tauri:run", - "label": "python scripts/publish-app.py --target tauri --tauri-bundles none", - "command": "python", - "args": [ - "scripts/publish-app.py", - "--target", - "tauri", - "--tauri-bundles", - "none" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "webgateway", - "label": "Publish WebGateway", - "description": "Publish ASP.NET gateway", - "group": "Build", - "dependsOn": [ - "web" - ], - "steps": [ - { - "id": "webgateway:run", - "label": "python scripts/publish-webgateway.py", - "command": "python", - "args": [ - "scripts/publish-webgateway.py" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "sync-output", - "label": "Sync Output", - "description": "Sync newest artifacts to output", - "group": "Build", - "dependsOn": [], - "steps": [ - { - "id": "sync-output:run", - "label": "python scripts/sync-output.py", - "command": "python", - "args": [ - "scripts/sync-output.py" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "stage-output", - "label": "Stage Output Bundle", - "description": "Publish and stage distributable output", - "group": "Build", - "dependsOn": [], - "steps": [ - { - "id": "stage-output:run", - "label": "python scripts/publish-output.py", - "command": "python", - "args": [ - "scripts/publish-output.py" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "run-gateway-dev", - "label": "Run WebGateway Server (Dev)", - "description": "Run gateway in development mode", - "group": "Dev", - "dependsOn": [], - "steps": [ - { - "id": "run-gateway-dev:run", - "label": "python scripts/run-webgateway.py --mode Dev", - "command": "python", - "args": [ - "scripts/run-webgateway.py", - "--mode", - "Dev" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "build", - "label": "Build", - "description": "Build detected project stacks", - "group": "Build", - "dependsOn": [], - "steps": [ - { - "id": "dotnet-build", - "label": "dotnet build", - "command": null, - "args": [], - "workingDir": ".", - "action": "dotnet-build", - "actionArgs": [], - "requires": [] - }, - { - "id": "npm-build", - "label": "npm run build", - "command": null, - "args": [], - "workingDir": ".", - "action": "npm-build", - "actionArgs": [], - "requires": [] - } - ] - }, - { - "id": "deps-refresh", - "label": "Refresh Dependencies", - "description": "Restore/install dependency stacks", - "group": "Deps", - "dependsOn": [], - "steps": [ - { - "id": "dotnet-restore", - "label": "dotnet restore", - "command": null, - "args": [], - "workingDir": ".", - "action": "dotnet-restore", - "actionArgs": [], - "requires": [] - }, - { - "id": "npm-ci", - "label": "npm ci", - "command": null, - "args": [], - "workingDir": ".", - "action": "npm-ci", - "actionArgs": [], - "requires": [] - } - ] - }, - { - "id": "test", - "label": "Run Tests", - "description": "Run detected test stacks", - "group": "Test", - "dependsOn": [], - "steps": [ - { - "id": "dotnet-test", - "label": "dotnet test", - "command": null, - "args": [], - "workingDir": ".", - "action": "dotnet-test", - "actionArgs": [], - "requires": [] - }, - { - "id": "npm-test", - "label": "npm test", - "command": null, - "args": [], - "workingDir": ".", - "action": "npm-test", - "actionArgs": [], - "requires": [] - }, - { - "id": "python-pytest", - "label": "python -m pytest", - "command": null, - "args": [], - "workingDir": ".", - "action": "python-pytest", - "actionArgs": [], - "requires": [] - } - ] - } - ], - "env": [ - { - "key": "SDT_LOG_LEVEL", - "description": "CLI log verbosity", - "default": "information", - "options": [ - "trace", - "debug", - "information", - "warning", - "error", - "critical" - ] - }, - { - "key": "SDT_ENV_PROFILE", - "description": "Active SDT runtime environment profile", - "default": "dev", - "options": [ - "dev", - "ci", - "release" - ] - } - ], - "envProfiles": { - "active": "dev", - "profiles": [ - { - "id": "dev", - "description": "Local development defaults", - "inherits": [], - "values": { - "SDT_ENV_PROFILE": "dev", - "SDT_LOG_LEVEL": "information" - } - }, - { - "id": "ci", - "description": "Continuous integration defaults", - "inherits": [ - "dev" - ], - "values": { - "SDT_ENV_PROFILE": "ci", - "CI": "true", - "SDT_LOG_LEVEL": "warning" - } - }, - { - "id": "release", - "description": "Release build defaults", - "inherits": [ - "dev" - ], - "values": { - "SDT_ENV_PROFILE": "release", - "SDT_LOG_LEVEL": "warning" - } - } - ] - }, - "toolchains": { - "python": { - "executable": "python", - "windowsExecutable": "py", - "launcherVersion": null, - "venvDir": ".venv", - "profiles": [], - "pipScript": null - }, - "node": { - "packageManager": "npm", - "workingDir": "." - } - }, - "tooling": { - "tools": [ - { - "tool": "dotnet", - "preferredInstallCommands": [], - "executables": [] - }, - { - "tool": "node", - "preferredInstallCommands": [], - "executables": [] - }, - { - "tool": "npm", - "preferredInstallCommands": [], - "executables": [] - }, - { - "tool": "python", - "preferredInstallCommands": [], - "executables": [] - } - ] - }, - "project": { - "type": "polyglot", - "rootHints": [ - "*.sln", - "package.json", - "scripts" - ], - "artifacts": [ - "bin", - "obj", - ".sdt/debug" - ] - }, - "debug": { - "profiles": [ - { - "id": "dotnet-run", - "label": "Run .NET app", - "type": "dotnet", - "command": "dotnet", - "args": [ - "run" - ], - "workingDir": ".", - "env": {}, - "requires": [ - { - "tool": "dotnet", - "installPolicy": "Prompt" - } - ], - "attach": { - "kind": "manual", - "port": null, - "processName": null, - "note": "Attach your IDE debugger to the running dotnet process." - } - }, - { - "id": "npm-dev", - "label": "Run npm dev server", - "type": "node", - "command": "npm", - "args": [ - "run", - "dev" - ], - "workingDir": ".", - "env": {}, - "requires": [ - { - "tool": "node", - "installPolicy": "Prompt" - }, - { - "tool": "npm", - "installPolicy": "Prompt" - } - ], - "attach": null - } - ], - "diagnostics": { - "enabled": true, - "outputDir": ".sdt/debug", - "includeAllEnv": false, - "captureEnvKeys": [ - "SDT_LOG_LEVEL", - "DOTNET_CLI_HOME", - "NUGET_PACKAGES", - "PIP_CACHE_DIR", - "NVM_HOME", - "NVM_SYMLINK" - ], - "redactSensitive": true, - "sensitiveKeyPatterns": [ - "TOKEN", - "SECRET", - "PASSWORD", - "PWD", - "CREDENTIAL", - "API_KEY", - "ACCESS_KEY", - "PRIVATE_KEY" - ], - "redactionAllowKeys": [], - "bundleOnFailure": true - } - } -} diff --git a/devtool.json.bak-20260301-182536 b/devtool.json.bak-20260301-182536 deleted file mode 100644 index 55201bc..0000000 --- a/devtool.json.bak-20260301-182536 +++ /dev/null @@ -1,477 +0,0 @@ -{ - "name": "DevTool-master", - "version": "0.1.0", - "targets": [], - "workflows": [ - { - "id": "sidecar", - "label": "Publish Sidecar", - "description": "Publish sidecar service", - "group": "Build", - "dependsOn": [], - "steps": [ - { - "id": "sidecar:run", - "label": "python scripts/publish-sidecar.py", - "command": "python", - "args": [ - "scripts/publish-sidecar.py" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "web", - "label": "Build Web UI", - "description": "Build frontend assets", - "group": "Build", - "dependsOn": [], - "steps": [ - { - "id": "web:run", - "label": "python scripts/publish-app.py --target web", - "command": "python", - "args": [ - "scripts/publish-app.py", - "--target", - "web" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "tauri", - "label": "Build Tauri Desktop App", - "description": "Build desktop binary", - "group": "Build", - "dependsOn": [ - "sidecar" - ], - "steps": [ - { - "id": "tauri:run", - "label": "python scripts/publish-app.py --target tauri --tauri-bundles none", - "command": "python", - "args": [ - "scripts/publish-app.py", - "--target", - "tauri", - "--tauri-bundles", - "none" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "webgateway", - "label": "Publish WebGateway", - "description": "Publish ASP.NET gateway", - "group": "Build", - "dependsOn": [ - "web" - ], - "steps": [ - { - "id": "webgateway:run", - "label": "python scripts/publish-webgateway.py", - "command": "python", - "args": [ - "scripts/publish-webgateway.py" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "sync-output", - "label": "Sync Output", - "description": "Sync newest artifacts to output", - "group": "Build", - "dependsOn": [], - "steps": [ - { - "id": "sync-output:run", - "label": "python scripts/sync-output.py", - "command": "python", - "args": [ - "scripts/sync-output.py" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "stage-output", - "label": "Stage Output Bundle", - "description": "Publish and stage distributable output", - "group": "Build", - "dependsOn": [], - "steps": [ - { - "id": "stage-output:run", - "label": "python scripts/publish-output.py", - "command": "python", - "args": [ - "scripts/publish-output.py" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "run-gateway-dev", - "label": "Run WebGateway Server (Dev)", - "description": "Run gateway in development mode", - "group": "Dev", - "dependsOn": [], - "steps": [ - { - "id": "run-gateway-dev:run", - "label": "python scripts/run-webgateway.py --mode Dev", - "command": "python", - "args": [ - "scripts/run-webgateway.py", - "--mode", - "Dev" - ], - "workingDir": ".", - "action": null, - "actionArgs": [], - "requires": [ - { - "tool": "python", - "installPolicy": "Prompt" - } - ] - } - ] - }, - { - "id": "build", - "label": "Build", - "description": "Build detected project stacks", - "group": "Build", - "dependsOn": [], - "steps": [ - { - "id": "dotnet-build", - "label": "dotnet build", - "command": null, - "args": [], - "workingDir": ".", - "action": "dotnet-build", - "actionArgs": [], - "requires": [] - }, - { - "id": "npm-build", - "label": "npm run build", - "command": null, - "args": [], - "workingDir": ".", - "action": "npm-build", - "actionArgs": [], - "requires": [] - } - ] - }, - { - "id": "deps-refresh", - "label": "Refresh Dependencies", - "description": "Restore/install dependency stacks", - "group": "Deps", - "dependsOn": [], - "steps": [ - { - "id": "dotnet-restore", - "label": "dotnet restore", - "command": null, - "args": [], - "workingDir": ".", - "action": "dotnet-restore", - "actionArgs": [], - "requires": [] - }, - { - "id": "npm-ci", - "label": "npm ci", - "command": null, - "args": [], - "workingDir": ".", - "action": "npm-ci", - "actionArgs": [], - "requires": [] - } - ] - }, - { - "id": "test", - "label": "Run Tests", - "description": "Run detected test stacks", - "group": "Test", - "dependsOn": [], - "steps": [ - { - "id": "dotnet-test", - "label": "dotnet test", - "command": null, - "args": [], - "workingDir": ".", - "action": "dotnet-test", - "actionArgs": [], - "requires": [] - }, - { - "id": "npm-test", - "label": "npm test", - "command": null, - "args": [], - "workingDir": ".", - "action": "npm-test", - "actionArgs": [], - "requires": [] - }, - { - "id": "python-pytest", - "label": "python -m pytest", - "command": null, - "args": [], - "workingDir": ".", - "action": "python-pytest", - "actionArgs": [], - "requires": [] - } - ] - } - ], - "env": [ - { - "key": "SDT_LOG_LEVEL", - "description": "CLI log verbosity", - "default": "information", - "options": [ - "trace", - "debug", - "information", - "warning", - "error", - "critical" - ] - } - ], - "envProfiles": { - "active": "dev", - "profiles": [ - { - "id": "dev", - "description": "Local development defaults", - "inherits": [], - "values": { - "SDT_ENV_PROFILE": "dev", - "SDT_LOG_LEVEL": "information" - } - }, - { - "id": "ci", - "description": "Continuous integration defaults", - "inherits": [ - "dev" - ], - "values": { - "SDT_ENV_PROFILE": "ci", - "CI": "true", - "SDT_LOG_LEVEL": "warning" - } - }, - { - "id": "release", - "description": "Release build defaults", - "inherits": [ - "dev" - ], - "values": { - "SDT_ENV_PROFILE": "release", - "SDT_LOG_LEVEL": "warning" - } - } - ] - }, - "toolchains": { - "python": { - "executable": "python", - "windowsExecutable": "py", - "launcherVersion": null, - "venvDir": ".venv", - "profiles": [], - "pipScript": null - }, - "node": { - "packageManager": "npm", - "workingDir": "." - } - }, - "tooling": { - "tools": [ - { - "tool": "dotnet", - "preferredInstallCommands": [], - "executables": [] - }, - { - "tool": "node", - "preferredInstallCommands": [], - "executables": [] - }, - { - "tool": "npm", - "preferredInstallCommands": [], - "executables": [] - }, - { - "tool": "python", - "preferredInstallCommands": [], - "executables": [] - } - ] - }, - "project": { - "type": "polyglot", - "rootHints": [ - "*.sln", - "package.json", - "scripts" - ], - "artifacts": [ - "bin", - "obj", - ".sdt/debug" - ] - }, - "debug": { - "profiles": [ - { - "id": "dotnet-run", - "label": "Run .NET app", - "type": "dotnet", - "command": "dotnet", - "args": [ - "run" - ], - "workingDir": ".", - "env": {}, - "requires": [ - { - "tool": "dotnet", - "installPolicy": "Prompt" - } - ], - "attach": { - "kind": "manual", - "port": null, - "processName": null, - "note": "Attach your IDE debugger to the running dotnet process." - } - }, - { - "id": "npm-dev", - "label": "Run npm dev server", - "type": "node", - "command": "npm", - "args": [ - "run", - "dev" - ], - "workingDir": ".", - "env": {}, - "requires": [ - { - "tool": "node", - "installPolicy": "Prompt" - }, - { - "tool": "npm", - "installPolicy": "Prompt" - } - ], - "attach": null - } - ], - "diagnostics": { - "enabled": true, - "outputDir": ".sdt/debug", - "includeAllEnv": false, - "captureEnvKeys": [ - "SDT_LOG_LEVEL", - "DOTNET_CLI_HOME", - "NUGET_PACKAGES", - "PIP_CACHE_DIR", - "NVM_HOME", - "NVM_SYMLINK" - ], - "redactSensitive": true, - "sensitiveKeyPatterns": [ - "TOKEN", - "SECRET", - "PASSWORD", - "PWD", - "CREDENTIAL", - "API_KEY", - "ACCESS_KEY", - "PRIVATE_KEY" - ], - "redactionAllowKeys": [], - "bundleOnFailure": true - } - } -} diff --git a/scripts/build.py b/scripts/build.py index 39676ce..dee4403 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -8,11 +8,15 @@ import shutil import subprocess import sys import time -from script_common import resolve_command +from typing import Any + +from script_common import resolve_command, SdtResult # type: ignore + +StepResult = dict[str, Any] -def run_step(command, args, cwd): - resolved = resolve_command(command) +def run_step(command: str, args: list[str], cwd: str) -> StepResult: + resolved = str(resolve_command(command)) if shutil.which(resolved) is None and not pathlib.Path(resolved).exists(): return { "command": resolved, @@ -38,7 +42,7 @@ def run_step(command, args, cwd): } -def resolve_python_executable(): +def resolve_python_executable() -> str: candidates = ["py", "python"] if os.name == "nt" else ["python3", "python"] for c in candidates: if shutil.which(c): @@ -46,20 +50,20 @@ def resolve_python_executable(): 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 parse_common(parser: argparse.ArgumentParser) -> None: + _ = 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): +def resolve_cwd(project_root: str, working_dir: str) -> str: 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): +def discover_dotnet_target(project_root: str, cwd: str) -> str | None: # Prefer local solution first (.slnx, then .sln), then csproj, then bounded scan from project root. local_slnx = sorted(pathlib.Path(cwd).glob("*.slnx")) if len(local_slnx) == 1: @@ -88,9 +92,9 @@ def discover_dotnet_target(project_root: str, cwd: str): return None -def bounded_find_files(root: str, extension: str, max_depth: int): +def bounded_find_files(root: str, extension: str, max_depth: int) -> list[str]: root_path = pathlib.Path(root).resolve() - results = [] + results: list[str] = [] for current_root, dirs, files in os.walk(root_path): rel = pathlib.Path(current_root).resolve().relative_to(root_path) depth = len(rel.parts) @@ -105,7 +109,7 @@ def bounded_find_files(root: str, extension: str, max_depth: int): return sorted(results) -def run_dotnet_action(project_root, working_dir, verb): +def run_dotnet_action(project_root: str, working_dir: str, verb: str) -> tuple[int, StepResult]: cwd = resolve_cwd(project_root, working_dir) target = discover_dotnet_target(project_root, cwd) if not target: @@ -124,10 +128,14 @@ def run_dotnet_action(project_root, working_dir, verb): args = [verb, target] step = run_step("dotnet", args, cwd) step["resolved_target"] = target - return 0 if step["exit_code"] == 0 else step["exit_code"], step + + # Explicitly ensure the first return value is an integer and narrow the step result + exit_val = step.get("exit_code", 1) + exit_code = int(exit_val) if isinstance(exit_val, (int, float, str)) and str(exit_val).isdigit() else 1 + return exit_code, step -def _deps_hash(app_root): +def _deps_hash(app_root: str) -> str: h = hashlib.sha256() for name in ("package.json", "package-lock.json"): p = pathlib.Path(app_root) / name @@ -136,7 +144,7 @@ def _deps_hash(app_root): return h.hexdigest() -def ensure_npm_dependencies(app_root): +def ensure_npm_dependencies(app_root: str) -> dict[str, Any]: package_json = pathlib.Path(app_root) / "package.json" if not package_json.exists(): return {"installed": False, "reason": "not_applicable"} @@ -174,7 +182,7 @@ def ensure_npm_dependencies(app_root): return {"installed": True, "reason": "installed", "step": install_step} -def read_package_json(cwd: str): +def read_package_json(cwd: str) -> dict[str, Any] | None: package_json = pathlib.Path(cwd) / "package.json" if not package_json.exists(): return None @@ -194,23 +202,23 @@ def has_npm_script(cwd: str, script_name: str) -> bool: return script_name in scripts and isinstance(scripts.get(script_name), str) -def action_dotnet_build(args): +def action_dotnet_build(args: argparse.Namespace) -> tuple[int, StepResult]: return run_dotnet_action(args.project_root, args.working_dir, "build") -def action_dotnet_restore(args): +def action_dotnet_restore(args: argparse.Namespace) -> tuple[int, StepResult]: return run_dotnet_action(args.project_root, args.working_dir, "restore") -def action_dotnet_test(args): +def action_dotnet_test(args: argparse.Namespace) -> tuple[int, StepResult]: return run_dotnet_action(args.project_root, args.working_dir, "test") -def action_dotnet_publish(args): +def action_dotnet_publish(args: argparse.Namespace) -> tuple[int, StepResult]: return run_dotnet_action(args.project_root, args.working_dir, "publish") -def action_npm_install(args): +def action_npm_install(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "package.json").exists(): return 0, { @@ -224,10 +232,10 @@ def action_npm_install(args): "skip_reason": "not_applicable_no_package_json", } step = run_step("npm", ["install"], cwd) - return 0 if step["exit_code"] == 0 else step["exit_code"], step + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_npm_ci(args): +def action_npm_ci(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "package.json").exists(): return 0, { @@ -241,10 +249,10 @@ def action_npm_ci(args): "skip_reason": "not_applicable_no_package_json", } step = run_step("npm", ["ci"], cwd) - return 0 if step["exit_code"] == 0 else step["exit_code"], step + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_npm_build(args): +def action_npm_build(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "package.json").exists(): return 0, { @@ -283,12 +291,12 @@ def action_npm_build(args): if deps.get("reason") == "install_failed": step = deps["step"] step["failure_reason"] = "deps_install_failed" - return step["exit_code"], step + return int(step["exit_code"]), step step = run_step("npm", ["run", "build"], cwd) - return 0 if step["exit_code"] == 0 else step["exit_code"], step + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_npm_test(args): +def action_npm_test(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "package.json").exists(): return 0, { @@ -327,12 +335,12 @@ def action_npm_test(args): if deps.get("reason") == "install_failed": step = deps["step"] step["failure_reason"] = "deps_install_failed" - return step["exit_code"], step + return int(step["exit_code"]), step step = run_step("npm", ["test"], cwd) - return 0 if step["exit_code"] == 0 else step["exit_code"], step + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_npm_audit(args): +def action_npm_audit(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "package.json").exists(): return 0, { @@ -346,37 +354,37 @@ def action_npm_audit(args): "skip_reason": "not_applicable_no_package_json", } step = run_step("npm", ["audit"], cwd) - return 0 if step["exit_code"] == 0 else step["exit_code"], step + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_python_venv_create(args): +def action_python_venv_create(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, ".") - venv_dir = args.venv_dir or ".venv" + venv_dir = str(args.venv_dir) if hasattr(args, "venv_dir") else ".venv" step = run_step(resolve_python_executable(), ["-m", "venv", venv_dir], cwd) - return 0 if step["exit_code"] == 0 else step["exit_code"], step + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_python_pip_install(args): +def action_python_pip_install(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, ".") - req = args.requirements + req = str(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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_python_pip_sync(args): +def action_python_pip_sync(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, ".") - req = args.requirements + req = str(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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_python_pytest(args): +def action_python_pytest(args: argparse.Namespace) -> tuple[int, StepResult]: 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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_cargo_build(args): +def action_cargo_build(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "Cargo.toml").exists(): return 0, { @@ -390,10 +398,10 @@ def action_cargo_build(args): "skip_reason": "not_applicable_no_cargo_toml", } step = run_step("cargo", ["build"], cwd) - return 0 if step["exit_code"] == 0 else step["exit_code"], step + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_cargo_test(args): +def action_cargo_test(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) if not (pathlib.Path(cwd) / "Cargo.toml").exists(): return 0, { @@ -407,10 +415,10 @@ def action_cargo_test(args): "skip_reason": "not_applicable_no_cargo_toml", } step = run_step("cargo", ["test"], cwd) - return 0 if step["exit_code"] == 0 else step["exit_code"], step + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_tauri_build(args): +def action_tauri_build(args: argparse.Namespace) -> tuple[int, StepResult]: cwd = resolve_cwd(args.project_root, args.working_dir) tauri_conf = pathlib.Path(cwd) / "src-tauri" / "tauri.conf.json" if not tauri_conf.exists(): @@ -431,133 +439,94 @@ def action_tauri_build(args): if deps.get("reason") == "install_failed": step = deps["step"] step["failure_reason"] = "deps_install_failed" - return step["exit_code"], step + return int(step["exit_code"]), step tauri_args = ["run", "tauri", "build"] - if args.no_bundle: + if hasattr(args, "no_bundle") and 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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_git_status(args): +def action_git_status(args: argparse.Namespace) -> tuple[int, StepResult]: 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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_git_fetch(args): +def action_git_fetch(args: argparse.Namespace) -> tuple[int, StepResult]: 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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_git_pull(args): +def action_git_pull(args: argparse.Namespace) -> tuple[int, StepResult]: 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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_git_clean(args): +def action_git_clean(args: argparse.Namespace) -> tuple[int, StepResult]: 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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_docker_build(args): +def action_docker_build(args: argparse.Namespace) -> tuple[int, StepResult]: 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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_docker_compose_up(args): +def action_docker_compose_up(args: argparse.Namespace) -> tuple[int, StepResult]: 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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def action_docker_compose_down(args): +def action_docker_compose_down(args: argparse.Namespace) -> tuple[int, StepResult]: 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 + return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step -def main(): +def main() -> int: 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) + 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) - p1 = sub.add_parser("dotnet-build") - parse_common(p1) + p4 = sub.add_parser("python-venv-create"); parse_common(p4) + _ = p4.add_argument("--venv-dir", default=".venv") - p1b = sub.add_parser("dotnet-test") - parse_common(p1b) + p5 = sub.add_parser("python-pip-install"); parse_common(p5) + _ = p5.add_argument("--requirements", required=True) - p1c = sub.add_parser("dotnet-publish") - parse_common(p1c) + p5b = sub.add_parser("python-pip-sync"); parse_common(p5b) + _ = p5b.add_argument("--requirements", required=True) - p2 = sub.add_parser("npm-install") - parse_common(p2) + 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) - p2b = sub.add_parser("npm-ci") - parse_common(p2b) + p7 = sub.add_parser("tauri-build"); parse_common(p7) + _ = p7.add_argument("--no-bundle", action="store_true") - 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) + 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() @@ -590,7 +559,8 @@ def main(): code, summary = handlers[args.action](args) if args.json: print(json.dumps(summary)) - return code + + return int(code) if __name__ == "__main__": diff --git a/scripts/publish-app.py b/scripts/publish-app.py index 9f82a8f..7a0cb5c 100644 --- a/scripts/publish-app.py +++ b/scripts/publish-app.py @@ -1,90 +1,42 @@ #!/usr/bin/env python3 import argparse - - -from script_common import find_node_app_root, resolve_repo_root, run, sha256_files +from typing import cast +from script_common import ensure_npm_build, find_node_app_root, resolve_repo_root 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") + _ = 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) + repo_root_val = cast(str | None, args.repo_root) + repo_root = resolve_repo_root(repo_root_val) + app_root_val = cast(str | None, args.app_root) + app_root = find_node_app_root(repo_root, app_root_val) 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) + # If dry-run is requested, we just print intent. + if args.dry_run: + print(f"Dry-run: Would build {args.target} ({args.configuration}) in {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]) + res = ensure_npm_build( + app_root=app_root, + target=str(args.target), + configuration=str(args.configuration), + tauri_bundles=str(args.tauri_bundles) + ) - print("$ npm " + " ".join(tauri_cmd)) - if not args.dry_run: - return run("npm", tauri_cmd, app_root) - - return 0 + return int(res["exit_code"]) if __name__ == "__main__": diff --git a/scripts/publish-output.py b/scripts/publish-output.py index 92c0afd..8dcfd16 100644 --- a/scripts/publish-output.py +++ b/scripts/publish-output.py @@ -1,22 +1,11 @@ #!/usr/bin/env python3 import argparse -import json import shutil -import subprocess import sys +import os +import json from pathlib import Path - -from script_common import find_csproj_by_keyword, 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 - +from script_common import find_csproj_by_keyword, find_node_app_root, resolve_repo_root, run # type: ignore def has_package_script(app_root: Path, script_name: str) -> bool: package_json = app_root / "package.json" @@ -35,18 +24,18 @@ def has_package_script(app_root: Path, script_name: str) -> bool: 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") + _ = 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) @@ -56,45 +45,48 @@ def main() -> int: sidecar_project = (repo_root / args.sidecar_project).resolve() if args.sidecar_project else find_csproj_by_keyword(repo_root, ["sidecar"]) gateway_project = (repo_root / args.gateway_project).resolve() if args.gateway_project else find_csproj_by_keyword(repo_root, ["webgateway", "gateway"]) app_root = (repo_root / args.app_root).resolve() if args.app_root else find_node_app_root(repo_root, None) + tauri_conf = None if app_root is not None: - candidate_a = app_root / "src-tauri" / "tauri.conf.json" - candidate_b = app_root / "tauri.conf.json" - if candidate_a.exists(): - tauri_conf = candidate_a - elif candidate_b.exists(): - tauri_conf = candidate_b + tauri_conf = next((p for p in [app_root/"src-tauri"/"tauri.conf.json", app_root/"tauri.conf.json"] if p.exists()), None) py = sys.executable + scripts_dir = Path(__file__).parent + if not args.skip_sidecar: if sidecar_project is None: print("Skipping sidecar: no sidecar csproj detected.") else: - cmd = [py, "scripts/publish-sidecar.py", "--configuration", args.configuration, "--runtime", args.runtime] - cmd.extend(["--project", str(sidecar_project)]) - code = run_step("Publish sidecar", cmd, repo_root, args.dry_run) - if code != 0: - return code + cmd = ["-m", "scripts.publish-sidecar" if __package__ else "publish-sidecar", + "--configuration", args.configuration, "--runtime", args.runtime, "--project", str(sidecar_project)] + print(f"\n> Publishing Sidecar\n$ {py} {' '.join(cmd)}") + if not args.dry_run: + code = run(py, cmd, scripts_dir) + if code != 0: return code if not args.skip_web: if app_root is None: - print("Skipping web: no app root with package.json detected.") + print("Skipping web: no app root detected.") elif not has_package_script(app_root, "build"): print("Skipping web: package.json has no 'build' script.") else: - cmd = [py, "scripts/publish-app.py", "--target", "web", "--configuration", args.configuration, "--app-root", str(app_root)] - code = run_step("Build web", cmd, repo_root, args.dry_run) - if code != 0: - return code + cmd = ["-m", "scripts.publish-app" if __package__ else "publish-app", + "--target", "web", "--configuration", args.configuration, "--app-root", str(app_root)] + print(f"\n> Building Web\n$ {py} {' '.join(cmd)}") + if not args.dry_run: + code = run(py, cmd, scripts_dir) + if code != 0: return code if not args.skip_webgateway: if gateway_project is None: print("Skipping web gateway: no gateway csproj detected.") else: - cmd = [py, "scripts/publish-webgateway.py", "--configuration", args.configuration, "--runtime", args.runtime, "--project", str(gateway_project)] - code = run_step("Publish web gateway", cmd, repo_root, args.dry_run) - if code != 0: - return code + cmd = ["-m", "scripts.publish-webgateway" if __package__ else "publish-webgateway", + "--configuration", args.configuration, "--runtime", args.runtime, "--project", str(gateway_project)] + print(f"\n> Publishing Web Gateway\n$ {py} {' '.join(cmd)}") + if not args.dry_run: + code = run(py, cmd, scripts_dir) + if code != 0: return code if not args.skip_tauri: if app_root is None or tauri_conf is None: @@ -102,21 +94,20 @@ def main() -> int: elif not has_package_script(app_root, "tauri"): print("Skipping tauri: package.json has no 'tauri' script.") else: - cmd = [py, "scripts/publish-app.py", "--target", "tauri", "--configuration", args.configuration, "--tauri-bundles", "none", "--app-root", str(app_root)] - code = run_step("Build tauri", cmd, repo_root, args.dry_run) - if code != 0: - return code + cmd = ["-m", "scripts.publish-app" if __package__ else "publish-app", + "--target", "tauri", "--configuration", args.configuration, "--tauri-bundles", "none", "--app-root", str(app_root)] + print(f"\n> Building Tauri\n$ {py} {' '.join(cmd)}") + if not args.dry_run: + code = run(py, cmd, scripts_dir) + if code != 0: return code target_dir = app_root / "src-tauri" / "target" / ("debug" if args.configuration == "Debug" else "release") - if args.runtime.startswith("win-") or getattr(sys, "platform", "").startswith("win"): - exes = sorted(target_dir.glob("*.exe"), key=lambda p: p.stat().st_mtime, reverse=True) - else: - exes = sorted((p for p in target_dir.glob("*") if p.is_file() and not p.suffix), key=lambda p: p.stat().st_mtime, reverse=True) + pattern = "*.exe" if os.name == "nt" else "*" + exes = sorted((p for p in target_dir.glob(pattern) if p.is_file() and (os.name == "nt" or not p.suffix)), 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}") + if args.dry_run: print(f"Would copy: {exes[0]} -> {staged}") else: shutil.copy2(exes[0], staged) print(f"Staged desktop executable: {staged}") diff --git a/scripts/publish-sidecar.py b/scripts/publish-sidecar.py index 964f750..bbd4514 100644 --- a/scripts/publish-sidecar.py +++ b/scripts/publish-sidecar.py @@ -1,51 +1,48 @@ #!/usr/bin/env python3 import argparse - - -from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run +from typing import cast +from script_common import ensure_dotnet_publish, find_csproj_by_keyword, resolve_repo_root, SdtResult 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") + _ = 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() + repo_root_val = cast(str | None, args.repo_root) + repo_root = resolve_repo_root(repo_root_val) + output_dir_val = cast(str, args.output_dir) + output_dir = (repo_root / output_dir_val).resolve() output_dir.mkdir(parents=True, exist_ok=True) - if args.project: - csproj = (repo_root / args.project).resolve() + project_val = cast(str | None, args.project) + if project_val: + csproj = (repo_root / project_val).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 + res: SdtResult = ensure_dotnet_publish( + csproj=csproj, + output_dir=output_dir, + configuration=str(args.configuration), + runtime=str(args.runtime), + single_file=True, + self_contained=True + ) + + if res["exit_code"] != 0: + return int(res["exit_code"]) - binary_name = csproj.stem + (".exe" if args.runtime.startswith("win-") else "") + runtime_val = str(args.runtime) + binary_name = csproj.stem + (".exe" if runtime_val.startswith("win-") else "") binary_path = output_dir / binary_name if binary_path.exists(): print(f"Published executable: {binary_path}") diff --git a/scripts/publish-webgateway.py b/scripts/publish-webgateway.py index 6a2c9c0..9884b84 100644 --- a/scripts/publish-webgateway.py +++ b/scripts/publish-webgateway.py @@ -1,74 +1,67 @@ #!/usr/bin/env python3 import argparse import shutil - -from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run +from typing import cast +from script_common import ensure_dotnet_publish, find_csproj_by_keyword, resolve_repo_root, SdtResult 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") + _ = 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() + repo_root_val = cast(str | None, args.repo_root) + repo_root = resolve_repo_root(repo_root_val) + output_dir_val = cast(str, args.output_dir) + output_dir = (repo_root / output_dir_val).resolve() output_dir.mkdir(parents=True, exist_ok=True) - if args.project: - csproj = (repo_root / args.project).resolve() + project_val = cast(str | None, args.project) + if project_val: + csproj = (repo_root / project_val).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 + res: SdtResult = ensure_dotnet_publish( + csproj=csproj, + output_dir=output_dir, + configuration=str(args.configuration), + runtime=str(args.runtime), + self_contained=bool(args.self_contained), + single_file=False + ) + + if res["exit_code"] != 0: + return int(res["exit_code"]) if not args.skip_web_assets: - if args.web_build_dir: - web_build_dir = (repo_root / args.web_build_dir).resolve() + web_build_dir_val = cast(str | None, args.web_build_dir) + if web_build_dir_val: + web_build_dir = (repo_root / web_build_dir_val).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" + # Look for recent web build output + # (Note: rglob is costly but necessary for discovery here) + web_pj = next((p.parent for p in repo_root.rglob("package.json") if (p.parent / "build").exists()), None) + web_build_dir = web_pj / "build" if web_pj else None 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"Copying web assets: {web_build_dir} -> {web_out}") + shutil.copytree(web_build_dir, web_out, dirs_exist_ok=True) + print(f"Copied web assets to {web_out}") print(f"Publish completed: {output_dir}") return 0 diff --git a/scripts/script-common.ps1 b/scripts/script-common.ps1 index 36e681e..2e329bd 100644 --- a/scripts/script-common.ps1 +++ b/scripts/script-common.ps1 @@ -16,6 +16,15 @@ function Clear-SdtProxyEnv { Remove-Item Env:PIP_NO_INDEX -ErrorAction SilentlyContinue } +function Test-SdtConfigExists { + param([string]$Path) + if (Test-Path (Join-Path $Path "devtool.json")) { + return $true + } + $sdtConfigs = Get-ChildItem -Path $Path -Filter "sdtconfig-*.json" -File -ErrorAction SilentlyContinue + return ($null -ne $sdtConfigs) -and ($sdtConfigs.Count -gt 0) +} + function Resolve-SdtRepoRoot { param([string]$StartPath) @@ -34,7 +43,7 @@ function Resolve-SdtRepoRoot { } if (-not [string]::IsNullOrWhiteSpace($override)) { $overridePath = [System.IO.Path]::GetFullPath($override) - if (Test-Path (Join-Path $overridePath "devtool.json")) { + if (Test-SdtConfigExists -Path $overridePath) { return $overridePath } } @@ -42,7 +51,7 @@ function Resolve-SdtRepoRoot { foreach ($start in $candidateStarts) { $cursor = [System.IO.Path]::GetFullPath($start) while (-not [string]::IsNullOrWhiteSpace($cursor)) { - if (Test-Path (Join-Path $cursor "devtool.json")) { + if (Test-SdtConfigExists -Path $cursor) { return $cursor } $parent = [System.IO.Directory]::GetParent($cursor) @@ -65,7 +74,7 @@ function Resolve-SdtRepoRoot { } } - throw "Could not locate repository root. Ensure a devtool.json exists in the project root." + throw "Could not locate repository root. Ensure a project config (sdtconfig-*.json or devtool.json) exists in the project root." } function Initialize-SdtDotnetEnv { diff --git a/scripts/script_common.py b/scripts/script_common.py index 03faa87..7946702 100644 --- a/scripts/script_common.py +++ b/scripts/script_common.py @@ -6,357 +6,298 @@ import pathlib import shutil import subprocess import sys -from typing import Dict, Iterable, List, Sequence +import time +from typing import Any, Iterable, Optional, Sequence +# --- Domain: SDT Types --- +# SDT Normalized Result Object +# result["exit_code"]: int +# result["status"]: "ok" | "failed" | "skipped" +# result["elapsed_seconds"]: float +# result["failure_reason"]: Optional[str] +# result["skip_reason"]: Optional[str] +SdtResult = dict[str, Any] PROXY_VARS = [ - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", - "GIT_HTTP_PROXY", - "GIT_HTTPS_PROXY", - "PIP_NO_INDEX", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", + "GIT_HTTP_PROXY", "GIT_HTTPS_PROXY", "PIP_NO_INDEX" ] +# --- Domain: FS Utilities --- -def resolve_repo_root(start: str | None = None) -> pathlib.Path: - base = pathlib.Path(start or os.getcwd()).resolve() +def sha256_files(paths: Iterable[pathlib.Path]) -> str: + h = hashlib.sha256() + for p in sorted(paths): + if p.exists(): h.update(p.read_bytes()) + return h.hexdigest() - # 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: +def first_existing(paths: Iterable[pathlib.Path]) -> Optional[pathlib.Path]: for p in paths: - p.mkdir(parents=True, exist_ok=True) + if p.exists(): return p + return None +def newest_file(search_root: pathlib.Path, pattern: str) -> Optional[pathlib.Path]: + hits = sorted(search_root.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True) + return hits[0] if hits else None -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 ensure_dirs(paths: list[pathlib.Path]) -> None: + for p in paths: p.mkdir(parents=True, exist_ok=True) 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: - def _read_package_json(package_json: pathlib.Path) -> dict | None: - if not package_json.exists(): - return None - try: - data = json.loads(package_json.read_text(encoding="utf-8")) - return data if isinstance(data, dict) else None - except Exception: - return None - - def _has_scripts(package_json: pathlib.Path, names: Sequence[str]) -> bool: - data = _read_package_json(package_json) - if not data: - return False - scripts = data.get("scripts") - if not isinstance(scripts, dict): - return False - for name in names: - value = scripts.get(name) - if isinstance(value, str) and value.strip(): + try: + has_glob = any(ch in h for ch in ("*", "?", "[")) + if has_glob: + if any(root.glob(h)): return True + return any(root.rglob(h)) + + marker = root / h + if marker.exists(): + return True + + # If hint is a plain filename marker, allow bounded search in root tree. + if not any(sep in h for sep in ("/", "\\")): + return any(p.name == h for p in root.rglob(h)) + + return False + except: return False - def _is_tauri_root(candidate_dir: pathlib.Path) -> bool: - return (candidate_dir / "src-tauri" / "tauri.conf.json").exists() or (candidate_dir / "tauri.conf.json").exists() +# --- Domain: Project Discovery --- - def _iter_package_jsons() -> list[pathlib.Path]: - excluded = {"node_modules", "dist", "build", ".git", ".sdt", ".venv", "venv", "bin", "obj"} - found: list[pathlib.Path] = [] - for current_root, dirs, files in os.walk(repo_root): - dirs[:] = [d for d in dirs if d not in excluded] - if "package.json" in files: - found.append(pathlib.Path(current_root) / "package.json") - found.sort(key=lambda p: len(p.parts)) - return found +def resolve_repo_root(start: str | None = None) -> pathlib.Path: + base = pathlib.Path(start or os.getcwd()).resolve() + for cur in [base, *base.parents]: + sdt_configs = list(cur.glob("sdtconfig-*.json")) + cfg = sdt_configs[0] if sdt_configs else (cur / "devtool.json") + if cfg.exists(): + hints = load_project_root_hints(cur, cfg) + if not hints or any(_hint_matches(cur, hint) for hint in hints): return cur + try: + proc = subprocess.run(["git", "-C", str(base), "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False) + if proc.returncode == 0 and proc.stdout.strip(): return pathlib.Path(proc.stdout.strip()).resolve() + except: pass + return base - if preferred: - p = (repo_root / preferred).resolve() - package_json = p / "package.json" - if package_json.exists(): - # Keep explicit preferred root only when it appears runnable for node workflows. - if _is_tauri_root(p) or _has_scripts(package_json, ("build", "dev", "start", "test", "tauri")): - return p +def load_project_root_hints(repo_root: pathlib.Path, cfg: pathlib.Path | None = None) -> list[str]: + if cfg is None: + sdt_configs = list(repo_root.glob("sdtconfig-*.json")) + cfg = sdt_configs[0] if sdt_configs else (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: return [] - package_files = _iter_package_jsons() - if not package_files: - return None - - # Strong preference: a tauri app root with tauri config and package.json. - tauri_candidates = [p.parent for p in package_files if _is_tauri_root(p.parent)] - if len(tauri_candidates) == 1: - return tauri_candidates[0] - if len(tauri_candidates) > 1: - tauri_candidates.sort(key=lambda p: len(p.parts)) - return tauri_candidates[0] - - runnable_candidates = [ - p.parent for p in package_files if _has_scripts(p, ("build", "dev", "start", "test", "tauri")) - ] - if len(runnable_candidates) == 1: - return runnable_candidates[0] - if len(runnable_candidates) > 1: - runnable_candidates.sort(key=lambda p: len(p.parts)) - return runnable_candidates[0] - - # As a last fallback, return unique package root only. - if len(package_files) == 1: - return package_files[0].parent +def _resolve_project_config_path(repo_root: pathlib.Path) -> Optional[pathlib.Path]: + sdt_configs = sorted(repo_root.glob("sdtconfig-*.json"), key=lambda p: p.name.lower()) + if sdt_configs: + return sdt_configs[0] + legacy = repo_root / "devtool.json" + if legacy.exists(): + return legacy return None +def load_node_working_dir(repo_root: pathlib.Path) -> Optional[str]: + cfg = _resolve_project_config_path(repo_root) + if cfg is None: + return None + try: + data = json.loads(cfg.read_text(encoding="utf-8")) + node = data.get("toolchains", {}).get("node", {}) + value = node.get("workingDir") + if isinstance(value, str) and value.strip(): + return value.strip() + except: + pass + 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] +def find_csproj(repo_root: pathlib.Path, hints: Sequence[str] | None = None) -> Optional[pathlib.Path]: + if hints: + for h in hints: + p = (repo_root / h).resolve() + if p.exists() and p.suffix == ".csproj": return p + hits = [h for h in repo_root.rglob("*.csproj") if not any(x in h.parts for x in [".git", "node_modules", "bin", "obj"])] + return hits[0] if len(hits) == 1 else None + +def find_csproj_by_keyword(repo_root: pathlib.Path, keywords: Sequence[str]) -> Optional[pathlib.Path]: + hits = [h for h in repo_root.rglob("*.csproj") if not any(x in h.parts for x in [".git", "node_modules", "bin", "obj"])] + for kw in keywords: + matches = [h for h in hits if kw.lower() in h.name.lower()] + if len(matches) == 1: return matches[0] + return None + +def find_node_app_root(repo_root: pathlib.Path, preferred: str | None = None) -> Optional[pathlib.Path]: + def _read_pj(p: pathlib.Path): + try: return json.loads(p.read_text(encoding="utf-8")) + except: return None + def _has_scr(p: pathlib.Path, names: Sequence[str]): + src = _read_pj(p) + return any(src.get("scripts", {}).get(n) for n in names) if src else False + def _is_tauri(d: pathlib.Path): return (d / "src-tauri" / "tauri.conf.json").exists() or (d / "tauri.conf.json").exists() + def _iter_pj(): + excluded = {".git", "node_modules", ".sdt", "dist", "build", ".venv", "venv", "bin", "obj"} + for p in repo_root.rglob("package.json"): + if any(x in p.parts for x in excluded): + continue + yield p + + if not preferred: + preferred = load_node_working_dir(repo_root) + if preferred: + p = (repo_root / preferred).resolve() + p = p.parent if p.is_file() else p + if (p / "package.json").exists(): + return p + + tauri = [p.parent for p in _iter_pj() if _is_tauri(p.parent)] + if len(tauri) == 1: return tauri[0] + if len(tauri) > 1: + tauri = sorted(set(tauri), key=lambda d: (len(d.parts), str(d).lower())) + return tauri[0] + scripts = [p.parent for p in _iter_pj() if _has_scr(p, ["tauri", "build"])] + if len(scripts) == 1: return scripts[0] + if len(scripts) > 1: + scripts = sorted(set(scripts), key=lambda d: (len(d.parts), str(d).lower())) + return scripts[0] + + all_pj = [p.parent for p in _iter_pj()] + return all_pj[0] if len(all_pj) == 1 else None + +# --- Domain: Environment Setup --- + +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) + h, p, c = repo_root/".dotnet_home", repo_root/".nuget"/"packages", repo_root/".nuget"/"http-cache" + ensure_dirs([h, p, c]) + env.update({"DOTNET_CLI_HOME":str(h), "NUGET_PACKAGES":str(p), "NUGET_HTTP_CACHE_PATH":str(c), + "DOTNET_SKIP_FIRST_TIME_EXPERIENCE":"1", "DOTNET_ADD_GLOBAL_TOOLS_TO_PATH":"0", + "DOTNET_GENERATE_ASPNET_CERTIFICATE":"0", + "DOTNET_CLI_TELEMETRY_OPTOUT":"1", "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) + c, t = repo_root/".pip"/"cache", repo_root/".tmp"/"pip-temp" + ensure_dirs([c, t]) + env.update({"PIP_CACHE_DIR":str(c), "PIP_DISABLE_PIP_VERSION_CHECK":"1", "PIP_DEFAULT_TIMEOUT":"30", "PIP_RETRIES":"2", "TEMP":str(t), "TMP":str(t)}) + return env + +# --- Domain: Process Execution --- + +def resolve_command(command: str) -> str: + if not command or os.name != "nt" or any(s in command for s in ("\\", "/")): return command + if pathlib.Path(command).suffix: return shutil.which(command) or command + low = command.lower() + cands = [f"{command}.cmd", f"{command}.exe", f"{command}.bat", command] if low in ("npm", "npx", "pnpm", "yarn", "tauri") else [command] + for c in cands: + found = _which_windows(c) + if found: + if pathlib.Path(found).name.lower() in ("npm", "npx", "pnpm", "yarn", "tauri"): + shim = pathlib.Path(found).with_name(pathlib.Path(found).name.lower() + ".cmd") + if shim.exists(): return str(shim) + return found + if low in ("npm", "npx", "pnpm", "yarn"): + node = _which_windows("node.exe") or _which_windows("node") + if node and (pathlib.Path(node).parent / f"{low}.cmd").exists(): return str(pathlib.Path(node).parent / f"{low}.cmd") + return cands[-1] + +def _which_windows(command: str) -> Optional[str]: + found = shutil.which(command) + if found: return str(pathlib.Path(found).resolve()) + for p in ["C:\\Program Files\\dotnet", "C:\\Program Files\\nodejs", "C:\\Program Files\\Git\\bin", "C:\\Program Files\\Git\\usr\\bin"]: + if (pathlib.Path(p) / command).exists(): return str((pathlib.Path(p) / command).resolve()) + return None + +def run(command: str, args: list[str], cwd: pathlib.Path, env: dict[str, str] | None = None) -> int: + try: + proc = subprocess.run([resolve_command(command), *args], cwd=str(cwd), env=env, check=False) + return proc.returncode + except: return 127 + +def run_capture(command: str, args: Sequence[str], cwd: pathlib.Path, env: dict[str, str] | None = None) -> tuple[int, str, str]: + try: + proc = subprocess.run([resolve_command(command), *args], cwd=str(cwd), env=env, capture_output=True, text=True, check=False, encoding="utf-8", errors="replace") + return proc.returncode, proc.stdout, proc.stderr + except: return 127, "", "Command not found" + +def _safe_stream_write(stream: Any, text: str) -> None: + if not text: + return + try: + stream.write(text) + return + except UnicodeEncodeError: + pass + + encoding = getattr(stream, "encoding", None) or "utf-8" + data = text.encode(encoding, errors="replace") + buffer = getattr(stream, "buffer", None) + if buffer is not None: + buffer.write(data) + buffer.flush() + return + + # Last resort when no binary buffer is exposed. + stream.write(data.decode(encoding, errors="replace")) + +def run_step_with_summary(label: str, command: str, args: Sequence[str], cwd: pathlib.Path, env: dict[str, str] | None = None) -> SdtResult: + print(f"\n> {label}\n$ {command} {' '.join(args)}") + start = time.time() + code, out, err = run_capture(command, args, cwd, env) + elapsed = round(time.time() - start, 3) + if out: _safe_stream_write(sys.stdout, out) + if err: _safe_stream_write(sys.stderr, err) + return {"exit_code": code, "status": "ok" if code == 0 else "failed", "elapsed_seconds": elapsed, "stdout": out, "stderr": err, "failure_reason": None, "skip_reason": None} + +# --- Domain: High-Level Build Logic --- + +def ensure_dotnet_publish(csproj: pathlib.Path, output_dir: pathlib.Path, configuration: str = "Release", runtime: str = "win-x64", single_file: bool = False, self_contained: bool = True) -> SdtResult: + repo_root = resolve_repo_root(str(csproj.parent)) + env = dotnet_env(repo_root) + args = ["publish", str(csproj), "-c", configuration, "-r", runtime, "--self-contained", "true" if self_contained else "false", + f"-p:PublishSingleFile={'true' if single_file else 'false'}", "-p:RestoreIgnoreFailedSources=true", "-p:NuGetAudit=false", "-o", str(output_dir)] + if single_file: args.append("-p:IncludeNativeLibrariesForSelfExtract=true") + res = run_step_with_summary(f"Publishing {csproj.name}", "dotnet", args, repo_root, env) + if res["exit_code"] != 0 and single_file: + comb = (res["stdout"] + res["stderr"]).lower() + if "generatebundle" in comb and "same bundlerelativepath" in comb: + print("Duplicate bundle entries detected; retrying without single-file optimization...") + retry = [a if "PublishSingleFile=true" not in a else "-p:PublishSingleFile=false" for a in args] + res = run_step_with_summary(f"Publishing {csproj.name} (retry)", "dotnet", retry, repo_root, env) + if res["exit_code"] != 0: + comb = (res["stdout"] + res["stderr"]).lower() + if "netsdk1152" in comb and "multiple publish output files with the same relative path" in comb: + print("Duplicate publish output files detected (NETSDK1152); retrying with ErrorOnDuplicatePublishOutputFiles=false...") + retry = list(args) + if not any("ErrorOnDuplicatePublishOutputFiles" in a for a in retry): + retry.append("-p:ErrorOnDuplicatePublishOutputFiles=false") + res = run_step_with_summary(f"Publishing {csproj.name} (dedupe retry)", "dotnet", retry, repo_root, env) + return res + +def ensure_npm_build(app_root: pathlib.Path, target: str = "web", configuration: str = "Release", tauri_bundles: str = "none") -> SdtResult: + pj, lock = app_root / "package.json", first_existing([app_root / "package-lock.json", app_root / "npm-shrinkwrap.json"]) + nm = app_root / "node_modules" + h_file, exp_h = nm / ".sdt-deps.sha256", sha256_files([pj, lock] if lock else [pj]) + should_inst = not nm.exists() or not h_file.exists() or h_file.read_text(encoding="utf-8").strip() != exp_h + if should_inst: + args = ["ci", "--no-audit", "--fund=false"] if lock else ["install", "--no-audit", "--fund=false"] + res = run_step_with_summary("Installing NPM dependencies", "npm", args, app_root) + if res["exit_code"] != 0 and lock and args[0] == "ci": + res = run_step_with_summary("Installing NPM dependencies (retry)", "npm", ["install", "--no-audit", "--fund=false"], app_root) + if res["exit_code"] != 0: return res + ensure_dirs([nm]); h_file.write_text(exp_h, encoding="utf-8") + if target == "web": return run_step_with_summary(f"Building Web ({configuration})", "npm", ["run", "build"], app_root) + t_cmd = ["run", "tauri", "build"] + tail = (["--no-bundle"] if tauri_bundles == "none" else ["--bundles", tauri_bundles]) + (["--debug"] if configuration == "Debug" else []) + if tail: t_cmd.extend(["--", *tail]) + return run_step_with_summary(f"Building Tauri ({configuration})", "npm", t_cmd, app_root) diff --git a/scripts/sync-output.py b/scripts/sync-output.py index 442942a..10c1dc0 100644 --- a/scripts/sync-output.py +++ b/scripts/sync-output.py @@ -4,7 +4,7 @@ import os import shutil from pathlib import Path -from script_common import newest_file, resolve_repo_root +from script_common import newest_file, resolve_repo_root # type: ignore def copy_tree_contents(src: Path, dst: Path) -> None: @@ -21,31 +21,36 @@ def copy_tree_contents(src: Path, dst: Path) -> None: 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") + _ = 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 + web_build_dir_val = args.web_build_dir + web_build = (repo_root / web_build_dir_val).resolve() if web_build_dir_val 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 + sidecar_bin_dir_val = args.sidecar_bin_dir + sidecar_bin = (repo_root / sidecar_bin_dir_val).resolve() if sidecar_bin_dir_val 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_exe = None if os.name == "nt": sidecar_exe = newest_file(sidecar_bin, "*.exe") else: @@ -56,11 +61,14 @@ def main() -> int: 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 + gateway_bin_dir_val = args.gateway_bin_dir + gateway_bin = (repo_root / gateway_bin_dir_val).resolve() if gateway_bin_dir_val 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: + gw_exe = None if os.name == "nt": gw_exe = newest_file(gateway_bin, "*.exe") else: @@ -72,11 +80,14 @@ def main() -> int: 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 + tauri_target_dir_val = args.tauri_target_dir + tauri_target = (repo_root / tauri_target_dir_val).resolve() if tauri_target_dir_val 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 + tauri_src = next((p for p in repo_root.rglob("src-tauri") if (p / "target").exists()), None) + tauri_target = tauri_src / "target" if tauri_src else None + if tauri_target is not None: + app_exe = None if os.name == "nt": app_exe = newest_file(tauri_target, "*.exe") else: diff --git a/scripts/verify-workflow-routes.py b/scripts/verify-workflow-routes.py index c03cbc5..1ca02ab 100644 --- a/scripts/verify-workflow-routes.py +++ b/scripts/verify-workflow-routes.py @@ -5,23 +5,23 @@ import pathlib import shutil import subprocess import sys -from typing import Any, Dict, List, Optional, Sequence, Tuple - +from typing import Any, Optional, Sequence from script_common import resolve_command, resolve_repo_root -def load_config(project_root: pathlib.Path) -> dict: - config_path = project_root / "devtool.json" +def load_config(project_root: pathlib.Path) -> dict[str, Any]: + sdt_configs = list(project_root.glob("sdtconfig-*.json")) + config_path = sdt_configs[0] if sdt_configs else (project_root / "devtool.json") if not config_path.exists(): - raise FileNotFoundError(f"devtool.json not found at: {config_path}") + raise FileNotFoundError(f"Project config not found at: {config_path}") return json.loads(config_path.read_text(encoding="utf-8")) -def iter_workflows(config: dict, selected: Optional[set[str]]) -> List[dict]: +def iter_workflows(config: dict[str, Any], selected: Optional[set[str]]) -> list[dict[str, Any]]: workflows = config.get("workflows", []) if not isinstance(workflows, list): return [] - normalized: List[dict] = [w for w in workflows if isinstance(w, dict) and isinstance(w.get("id"), str)] + normalized: list[dict[str, Any]] = [w for w in workflows if isinstance(w, dict) and isinstance(w.get("id"), str)] if selected: normalized = [w for w in normalized if w["id"] in selected] return normalized @@ -45,21 +45,25 @@ def resolve_script_arg(project_root: pathlib.Path, working_dir: pathlib.Path, ar return b -def static_check_workflow(project_root: pathlib.Path, workflow: dict) -> dict: - result = { +def static_check_workflow(project_root: pathlib.Path, workflow: dict[str, Any]) -> dict[str, Any]: + result: dict[str, Any] = { "workflowId": workflow.get("id"), "ok": True, "issues": [], "steps": [], } - for step in workflow.get("steps", []): + steps = workflow.get("steps", []) + if not isinstance(steps, list): + return result + + for step in steps: if not isinstance(step, dict): continue step_id = step.get("id", "") - step_result = {"stepId": step_id, "ok": True, "issues": []} + step_result: dict[str, Any] = {"stepId": step_id, "ok": True, "issues": []} - working_dir_rel = step.get("workingDir") or "." + working_dir_rel = str(step.get("workingDir") or ".") working_dir = (project_root / working_dir_rel).resolve() if not working_dir.exists(): step_result["ok"] = False @@ -75,14 +79,13 @@ def static_check_workflow(project_root: pathlib.Path, workflow: dict) -> dict: step_result["issues"].append(f"command_not_found:{command}") if command.lower() in ("python", "py", "python3", "python.exe", "py.exe"): - if args and isinstance(args[0], str) and args[0].endswith(".py"): + if isinstance(args, list) and args and isinstance(args[0], str) and args[0].endswith(".py"): script_path = resolve_script_arg(project_root, working_dir, args[0]) if not script_path.exists(): step_result["ok"] = False step_result["issues"].append(f"python_script_not_found:{script_path}") if isinstance(action, str) and action.strip(): - # Action-based steps still require workingDir existence for reliable execution. if not working_dir.exists(): step_result["ok"] = False step_result["issues"].append("action_working_dir_not_found") @@ -96,8 +99,8 @@ def static_check_workflow(project_root: pathlib.Path, workflow: dict) -> dict: return result -def sdt_attempts(repo_root: pathlib.Path) -> List[List[str]]: - attempts: List[List[str]] = [] +def sdt_attempts(repo_root: pathlib.Path) -> list[list[str]]: + attempts: list[list[str]] = [] attempts.append(["sdt"]) if sys.platform.startswith("win"): attempts.append(["sdt.exe"]) @@ -110,9 +113,8 @@ def sdt_attempts(repo_root: pathlib.Path) -> List[List[str]]: if devtool_csproj.exists(): attempts.append(["dotnet", "run", "--project", str(devtool_csproj), "--"]) - # Preserve order but dedupe exact attempts. - seen = set() - unique: List[List[str]] = [] + seen: set[tuple[str, ...]] = set() + unique: list[list[str]] = [] for a in attempts: key = tuple(a) if key in seen: @@ -126,8 +128,8 @@ def try_run_sdt( repo_root: pathlib.Path, command_args: Sequence[str], timeout_seconds: int, -) -> Tuple[Optional[subprocess.CompletedProcess], Optional[str]]: - errors: List[str] = [] +) -> tuple[Optional[subprocess.CompletedProcess[str]], Optional[str]]: + errors: list[str] = [] for base in sdt_attempts(repo_root): cmd = [*base, *command_args] try: @@ -147,7 +149,7 @@ def try_run_sdt( return None, "; ".join(errors) if errors else "no_sdt_attempts" -def parse_headless_summary(stdout: str) -> Optional[Dict[str, Any]]: +def parse_headless_summary(stdout: str) -> Optional[dict[str, Any]]: lines = [line.strip() for line in stdout.splitlines() if line.strip()] for line in reversed(lines): if not line.startswith("{"): @@ -167,8 +169,8 @@ def execute_check_workflow( workflow_id: str, env_profile: Optional[str], timeout_seconds: int, -) -> dict: - args = [ +) -> dict[str, Any]: + run_args = [ "run", workflow_id, "--json", @@ -177,9 +179,9 @@ def execute_check_workflow( "--non-interactive", ] if env_profile: - args.extend(["--env-profile", env_profile]) + run_args.extend(["--env-profile", env_profile]) - proc, attempted = try_run_sdt(repo_root, args, timeout_seconds) + proc, attempted = try_run_sdt(repo_root, run_args, timeout_seconds) if proc is None: return { "workflowId": workflow_id, @@ -215,13 +217,13 @@ def main() -> int: parser = argparse.ArgumentParser( description="Verify SDT workflow routes (static path checks + optional headless execution)." ) - parser.add_argument("--repo-root", default=None, help="Repo root containing DevTool.csproj") - parser.add_argument("--project-root", default=".", help="Project root containing devtool.json") - parser.add_argument("--workflow", action="append", default=[], help="Workflow id to verify (repeatable)") - parser.add_argument("--execute", action="store_true", help="Also run each workflow via `sdt run ... --json`") - parser.add_argument("--env-profile", default=None) - parser.add_argument("--timeout-seconds", type=int, default=600) - parser.add_argument("--output-json", default=None, help="Write full report JSON to file") + _ = parser.add_argument("--repo-root", default=None, help="Repo root containing DevTool.csproj") + _ = parser.add_argument("--project-root", default=".", help="Project root containing devtool.json") + _ = parser.add_argument("--workflow", action="append", default=[], help="Workflow id to verify (repeatable)") + _ = parser.add_argument("--execute", action="store_true", help="Also run each workflow via `sdt run ... --json`") + _ = parser.add_argument("--env-profile", default=None) + _ = parser.add_argument("--timeout-seconds", type=int, default=600) + _ = parser.add_argument("--output-json", default=None, help="Write full report JSON to file") args = parser.parse_args() repo_root = resolve_repo_root(args.repo_root) @@ -235,10 +237,10 @@ def main() -> int: return 2 static_results = [static_check_workflow(project_root, w) for w in workflows] - execute_results: List[dict] = [] + execute_results: list[dict[str, Any]] = [] if args.execute: for w in workflows: - wid = w["id"] + wid = str(w["id"]) execute_results.append( execute_check_workflow( repo_root=repo_root, @@ -252,7 +254,7 @@ def main() -> int: static_failures = [r for r in static_results if not r["ok"]] exec_failures = [r for r in execute_results if not r["ok"]] - report = { + report: dict[str, Any] = { "repoRoot": str(repo_root), "projectRoot": str(project_root), "totalWorkflows": len(workflows), @@ -283,13 +285,13 @@ def main() -> int: if static_failures: print("\nStatic failures:") - for f in static_failures: - print(f"- {f['workflowId']}: {', '.join(f['issues'])}") + for sf in static_failures: + print(f"- {sf['workflowId']}: {', '.join(sf['issues'])}") if exec_failures: print("\nExecution failures:") - for f in exec_failures: - print(f"- {f['workflowId']}: stopReason={f.get('stopReason')} message={f.get('message')}") + for ef in exec_failures: + print(f"- {ef['workflowId']}: stopReason={ef.get('stopReason')} message={ef.get('message')}") return 1 if static_failures or exec_failures else 0 diff --git a/src/DevTool.Engine/Config/ConfigBootstrapper.cs b/src/DevTool.Engine/Config/ConfigBootstrapper.cs index d9a8a3d..e558756 100644 --- a/src/DevTool.Engine/Config/ConfigBootstrapper.cs +++ b/src/DevTool.Engine/Config/ConfigBootstrapper.cs @@ -46,6 +46,16 @@ public static class ConfigBootstrapper WriteIndented = true }; + private static readonly HashSet KnownScriptHelpers = new(StringComparer.OrdinalIgnoreCase) + { + "publish-sidecar.py", + "publish-app.py", + "publish-webgateway.py", + "publish-output.py", + "sync-output.py", + "run-webgateway.py", + }; + public static BootstrapScanResult Scan(string startDir) { var root = FindProjectRoot(startDir); @@ -166,12 +176,14 @@ public static class ConfigBootstrapper rootHints.Add("scripts"); } + var projectName = new DirectoryInfo(root).Name; + if (rootHints.Count == 0) - rootHints.Add("devtool.json"); + rootHints.Add($"sdtconfig-{projectName}.json"); return new BootstrapScanResult( ProjectRoot: root, - ProjectName: new DirectoryInfo(root).Name, + ProjectName: projectName, ProjectType: InferProjectType(toolFamilies), ToolFamilies: toolFamilies.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(), DotnetWorkingDir: dotnetWorkingDir, @@ -299,11 +311,12 @@ public static class ConfigBootstrapper public static string ToJson(DevToolConfig config) => JsonSerializer.Serialize(config, JsonOptions) + Environment.NewLine; - public static string WriteDefaultConfig(string projectRoot, DevToolConfig config, bool overwrite = false) + public static string WriteDefaultConfig(string projectRoot, DevToolConfig config, bool overwrite = false, bool legacyNaming = false) { - var path = Path.Combine(projectRoot, "devtool.json"); + var fileName = legacyNaming ? "devtool.json" : $"sdtconfig-{config.Name}.json"; + var path = Path.Combine(projectRoot, fileName); if (File.Exists(path) && !overwrite) - throw new InvalidOperationException($"devtool.json already exists at {path}"); + throw new InvalidOperationException($"{fileName} already exists at {path}"); File.WriteAllText(path, ToJson(config)); return path; @@ -314,14 +327,14 @@ public static class ConfigBootstrapper 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")) + if (scripts.ContainsKey("publish-sidecar.py") || + scripts.ContainsKey("publish-app.py") || + scripts.ContainsKey("publish-webgateway.py") || + scripts.ContainsKey("publish-output.py") || + scripts.ContainsKey("sync-output.py") || + scripts.ContainsKey("run-webgateway.py")) { - foreach (var workflow in BuildScriptDrivenWorkflows(scripts)) + foreach (var workflow in BuildScriptDrivenWorkflows(scripts, scan.NodeWorkingDir)) yield return workflow; } @@ -554,32 +567,76 @@ public static class ConfigBootstrapper }; } - private static HashSet DetectScriptHelpers(string projectRoot) + private static Dictionary DetectScriptHelpers(string projectRoot) { - var scriptsDir = Path.Combine(projectRoot, "scripts"); - if (!Directory.Exists(scriptsDir)) - return new HashSet(StringComparer.OrdinalIgnoreCase); + var candidates = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var file in EnumerateFilesBounded(projectRoot, "*.py", MaxScanDepth)) + { + var fileName = Path.GetFileName(file); + if (string.IsNullOrWhiteSpace(fileName) || !KnownScriptHelpers.Contains(fileName)) + continue; - return Directory.EnumerateFiles(scriptsDir, "*.py", SearchOption.TopDirectoryOnly) - .Select(Path.GetFileName) - .Where(f => !string.IsNullOrWhiteSpace(f)) - .Cast() - .ToHashSet(StringComparer.OrdinalIgnoreCase); + var dir = Path.GetDirectoryName(file); + if (string.IsNullOrWhiteSpace(dir)) + continue; + + var fullDir = Path.GetFullPath(dir); + if (!candidates.TryGetValue(fullDir, out var list)) + { + list = []; + candidates[fullDir] = list; + } + list.Add(fileName); + } + + if (candidates.Count == 0) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var selectedDir = candidates + .OrderByDescending(kvp => kvp.Value.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + .ThenBy(kvp => + { + var rel = Path.GetRelativePath(projectRoot, kvp.Key); + return rel.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Count(part => !string.IsNullOrWhiteSpace(part) && part != "."); + }) + .ThenBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase) + .First().Key; + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var file in Directory.EnumerateFiles(selectedDir, "*.py", SearchOption.TopDirectoryOnly)) + { + var name = Path.GetFileName(file); + if (string.IsNullOrWhiteSpace(name) || !KnownScriptHelpers.Contains(name)) + continue; + result[name] = Path.GetRelativePath(projectRoot, file); + } + + return result; } - private static IEnumerable BuildScriptDrivenWorkflows(HashSet scripts) + private static IEnumerable BuildScriptDrivenWorkflows( + IReadOnlyDictionary scripts, + string? nodeWorkingDir) { - static WorkflowStep ScriptStep(string id, string label, params string[] scriptArgs) => new() + static WorkflowStep ScriptStep(string id, string label, string scriptPath, params string[] extraArgs) { - Id = id, - Label = label, - Command = "python", - Args = scriptArgs.ToList(), - WorkingDir = ".", - Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }] - }; + var args = new List { scriptPath }; + args.AddRange(extraArgs); + return new WorkflowStep + { + Id = id, + Label = label, + Command = "python", + Args = args, + WorkingDir = ".", + Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }] + }; + } - if (scripts.Contains("publish-sidecar.py")) + var tauriConfigRequire = ResolveTauriConfigRequirePath(nodeWorkingDir); + + if (scripts.TryGetValue("publish-sidecar.py", out var publishSidecarPath)) { yield return new WorkflowDefinition { @@ -587,11 +644,12 @@ public static class ConfigBootstrapper Label = "Publish Sidecar", Description = "Publish sidecar service", Group = "Build", - Steps = [ScriptStep("sidecar:run", "python scripts/publish-sidecar.py", "scripts/publish-sidecar.py")] + Steps = [ScriptStep("sidecar:run", $"python {publishSidecarPath}", publishSidecarPath)], + RequireFiles = [publishSidecarPath] }; } - if (scripts.Contains("publish-app.py")) + if (scripts.TryGetValue("publish-app.py", out var publishAppPath)) { yield return new WorkflowDefinition { @@ -601,8 +659,9 @@ public static class ConfigBootstrapper Group = "Build", Steps = [ - ScriptStep("web:run", "python scripts/publish-app.py --target web", "scripts/publish-app.py", "--target", "web") - ] + ScriptStep("web:run", $"python {publishAppPath} --target web", publishAppPath, "--target", "web") + ], + RequireFiles = [publishAppPath] }; yield return new WorkflowDefinition @@ -611,16 +670,17 @@ public static class ConfigBootstrapper Label = "Build Tauri Desktop App", Description = "Build desktop binary", Group = "Build", - DependsOn = scripts.Contains("publish-sidecar.py") ? ["sidecar"] : [], + DependsOn = scripts.ContainsKey("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") - ] + ScriptStep("tauri:run", $"python {publishAppPath} --target tauri --tauri-bundles none", + publishAppPath, "--target", "tauri", "--tauri-bundles", "none") + ], + RequireFiles = [publishAppPath, tauriConfigRequire] }; } - if (scripts.Contains("publish-webgateway.py")) + if (scripts.TryGetValue("publish-webgateway.py", out var publishWebgatewayPath)) { yield return new WorkflowDefinition { @@ -628,12 +688,13 @@ public static class ConfigBootstrapper 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")] + DependsOn = scripts.ContainsKey("publish-app.py") ? ["web"] : [], + Steps = [ScriptStep("webgateway:run", $"python {publishWebgatewayPath}", publishWebgatewayPath)], + RequireFiles = [publishWebgatewayPath] }; } - if (scripts.Contains("sync-output.py")) + if (scripts.TryGetValue("sync-output.py", out var syncOutputPath)) { yield return new WorkflowDefinition { @@ -641,11 +702,12 @@ public static class ConfigBootstrapper 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")] + Steps = [ScriptStep("sync-output:run", $"python {syncOutputPath}", syncOutputPath)], + RequireFiles = [syncOutputPath] }; } - if (scripts.Contains("publish-output.py")) + if (scripts.TryGetValue("publish-output.py", out var publishOutputPath)) { yield return new WorkflowDefinition { @@ -653,11 +715,12 @@ public static class ConfigBootstrapper 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")] + Steps = [ScriptStep("stage-output:run", $"python {publishOutputPath}", publishOutputPath)], + RequireFiles = [publishOutputPath] }; } - if (scripts.Contains("run-webgateway.py")) + if (scripts.TryGetValue("run-webgateway.py", out var runWebgatewayPath)) { yield return new WorkflowDefinition { @@ -667,13 +730,21 @@ public static class ConfigBootstrapper Group = "Dev", Steps = [ - ScriptStep("run-gateway-dev:run", "python scripts/run-webgateway.py --mode Dev", - "scripts/run-webgateway.py", "--mode", "Dev") - ] + ScriptStep("run-gateway-dev:run", $"python {runWebgatewayPath} --mode Dev", + runWebgatewayPath, "--mode", "Dev") + ], + RequireFiles = [runWebgatewayPath] }; } } + private static string ResolveTauriConfigRequirePath(string? nodeWorkingDir) + { + if (string.IsNullOrWhiteSpace(nodeWorkingDir) || nodeWorkingDir == ".") + return "src-tauri/tauri.conf.json"; + return Path.Combine(nodeWorkingDir, "src-tauri", "tauri.conf.json").Replace('\\', '/'); + } + private static string FindProjectRoot(string startDir) { var start = Path.GetFullPath(startDir); @@ -786,6 +857,18 @@ public static class ConfigBootstrapper if (candidates.Count == 0) return null; + // Prefer app roots that include a Tauri config when available. + foreach (var candidate in candidates) + { + if (!HasNodeScripts(candidate)) + continue; + var dir = Path.GetDirectoryName(candidate); + if (string.IsNullOrWhiteSpace(dir)) + continue; + if (IsTauriNodeRoot(dir)) + return candidate; + } + foreach (var candidate in candidates) { if (HasNodeScripts(candidate)) @@ -822,6 +905,12 @@ public static class ConfigBootstrapper } } + private static bool IsTauriNodeRoot(string nodeDir) + { + return File.Exists(Path.Combine(nodeDir, "src-tauri", "tauri.conf.json")) || + File.Exists(Path.Combine(nodeDir, "tauri.conf.json")); + } + private static IEnumerable EnumerateFilesBounded(string root, string pattern, int maxDepth) { var queue = new Queue<(string Dir, int Depth)>(); diff --git a/src/DevTool.Engine/Config/ConfigLoader.cs b/src/DevTool.Engine/Config/ConfigLoader.cs index 507c9d6..9f588c4 100644 --- a/src/DevTool.Engine/Config/ConfigLoader.cs +++ b/src/DevTool.Engine/Config/ConfigLoader.cs @@ -28,7 +28,7 @@ public static class ConfigLoader }; /// - /// Walks up from (or CWD) until it finds devtool.json. + /// Walks up from (or CWD) until it finds sdtconfig-*.json or devtool.json. /// Returns null if not found. /// public static string? FindConfigPath(string? startDir = null) @@ -36,10 +36,14 @@ public static class ConfigLoader var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory()); while (dir is not null) { + var sdtCandidates = dir.GetFiles("sdtconfig-*.json"); + if (sdtCandidates.Length > 0) + return sdtCandidates[0].FullName; + var candidate = Path.Combine(dir.FullName, "devtool.json"); if (File.Exists(candidate)) return candidate; - dir = dir.Parent!; + dir = dir.Parent; } return null; } @@ -76,7 +80,7 @@ public static class ConfigLoader 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. " + + "Use migration preview file 'devtool.generated.workflows.json' and migrate your config. " + "Temporary rollback: set SDT_LEGACY_MODE=compat."); } @@ -87,7 +91,7 @@ public static class ConfigLoader catch (Exception ex) { throw new InvalidOperationException( - $"Failed to parse devtool.json at {configPath}: {ex.Message}", ex); + $"Failed to parse config at {configPath}: {ex.Message}", ex); } } @@ -102,7 +106,7 @@ public static class ConfigLoader var json = File.ReadAllText(configPath); var config = JsonSerializer.Deserialize(json, JsonOptions) - ?? throw new InvalidOperationException("devtool.json deserialized to null."); + ?? throw new InvalidOperationException("Config deserialized to null."); if (config.Targets.Count == 0) return new LegacyMigrationApplyResult(false, "No legacy targets found to migrate.", ConfigPath: configPath); @@ -200,7 +204,7 @@ public static class ConfigLoader private static DevToolConfig DeserializeConfig(JsonObject obj, string sourcePath) { return obj.Deserialize(JsonOptions) - ?? throw new InvalidOperationException($"devtool.json at {sourcePath} deserialized to null."); + ?? throw new InvalidOperationException($"Config at {sourcePath} deserialized to null."); } private static JsonObject MergeObjects(JsonObject baseObj, JsonObject overlayObj) diff --git a/src/DevTool.Engine/Config/DevToolConfig.cs b/src/DevTool.Engine/Config/DevToolConfig.cs index 29d996a..272b1cd 100644 --- a/src/DevTool.Engine/Config/DevToolConfig.cs +++ b/src/DevTool.Engine/Config/DevToolConfig.cs @@ -67,6 +67,7 @@ public sealed class WorkflowDefinition public string Description { get; init; } = ""; public string Group { get; init; } = "General"; public List DependsOn { get; init; } = []; + public List RequireFiles { get; init; } = []; public List Steps { get; init; } = []; } @@ -100,6 +101,9 @@ public enum InstallPolicy public sealed class ToolingConfig { + [JsonConverter(typeof(JsonStringEnumConverter))] + public InstallPolicy DefaultInstallPolicy { get; init; } = InstallPolicy.Prompt; + public List Tools { get; init; } = []; } diff --git a/src/DevTool.Engine/Config/WorkspaceConfig.cs b/src/DevTool.Engine/Config/WorkspaceConfig.cs index 130d012..61e7504 100644 --- a/src/DevTool.Engine/Config/WorkspaceConfig.cs +++ b/src/DevTool.Engine/Config/WorkspaceConfig.cs @@ -15,7 +15,7 @@ public sealed class WorkspaceProject /// /// Relative or absolute path to the project root - /// (the directory containing devtool.json). + /// (the directory containing SDT project config: sdtconfig-*.json or devtool.json). /// public string Path { get; init; } = ""; public List Tags { get; init; } = []; @@ -65,5 +65,6 @@ public sealed class WorkspaceInventorySettings "*.sln", "*.csproj", "devtool.json", + "sdtconfig-*.json", ]; } diff --git a/src/DevTool.Engine/Config/WorkspaceInventoryService.cs b/src/DevTool.Engine/Config/WorkspaceInventoryService.cs index 1cbdf3b..c345607 100644 --- a/src/DevTool.Engine/Config/WorkspaceInventoryService.cs +++ b/src/DevTool.Engine/Config/WorkspaceInventoryService.cs @@ -143,6 +143,13 @@ public sealed class WorkspaceInventoryService : IWorkspaceInventoryService continue; } + if (string.Equals(marker, "sdtconfig-*.json", StringComparison.OrdinalIgnoreCase)) + { + if (Directory.EnumerateFiles(dir, "sdtconfig-*.json", SearchOption.TopDirectoryOnly).Any()) + kinds.Add(WorkspaceInventoryKind.Devtool); + continue; + } + if (string.Equals(marker, "*.slnx", StringComparison.OrdinalIgnoreCase)) { if (Directory.EnumerateFiles(dir, "*.slnx", SearchOption.TopDirectoryOnly).Any()) @@ -245,7 +252,7 @@ public sealed class WorkspaceInventoryService : IWorkspaceInventoryService var warnings = new List(); if (!hasDevtool) - warnings.Add("No devtool.json found."); + warnings.Add("No SDT project config found."); if (full.Equals(currentRoot, StringComparison.OrdinalIgnoreCase)) warnings.Add("Current project root."); diff --git a/src/DevTool.Engine/Core/PrereqInstallerService.cs b/src/DevTool.Engine/Core/PrereqInstallerService.cs index b73f9c9..656cef1 100644 --- a/src/DevTool.Engine/Core/PrereqInstallerService.cs +++ b/src/DevTool.Engine/Core/PrereqInstallerService.cs @@ -144,6 +144,7 @@ public sealed class PrereqInstallerService : IPrereqInstaller "java" => isWindows ? new InstallCommand("winget", ["install", "Microsoft.OpenJDK.21"]) : new InstallCommand("sh", ["-c", "echo Install JDK via package manager"]), + "pyinstaller" => new InstallCommand(PythonResolver.ResolveExecutable(), ["-m", "pip", "install", "pyinstaller"]), _ => new InstallCommand("sh", ["-c", $"echo No installer template for '{tool}'"]), }; diff --git a/src/DevTool.Engine/Core/ProjectScaffolder.cs b/src/DevTool.Engine/Core/ProjectScaffolder.cs new file mode 100644 index 0000000..5d0129d --- /dev/null +++ b/src/DevTool.Engine/Core/ProjectScaffolder.cs @@ -0,0 +1,247 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Sdt.Config; +using Sdt.Runner; + +namespace Sdt.Core; + +public sealed record ScaffoldingOptions( + string ProjectName, + string Backend, + string Frontend +); + +public sealed class ProjectScaffolder +{ + private readonly ActionRunner _runner = new ActionRunner(); + + public async Task ScaffoldAsync(string projectRoot, ScaffoldingOptions options, Action onOutput) + { + onOutput($"Starting scaffolding for {options.ProjectName}...", false); + + var srcDir = Path.Combine(projectRoot, "src"); + var testDir = Path.Combine(projectRoot, "tests"); + Directory.CreateDirectory(srcDir); + Directory.CreateDirectory(testDir); + + if (options.Backend == "C# (.NET)") + { + await ScaffoldCSharpBackendAsync(projectRoot, options.ProjectName, onOutput); + } + else if (options.Backend == "Python") + { + await ScaffoldPythonBackendAsync(projectRoot, options.ProjectName, onOutput); + } + + if (options.Frontend == "Tauri") + { + await ScaffoldTauriFrontendAsync(projectRoot, options.ProjectName, onOutput); + } + else if (options.Frontend == "Web UI") + { + await ScaffoldWebFrontendAsync(projectRoot, options.ProjectName, onOutput); + } + else if (options.Frontend == "PyQt") + { + await ScaffoldPyQtFrontendAsync(projectRoot, options.ProjectName, onOutput); + } + else if (options.Frontend == "Avalonia") + { + await ScaffoldAvaloniaFrontendAsync(projectRoot, options.ProjectName, onOutput); + } + + onOutput("Generating initial SDT project configuration...", false); + try + { + var scanResult = ConfigBootstrapper.Scan(projectRoot); + var config = ConfigBootstrapper.BuildDefaultConfig(scanResult); + ConfigBootstrapper.WriteDefaultConfig(projectRoot, config, overwrite: true); + onOutput($"Generated initial project configuration at {projectRoot}", false); + } + catch (Exception ex) + { + onOutput($"WARNING: Failed to generate initial SDT config: {ex.Message}", true); + } + + onOutput("Scaffolding completed successfully.", false); + return true; + } + + private async Task ScaffoldCSharpBackendAsync(string projectRoot, string projectName, Action onOutput) + { + onOutput("Scaffolding C# Backend...", false); + var srcDir = Path.Combine(projectRoot, "src", projectName); + var testDir = Path.Combine(projectRoot, "tests", $"{projectName}.Tests"); + Directory.CreateDirectory(srcDir); + Directory.CreateDirectory(testDir); + + // Run dotnet new sln + await RunCommandAsync("dotnet", ["new", "sln", "-n", projectName], projectRoot, onOutput); + + // Run dotnet new webapi + await RunCommandAsync("dotnet", ["new", "webapi", "-n", projectName, "-o", srcDir], projectRoot, onOutput); + + // Run dotnet new xunit + await RunCommandAsync("dotnet", ["new", "xunit", "-n", $"{projectName}.Tests", "-o", testDir], projectRoot, onOutput); + + // Add to sln + await RunCommandAsync("dotnet", ["sln", "add", srcDir], projectRoot, onOutput); + await RunCommandAsync("dotnet", ["sln", "add", testDir], projectRoot, onOutput); + + // Run dotnet restore + onOutput("Running dotnet restore...", false); + await RunCommandAsync("dotnet", ["restore"], projectRoot, onOutput); + } + + private async Task ScaffoldPythonBackendAsync(string projectRoot, string projectName, Action onOutput) + { + onOutput("Scaffolding Python Backend...", false); + var srcName = projectName.ToLowerInvariant().Replace(" ", "_").Replace("-", "_"); + var pkgDir = Path.Combine(projectRoot, "src", srcName); + Directory.CreateDirectory(pkgDir); + File.WriteAllText(Path.Combine(pkgDir, "__init__.py"), ""); + File.WriteAllText(Path.Combine(pkgDir, "main.py"), "def main():\n print('Hello from Python backend!')\n\nif __name__ == '__main__':\n main()\n"); + + var testDir = Path.Combine(projectRoot, "tests"); + File.WriteAllText(Path.Combine(testDir, "__init__.py"), ""); + File.WriteAllText(Path.Combine(testDir, "test_main.py"), "def test_basic():\n assert True\n"); + + var pyproject = $@" +[project] +name = ""{srcName}"" +version = ""0.1.0"" +description = ""A Python project"" +dependencies = [] + +[build-system] +requires = [""setuptools>=61.0""] +build-backend = ""setuptools.build_meta"" +"; + File.WriteAllText(Path.Combine(projectRoot, "pyproject.toml"), pyproject.Trim()); + File.WriteAllText(Path.Combine(projectRoot, "requirements.txt"), "pytest\n"); + + onOutput("Creating Python virtual environment...", false); + await RunCommandAsync("python", ["-m", "venv", ".venv"], projectRoot, onOutput); + } + + private async Task ScaffoldTauriFrontendAsync(string projectRoot, string projectName, Action onOutput) + { + onOutput("Scaffolding Tauri Frontend...", false); + var cmd = OperatingSystem.IsWindows() ? "npm.cmd" : "npm"; + // To be completely non-interactive, there is unfortunately no great unified tauri skeleton that is purely args without prompts across all versions safely, so we might just create a vite app and add tauri, or use create-tauri-app + // Using `npm create vite@latest frontend -- --template react-ts` then adding tauri is safest + await RunCommandAsync(cmd, ["create", "vite@latest", "frontend", "--", "--template", "react-ts"], projectRoot, onOutput); + + var frontendDir = Path.Combine(projectRoot, "frontend"); + await RunCommandAsync(cmd, ["install"], frontendDir, onOutput); + await RunCommandAsync(cmd, ["install", "@tauri-apps/api", "@tauri-apps/plugin-core"], frontendDir, onOutput); + + // NOTE: We don't fully initialize tauri here to avoid interactive prompts from `tauri init`. + // A minimal tauri.conf.json could be dumped directly. + Directory.CreateDirectory(Path.Combine(frontendDir, "src-tauri")); + var tauriConf = @"{ + ""build"": { + ""beforeDevCommand"": ""npm run dev"", + ""beforeBuildCommand"": ""npm run build"", + ""devUrl"": ""http://localhost:5173"", + ""frontendDist"": ""../dist"" + }, + ""productName"": """ + projectName + @""", + ""version"": ""0.1.0"" +}"; + File.WriteAllText(Path.Combine(frontendDir, "src-tauri", "tauri.conf.json"), tauriConf); + } + + private async Task ScaffoldWebFrontendAsync(string projectRoot, string projectName, Action onOutput) + { + onOutput("Scaffolding Web Frontend (React+Vite)...", false); + var cmd = OperatingSystem.IsWindows() ? "npm.cmd" : "npm"; + await RunCommandAsync(cmd, ["create", "vite@latest", "frontend", "--", "--template", "react-ts"], projectRoot, onOutput); + + onOutput("Running npm install...", false); + await RunCommandAsync(cmd, ["install"], Path.Combine(projectRoot, "frontend"), onOutput); + } + + private async Task ScaffoldPyQtFrontendAsync(string projectRoot, string projectName, Action onOutput) + { + onOutput("Scaffolding PyQt Frontend...", false); + var reqPath = Path.Combine(projectRoot, "requirements.txt"); + if (File.Exists(reqPath)) + { + var reqs = File.ReadAllText(reqPath); + if (!reqs.Contains("PyQt6")) + File.AppendAllText(reqPath, "\nPyQt6\n"); + } + else + { + File.WriteAllText(reqPath, "PyQt6\n"); + } + + var srcName = projectName.ToLowerInvariant().Replace(" ", "_").Replace("-", "_"); + var pkgDir = Path.Combine(projectRoot, "src", srcName); + Directory.CreateDirectory(pkgDir); + + var code = @" +import sys +from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel + +def launch_gui(): + app = QApplication(sys.argv) + window = QMainWindow() + window.setWindowTitle('" + projectName + @"') + window.setCentralWidget(QLabel('Hello from PyQt!')) + window.show() + sys.exit(app.exec()) + +if __name__ == '__main__': + launch_gui() +"; + File.WriteAllText(Path.Combine(pkgDir, "gui.py"), code.Trim()); + } + + private async Task ScaffoldAvaloniaFrontendAsync(string projectRoot, string projectName, Action onOutput) + { + onOutput("Scaffolding Avalonia Frontend...", false); + var srcDir = Path.Combine(projectRoot, "src", $"{projectName}.Desktop"); + Directory.CreateDirectory(srcDir); + + // Ensure templates are installed + await RunCommandAsync("dotnet", ["new", "install", "Avalonia.Templates"], projectRoot, onOutput); + + await RunCommandAsync("dotnet", ["new", "avalonia.app", "-n", $"{projectName}.Desktop", "-o", srcDir], projectRoot, onOutput); + + await RunCommandAsync("dotnet", ["sln", "add", srcDir], projectRoot, onOutput); + + onOutput("Running dotnet restore...", false); + await RunCommandAsync("dotnet", ["restore", srcDir], projectRoot, onOutput); + } + + private async Task RunCommandAsync(string command, string[] args, string workingDir, Action onOutput) + { + try + { + var step = new WorkflowStep + { + Command = command, + Args = args.ToList(), + WorkingDir = "." // Command runner will combine this with projectRoot + }; + + var result = await _runner.RunStepAsync( + step, + workingDir, + onOutput); + + if (result.ExitCode != 0) + { + onOutput($"WARNING: Command {command} exitted with code {result.ExitCode}", true); + } + } + catch (Exception ex) + { + onOutput($"ERROR running {command}: {ex.Message}", true); + } + } +} diff --git a/src/DevTool.Engine/Core/RequirementResolver.cs b/src/DevTool.Engine/Core/RequirementResolver.cs index 5151806..e0936f8 100644 --- a/src/DevTool.Engine/Core/RequirementResolver.cs +++ b/src/DevTool.Engine/Core/RequirementResolver.cs @@ -52,7 +52,7 @@ public sealed class RequirementResolver : IRequirementResolver "npm" => [Req("node"), Req("npm")], "pnpm" => [Req("node"), Req("pnpm")], "yarn" => [Req("node"), Req("yarn")], - "python" or "py" => [Req("python")], + "python" or "py" => InferPythonRequirements(args), "cargo" => [Req("cargo")], "tauri" => [Req("cargo"), Req("node"), Req("npm")], "git" => [Req("git")], @@ -65,6 +65,18 @@ public sealed class RequirementResolver : IRequirementResolver }; } + private static List InferPythonRequirements(IReadOnlyList args) + { + var result = new List { Req("python") }; + if (args.Any(a => string.Equals(a, "PyInstaller", StringComparison.OrdinalIgnoreCase) || + a.EndsWith("pyinstaller", StringComparison.OrdinalIgnoreCase) || + a.EndsWith("pyinstaller.exe", StringComparison.OrdinalIgnoreCase))) + { + result.Add(Req("pyinstaller")); + } + return result; + } + private static ToolRequirement Req(string tool) => new() { Tool = tool, diff --git a/src/DevTool.Engine/Core/ToolProbeService.cs b/src/DevTool.Engine/Core/ToolProbeService.cs index b91f35b..8bd2be1 100644 --- a/src/DevTool.Engine/Core/ToolProbeService.cs +++ b/src/DevTool.Engine/Core/ToolProbeService.cs @@ -75,6 +75,7 @@ public sealed class ToolProbeService : IToolProbe var command = tool.ToLowerInvariant() switch { "python" => PythonResolver.ResolveExecutable(), + "pyinstaller" => PythonResolver.ResolveExecutable(), "dotnet" => "dotnet", "node" => "node", "npm" => "npm", @@ -91,7 +92,7 @@ public sealed class ToolProbeService : IToolProbe var resolution = CommandResolver.ResolveWithTrace(command, config, tool); command = resolution.Resolved; - var versionArg = command is "python" ? "--version" : "--version"; + var versionArg = tool.ToLowerInvariant() == "pyinstaller" ? "-m PyInstaller --version" : "--version"; try { var psi = new ProcessStartInfo @@ -105,9 +106,21 @@ public sealed class ToolProbeService : IToolProbe if (envOverrides is not null) { foreach (var kvp in envOverrides) + { psi.Environment[kvp.Key] = kvp.Value; + } + } + + if (tool.ToLowerInvariant() == "pyinstaller") + { + psi.ArgumentList.Add("-m"); + psi.ArgumentList.Add("PyInstaller"); + psi.ArgumentList.Add("--version"); + } + else + { + psi.ArgumentList.Add(versionArg); } - psi.ArgumentList.Add(versionArg); using var process = new Process { StartInfo = psi }; process.Start(); diff --git a/src/DevTool.Engine/Core/WorkflowExecutor.cs b/src/DevTool.Engine/Core/WorkflowExecutor.cs index cbf35ae..7a586b6 100644 --- a/src/DevTool.Engine/Core/WorkflowExecutor.cs +++ b/src/DevTool.Engine/Core/WorkflowExecutor.cs @@ -1,4 +1,5 @@ using Sdt.Config; +using Sdt.Runner; namespace Sdt.Core; @@ -222,12 +223,73 @@ public sealed class WorkflowExecutor( } } - var runResult = await _actionRunner.RunStepAsync( - step, - projectRoot, - onOutput, - envOverrides, - cancellationToken).ConfigureAwait(false); + RunResult runResult; + int retryCount = 0; + const int MaxRetries = 1; + + while (true) + { + var capturedOutput = new List(); + runResult = await _actionRunner.RunStepAsync( + step, + projectRoot, + (line, isErr) => + { + onOutput(line, isErr); + if (isErr) capturedOutput.Add(line); + }, + envOverrides, + cancellationToken).ConfigureAwait(false); + + if (runResult.Success || retryCount >= MaxRetries) + break; + + var missingTool = DetectMissingToolFromOutput(capturedOutput); + if (missingTool == null) + break; + + var policy = config.Tooling?.DefaultInstallPolicy ?? InstallPolicy.Prompt; + if (policy == InstallPolicy.Never) + { + onOutput($"Auto-recovery for '{missingTool}' skipped due to InstallPolicy.Never.", false); + break; + } + + onOutput($"Detected missing tool '{missingTool}' during execution. {(policy == InstallPolicy.Auto ? "Auto-installing..." : "Prompting for installation...")}", false); + + var installPlan = await _installer.GetInstallPlanAsync(missingTool, projectRoot, config, cancellationToken).ConfigureAwait(false); + if (!installPlan.Supported || installPlan.Commands.Count == 0) + { + onOutput($"No installer plan available for auto-recovery of '{missingTool}'.", true); + break; + } + + var approved = policy == InstallPolicy.Auto + ? true + : await confirmInstallAsync(missingTool, installPlan).ConfigureAwait(false); + if (!approved) + break; + + bool installSuccess = true; + foreach (var installCommand in installPlan.Commands) + { + var installResult = await _installer.RunInstallAsync(installCommand, projectRoot, onOutput, envOverrides, cancellationToken).ConfigureAwait(false); + if (!installResult.Success) + { + installSuccess = false; + break; + } + } + + if (!installSuccess) + { + onOutput($"Auto-recovery install failed for '{missingTool}'.", true); + break; + } + + onOutput($"Auto-recovery successful for '{missingTool}'. Retrying step '{step.Label}'...", false); + retryCount++; + } results.Add(new WorkflowStepResult( workflow.Id, @@ -273,4 +335,32 @@ public sealed class WorkflowExecutor( Message: "Workflow completed successfully.", Steps: results); } + + private static string? DetectMissingToolFromOutput(List output) + { + foreach (var line in output) + { + // Python: No module named 'PyInstaller' + if (line.Contains("No module named", StringComparison.OrdinalIgnoreCase)) + { + var match = System.Text.RegularExpressions.Regex.Match(line, "No module named '?(\\w+)'?", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (match.Success) return match.Groups[1].Value.ToLowerInvariant(); + } + + // Windows: 'cargo' is not recognized as an internal or external command + if (line.Contains("is not recognized as an internal or external command", StringComparison.OrdinalIgnoreCase)) + { + var match = System.Text.RegularExpressions.Regex.Match(line, "'([^']+)' is not recognized", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (match.Success) return match.Groups[1].Value.ToLowerInvariant(); + } + + // Linux: cargo: command not found + if (line.Contains("command not found", StringComparison.OrdinalIgnoreCase)) + { + var match = System.Text.RegularExpressions.Regex.Match(line, "([^\\s:]+):\\s*command not found", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (match.Success) return match.Groups[1].Value.ToLowerInvariant(); + } + } + return null; + } } diff --git a/src/DevTool.Host.Bridge/BridgeStdioServer.cs b/src/DevTool.Host.Bridge/BridgeStdioServer.cs index aed771f..4178747 100644 --- a/src/DevTool.Host.Bridge/BridgeStdioServer.cs +++ b/src/DevTool.Host.Bridge/BridgeStdioServer.cs @@ -150,7 +150,7 @@ public sealed class BridgeStdioServer if (!Directory.Exists(absoluteCandidate)) throw new BridgeValidationException($"Candidate path does not exist: {absoluteCandidate}"); - if (initConfig && !File.Exists(Path.Combine(absoluteCandidate, "devtool.json"))) + if (initConfig && ConfigLoader.FindConfigPath(absoluteCandidate) is null) { var scan = ConfigBootstrapper.Scan(absoluteCandidate); var generated = ConfigBootstrapper.BuildDefaultConfig(scan); @@ -632,7 +632,7 @@ public sealed class BridgeStdioServer private LoadedProjectConfig LoadProject(JsonElement @params) { var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); - return ConfigLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No devtool.json found for project."); + return ConfigLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No project config found for project."); } private string ResolveProjectRootForProjectScopedMethod(JsonElement @params) @@ -684,12 +684,12 @@ public sealed class BridgeStdioServer { var path = ConfigLoader.FindConfigPath(projectRoot); if (string.IsNullOrWhiteSpace(path)) - return new LegacyMigrationApplyResult(false, "Could not find devtool.json for saving."); + return new LegacyMigrationApplyResult(false, "Could not find project config for saving."); var backup = path + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}"; File.Copy(path, backup, overwrite: false); File.WriteAllText(path, ConfigBootstrapper.ToJson(config)); - return new LegacyMigrationApplyResult(true, "Saved updated devtool.json.", BackupPath: backup, ConfigPath: path); + return new LegacyMigrationApplyResult(true, "Saved updated project config.", BackupPath: backup, ConfigPath: path); } catch (Exception ex) { diff --git a/src/DevTool.Host.Tui/Tui/App.cs b/src/DevTool.Host.Tui/Tui/App.cs index 198970f..4f4b4b4 100644 --- a/src/DevTool.Host.Tui/Tui/App.cs +++ b/src/DevTool.Host.Tui/Tui/App.cs @@ -51,7 +51,7 @@ public sealed class App _startupWorkflowId = startupWorkflowId; _activeEnvProfile = config.EnvProfiles?.Active; var normalized = WorkflowModelBuilder.Normalize(config, ResolveLegacyMode(), _requirementResolver); - _workflows = normalized.Workflows.ToList(); + _workflows = normalized.Workflows.Where(IsWorkflowApplicable).ToList(); _warnings = []; _debugRunner = new DebugProfileRunner(new ToolProbeService(), new PrereqInstallerService()); _diagnostics = new DiagnosticsBundleService(); @@ -60,6 +60,30 @@ public sealed class App _warnings.AddRange(normalized.Warnings); } + private bool IsWorkflowApplicable(WorkflowDefinition workflow) + { + if (workflow.RequireFiles is null || workflow.RequireFiles.Count == 0) + return true; + + foreach (var req in workflow.RequireFiles) + { + var path = Path.GetFullPath(Path.Combine(_projectRoot, req)); + if (!File.Exists(path) && !Directory.Exists(path)) + { + if (req.Contains('*')) + { + var dir = Path.GetDirectoryName(path); + if (string.IsNullOrWhiteSpace(dir)) dir = _projectRoot; + var file = Path.GetFileName(req); + if (Directory.Exists(dir) && Directory.EnumerateFileSystemEntries(dir, file).Any()) + continue; + } + return false; + } + } + return true; + } + private static LegacyMode ResolveLegacyMode() { var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE"); @@ -364,7 +388,7 @@ public sealed class App if (_config.Targets.Count > 0) systemItems.Insert(0, new MenuItem( - $"[{Theme.Green}]⇪ Migrate legacy targets → workflows[/] [{Theme.GreenDim}]writes devtool.json + backup[/]", + $"[{Theme.Green}]⇪ Migrate legacy targets → workflows[/] [{Theme.GreenDim}]writes project config + backup[/]", "__migrate_legacy__")); systemItems.Add(new MenuItem($"[{Theme.GreenDim}]✗ Quit[/]", "__quit__")); diff --git a/src/DevTool.Host.Tui/Tui/SetupWizardScreen.cs b/src/DevTool.Host.Tui/Tui/SetupWizardScreen.cs index 90e7de5..a596732 100644 --- a/src/DevTool.Host.Tui/Tui/SetupWizardScreen.cs +++ b/src/DevTool.Host.Tui/Tui/SetupWizardScreen.cs @@ -26,6 +26,37 @@ public sealed class SetupWizardScreen Emit(new RunEvent("setup", RunEventType.WorkflowStarted, "Setup wizard started.")); Emit(new RunEvent("setup", RunEventType.WorkflowPlanned, "Setup lifecycle initialized.")); + if (!nonInteractive) + { + bool isNewProject = true; + try + { + isNewProject = !Directory.EnumerateFileSystemEntries(projectRoot) + .Any(x => !x.EndsWith(".sdt") && !x.EndsWith(".git") && !x.EndsWith("sdtconfig")); + } + catch {} + + var runWizard = false; + if (isNewProject) + { + runWizard = AnsiConsole.Confirm($"\n[{Theme.Amber}]This appears to be an empty directory. Would you like to scaffold a new project?[/]", defaultValue: true); + } + else + { + runWizard = AnsiConsole.Confirm($"\n[{Theme.Amber}]Would you like to scaffold a new project in this directory?[/]", defaultValue: false); + } + + if (runWizard) + { + await RunScaffoldingWizardAsync(projectRoot); + // Clear and re-render header because scaffolding can produce a lot of output + AnsiConsole.Clear(); + AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor)); + AnsiConsole.Write(new Rule($"[bold {Theme.GreenBold}]Setup Wizard[/]").RuleStyle(Theme.DimStyle)); + AnsiConsole.MarkupLine(Theme.Faint($"root: {projectRoot}") + "\n"); + } + } + AnsiConsole.MarkupLine(Theme.G("Step 1/4: Running config doctor...")); Emit(new RunEvent("setup", RunEventType.WorkflowStepStarted, "Running config doctor.", StepId: "setup:doctor")); var doctor = new ConfigDoctorService(new ToolProbeService(), new RequirementResolver()); @@ -120,7 +151,7 @@ public sealed class SetupWizardScreen var applyConfig = nonInteractive ? true : AnsiConsole.Confirm( - $"[{Theme.Amber}]Apply {update.Changes.Count} recommended config update(s) to devtool.json?[/]", + $"[{Theme.Amber}]Apply {update.Changes.Count} recommended update(s) to your project config?[/]", defaultValue: true); if (applyConfig) { @@ -151,6 +182,38 @@ public sealed class SetupWizardScreen return true; } + private async Task RunScaffoldingWizardAsync(string projectRoot) + { + AnsiConsole.Clear(); + AnsiConsole.Write(Theme.SectionRule("NEW PROJECT WIZARD")); + + var projectName = AnsiConsole.Ask($"[{Theme.Green}]Project Name:[/]", new DirectoryInfo(projectRoot).Name); + + var backend = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"[{Theme.Green}]Select Backend:[/]") + .AddChoices("None", "C# (.NET)", "Python")); + + var frontend = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"[{Theme.Green}]Select Frontend:[/]") + .AddChoices("None", "Web UI", "Tauri", "PyQt", "Avalonia")); + + var options = new ScaffoldingOptions(projectName, backend, frontend); + + var scaffolder = new ProjectScaffolder(); + await scaffolder.ScaffoldAsync(projectRoot, options, (line, isErr) => + { + var escaped = Markup.Escape(line); + AnsiConsole.MarkupLine(isErr + ? $"[{Theme.Amber}]{escaped}[/]" + : $"[{Theme.Faint}]{escaped}[/]"); + }); + + AnsiConsole.MarkupLine(Theme.Ok("Scaffolding finished. Proceeding to config doctor...")); + Thread.Sleep(1500); + } + private static Task ConfirmInstallAsync(string tool, InstallPlan plan) { if (RuntimePolicy.IsNonInteractive()) @@ -176,12 +239,12 @@ public sealed class SetupWizardScreen { var path = ConfigLoader.FindConfigPath(projectRoot); if (string.IsNullOrWhiteSpace(path)) - return new LegacyMigrationApplyResult(false, "Could not find devtool.json for saving."); + return new LegacyMigrationApplyResult(false, "Could not find project config for saving."); var backup = path + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}"; File.Copy(path, backup, overwrite: false); File.WriteAllText(path, ConfigBootstrapper.ToJson(config)); - return new LegacyMigrationApplyResult(true, "Saved updated devtool.json.", BackupPath: backup, ConfigPath: path); + return new LegacyMigrationApplyResult(true, "Saved updated project config.", BackupPath: backup, ConfigPath: path); } catch (Exception ex) { diff --git a/src/DevTool.Host.Tui/Tui/ToolchainScreen.cs b/src/DevTool.Host.Tui/Tui/ToolchainScreen.cs index 0477532..f762e80 100644 --- a/src/DevTool.Host.Tui/Tui/ToolchainScreen.cs +++ b/src/DevTool.Host.Tui/Tui/ToolchainScreen.cs @@ -23,7 +23,7 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot) var tc = _config.Toolchains; if (tc is null) { - AnsiConsole.MarkupLine(Theme.Warn("No toolchains configured in devtool.json.")); + AnsiConsole.MarkupLine(Theme.Warn("No toolchains configured in project config.")); AnsiConsole.MarkupLine(Theme.Faint("Add a \"toolchains\" section with \"python\" and/or \"node\" entries.")); AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back...")); Console.ReadKey(intercept: true); diff --git a/src/DevTool.Host.Tui/Tui/WorkspaceScreen.cs b/src/DevTool.Host.Tui/Tui/WorkspaceScreen.cs index 29ce6a3..5e94b0a 100644 --- a/src/DevTool.Host.Tui/Tui/WorkspaceScreen.cs +++ b/src/DevTool.Host.Tui/Tui/WorkspaceScreen.cs @@ -50,9 +50,9 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR foreach (var proj in projects) { var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj); - var devtoolPath = Path.Combine(absPath, "devtool.json"); + var configPath = ConfigLoader.FindConfigPath(absPath); var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase); - var exists = File.Exists(devtoolPath); + var exists = configPath is not null; var label = isCurrent ? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/] [{Theme.GreenDim}](current)[/]" @@ -61,7 +61,7 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR label += $" [{Theme.Amber}](disabled)[/]"; var desc = !exists - ? $" [{Theme.Red}]devtool.json not found[/]" + ? $" [{Theme.Red}]project config not found[/]" : string.IsNullOrWhiteSpace(proj.Description) ? $" [{Theme.GreenDim}]{Markup.Escape(absPath)}[/]" : $" [{Theme.GreenDim}]{Markup.Escape(proj.Description)}[/]"; @@ -160,7 +160,7 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR { var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj); var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase); - var hasConfig = File.Exists(Path.Combine(absPath, "devtool.json")); + var hasConfig = ConfigLoader.FindConfigPath(absPath) is not null; var status = proj.Disabled ? Theme.Warn("disabled") : hasConfig ? Theme.Ok("ready") : Theme.Fail("no config"); @@ -223,15 +223,17 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR return; } - var configPath = Path.Combine(absolutePath, "devtool.json"); - if (!File.Exists(configPath)) + var configPath = ConfigLoader.FindConfigPath(absolutePath); + if (configPath is null) { var create = AnsiConsole.Confirm( - $"[{Theme.Amber}]No devtool.json found. Create a minimal template?[/]", + $"[{Theme.Amber}]No project config found. Create a minimal template?[/]", defaultValue: true); if (!create) return; + var newConfigName = $"sdtconfig-{new DirectoryInfo(absolutePath).Name}.json"; + configPath = Path.Combine(absolutePath, newConfigName); File.WriteAllText(configPath, "{\n \"name\": \"SDT Project\",\n \"version\": \"0.1.0\",\n \"workflows\": []\n}\n"); } @@ -270,14 +272,14 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR return; } - var configPath = Path.Combine(absolutePath, "devtool.json"); - if (initializeConfig && !File.Exists(configPath)) + var configPath = ConfigLoader.FindConfigPath(absolutePath); + if (initializeConfig && configPath is null) { try { var scan = ConfigBootstrapper.Scan(absolutePath); var generated = ConfigBootstrapper.BuildDefaultConfig(scan); - ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false); + configPath = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false); } catch (Exception ex) { @@ -287,9 +289,9 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR } } - if (!File.Exists(configPath)) + if (configPath is null) { - AnsiConsole.MarkupLine(Theme.Warn("Candidate has no devtool.json yet. Use Add + initialize.")); + AnsiConsole.MarkupLine(Theme.Warn("Candidate has no project config yet. Use Add + initialize.")); Thread.Sleep(900); return; } diff --git a/tests/DevTool.Tests/ConfigBootstrapperTests.cs b/tests/DevTool.Tests/ConfigBootstrapperTests.cs index b0b9aa4..ed8b723 100644 --- a/tests/DevTool.Tests/ConfigBootstrapperTests.cs +++ b/tests/DevTool.Tests/ConfigBootstrapperTests.cs @@ -160,6 +160,24 @@ public sealed class ConfigBootstrapperTests Assert.Equal("AppB", scan.DotnetWorkingDir, ignoreCase: true); } + [Fact] + public void Scan_PrefersTauriPackageRoot_ForNodeWorkingDir() + { + var root = CreateTempDir(); + var workspacePkg = Path.Combine(root, "journal"); + var appPkg = Path.Combine(workspacePkg, "Journal.App"); + Directory.CreateDirectory(appPkg); + Directory.CreateDirectory(Path.Combine(appPkg, "src-tauri")); + + File.WriteAllText(Path.Combine(workspacePkg, "package.json"), """{ "name": "workspace", "scripts": { "build": "echo build" } }"""); + File.WriteAllText(Path.Combine(appPkg, "package.json"), """{ "name": "journal-app", "scripts": { "build": "echo build", "tauri": "echo tauri" } }"""); + File.WriteAllText(Path.Combine(appPkg, "src-tauri", "tauri.conf.json"), "{}"); + + var scan = ConfigBootstrapper.Scan(root); + + Assert.Equal(Path.Combine("journal", "Journal.App"), scan.NodeWorkingDir, ignoreCase: true); + } + private static string CreateTempDir() { var path = Path.Combine(Path.GetTempPath(), "sdt-bootstrap-" + Guid.NewGuid().ToString("N")); diff --git a/tests/DevTool.Tests/HeadlessExecutionTests.cs b/tests/DevTool.Tests/HeadlessExecutionTests.cs index 2a71268..fbbd7a4 100644 --- a/tests/DevTool.Tests/HeadlessExecutionTests.cs +++ b/tests/DevTool.Tests/HeadlessExecutionTests.cs @@ -180,7 +180,7 @@ public sealed class HeadlessExecutionTests { var root = Path.Combine(Path.GetTempPath(), "sdt-headless-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); - File.WriteAllText(Path.Combine(root, "devtool.json"), devtoolJson); + File.WriteAllText(Path.Combine(root, "sdtconfig-headless.json"), devtoolJson); return root; } } diff --git a/tests/DevTool.Tests/ScriptCommonTests.cs b/tests/DevTool.Tests/ScriptCommonTests.cs index 9ec4b7d..264a471 100644 --- a/tests/DevTool.Tests/ScriptCommonTests.cs +++ b/tests/DevTool.Tests/ScriptCommonTests.cs @@ -13,7 +13,7 @@ public sealed class ScriptCommonTests 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"), """ + await File.WriteAllTextAsync(Path.Combine(root, "sdtconfig-demo.json"), """ { "name": "demo", "version": "0.1.0", @@ -35,7 +35,7 @@ public sealed class ScriptCommonTests var nested = Path.Combine(root, "child", "leaf"); Directory.CreateDirectory(nested); Directory.CreateDirectory(Path.Combine(root, ".git")); - await File.WriteAllTextAsync(Path.Combine(root, "devtool.json"), """ + await File.WriteAllTextAsync(Path.Combine(root, "sdtconfig-demo.json"), """ { "name": "demo", "version": "0.1.0", diff --git a/tests/DevTool.Tests/WorkspaceDefaultsTests.cs b/tests/DevTool.Tests/WorkspaceDefaultsTests.cs index 99d4880..758fe44 100644 --- a/tests/DevTool.Tests/WorkspaceDefaultsTests.cs +++ b/tests/DevTool.Tests/WorkspaceDefaultsTests.cs @@ -32,7 +32,7 @@ public sealed class WorkspaceDefaultsTests } """); - File.WriteAllText(Path.Combine(projectRoot, "devtool.json"), """ + File.WriteAllText(Path.Combine(projectRoot, "sdtconfig-project-a.json"), """ { "name": "Project A", "version": "1.0.0", @@ -97,7 +97,7 @@ public sealed class WorkspaceDefaultsTests } """); - File.WriteAllText(Path.Combine(projectRoot, "devtool.json"), """ + File.WriteAllText(Path.Combine(projectRoot, "sdtconfig-project-b.json"), """ { "name": "Project B", "version": "1.0.0", diff --git a/tests/DevTool.Tests/WorkspaceInventoryServiceTests.cs b/tests/DevTool.Tests/WorkspaceInventoryServiceTests.cs index 58f9554..9ffbc74 100644 --- a/tests/DevTool.Tests/WorkspaceInventoryServiceTests.cs +++ b/tests/DevTool.Tests/WorkspaceInventoryServiceTests.cs @@ -16,7 +16,7 @@ public sealed class WorkspaceInventoryServiceTests Directory.CreateDirectory(b); Directory.CreateDirectory(a); - File.WriteAllText(Path.Combine(c, "devtool.json"), """{"name":"c","version":"0.1.0","workflows":[]}"""); + File.WriteAllText(Path.Combine(c, "sdtconfig-c.json"), """{"name":"c","version":"0.1.0","workflows":[]}"""); File.WriteAllText(Path.Combine(b, "B.slnx"), ""); File.WriteAllText(Path.Combine(a, "A.csproj"), ""); @@ -64,7 +64,7 @@ public sealed class WorkspaceInventoryServiceTests var excluded = Path.Combine(root, "node_modules", "Pkg"); Directory.CreateDirectory(excluded); File.WriteAllText(Path.Combine(excluded, "Pkg.csproj"), ""); - File.WriteAllText(Path.Combine(root, "devtool.json"), """{"name":"root","version":"0.1.0","workflows":[]}"""); + File.WriteAllText(Path.Combine(root, "sdtconfig-root.json"), """{"name":"root","version":"0.1.0","workflows":[]}"""); var service = new WorkspaceInventoryService(); var result = service.Scan(root, root, new WorkspaceInventorySettings(), new WorkspaceConfig()); @@ -82,7 +82,7 @@ public sealed class WorkspaceInventoryServiceTests Directory.CreateDirectory(b); File.WriteAllText(Path.Combine(a, "a.csproj"), ""); File.WriteAllText(Path.Combine(b, "b.csproj"), ""); - File.WriteAllText(Path.Combine(root, "devtool.json"), """{"name":"root","version":"0.1.0","workflows":[]}"""); + File.WriteAllText(Path.Combine(root, "sdtconfig-root.json"), """{"name":"root","version":"0.1.0","workflows":[]}"""); var service = new WorkspaceInventoryService(); var first = service.Scan(root, root, new WorkspaceInventorySettings(), new WorkspaceConfig()); diff --git a/tests/DevTool.Tests/WorkspaceLoaderTests.cs b/tests/DevTool.Tests/WorkspaceLoaderTests.cs index 8e96365..690f0f0 100644 --- a/tests/DevTool.Tests/WorkspaceLoaderTests.cs +++ b/tests/DevTool.Tests/WorkspaceLoaderTests.cs @@ -10,7 +10,7 @@ public sealed class WorkspaceLoaderTests { var root = Path.Combine(Path.GetTempPath(), "sdt-workspace-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); - File.WriteAllText(Path.Combine(root, "devtool.json"), """ + File.WriteAllText(Path.Combine(root, "sdtconfig-legacy.json"), """ { "name": "legacy", "version": "0.1.0", @@ -72,7 +72,7 @@ public sealed class WorkspaceLoaderTests var markerOnly = Path.Combine(workspaceRoot, "marker"); Directory.CreateDirectory(current); Directory.CreateDirectory(markerOnly); - File.WriteAllText(Path.Combine(current, "devtool.json"), """{"name":"current","version":"0.1.0","workflows":[]}"""); + File.WriteAllText(Path.Combine(current, "sdtconfig-current.json"), """{"name":"current","version":"0.1.0","workflows":[]}"""); File.WriteAllText(Path.Combine(markerOnly, "marker.csproj"), ""); var loaded = WorkspaceLoader.FindAndLoad(current);