several bug fixes,

This commit is contained in:
stan44 2026-03-04 16:40:57 -06:00
parent 38d0afac68
commit 104c8eab91
34 changed files with 1274 additions and 1798 deletions

View File

@ -36,13 +36,13 @@ try
if (projectResult is null) 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( 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); defaultValue: true);
if (!bootstrap) if (!bootstrap)
{ {
AnsiConsole.MarkupLine(Theme.Faint("Create a devtool.json in your project root to get started.")); AnsiConsole.MarkupLine(Theme.Faint("Create an sdtconfig-<ProjectName>.json (or devtool.json) in your project root to get started."));
return 1; return 1;
} }
@ -53,10 +53,10 @@ try
var generated = ConfigBootstrapper.BuildDefaultConfig(scan); var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
var preview = ConfigBootstrapper.ToJson(generated); 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( var confirmWrite = AnsiConsole.Confirm(
$"[{Theme.Amber}]Write generated devtool.json to {scan.ProjectRoot}?[/]", $"[{Theme.Amber}]Write generated project config to {scan.ProjectRoot}?[/]",
defaultValue: true); defaultValue: true);
if (!confirmWrite) if (!confirmWrite)
return 1; return 1;
@ -110,7 +110,7 @@ try
} }
if (loaded is null) 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...")); AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
Console.ReadKey(intercept: true); Console.ReadKey(intercept: true);
continue; continue;
@ -289,7 +289,7 @@ static async Task<int> RunHeadlessAsync(IReadOnlyList<string> cliArgs, string ki
var loaded = ConfigLoader.FindAndLoad(startDir); var loaded = ConfigLoader.FindAndLoad(startDir);
if (loaded is null) 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); return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
} }

View File

@ -47,7 +47,7 @@
## In Progress (next focus) ## 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) - [ ] 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 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) - [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] 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] Structural refactor to domain-separated projects under `src/` (`DevTool.Engine`, `DevTool.Runtime`, `DevTool.Host.Tui`)
- [x] Add second Tauri bridge command: `sdt run <workflowId> --json` with live stream panel - [x] Add second Tauri bridge command: `sdt run <workflowId> --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] 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] 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) - [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 project-type matrix coverage tests (`dotnet`, `node/npm`, `tauri/cargo`)
- [x] Add deterministic headless stop-reason/exit-code tests - [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 - [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. - 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`. - v1.4 policy: ship Windows/Linux, macOS delegated with checklist in `docs/matrix-status.md`.

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -8,11 +8,15 @@ import shutil
import subprocess import subprocess
import sys import sys
import time 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): def run_step(command: str, args: list[str], cwd: str) -> StepResult:
resolved = resolve_command(command) resolved = str(resolve_command(command))
if shutil.which(resolved) is None and not pathlib.Path(resolved).exists(): if shutil.which(resolved) is None and not pathlib.Path(resolved).exists():
return { return {
"command": resolved, "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"] candidates = ["py", "python"] if os.name == "nt" else ["python3", "python"]
for c in candidates: for c in candidates:
if shutil.which(c): if shutil.which(c):
@ -46,20 +50,20 @@ def resolve_python_executable():
return "python" return "python"
def parse_common(parser): def parse_common(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--project-root", required=True) _ = parser.add_argument("--project-root", required=True)
parser.add_argument("--working-dir", default=".") _ = parser.add_argument("--working-dir", default=".")
parser.add_argument("--json", action="store_true") _ = 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)) return os.path.abspath(os.path.join(project_root, working_dir))
EXCLUDED_SCAN_DIRS = {".git", "node_modules", "bin", "obj", ".venv", "venv", ".sdt", "dist", "build"} 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. # Prefer local solution first (.slnx, then .sln), then csproj, then bounded scan from project root.
local_slnx = sorted(pathlib.Path(cwd).glob("*.slnx")) local_slnx = sorted(pathlib.Path(cwd).glob("*.slnx"))
if len(local_slnx) == 1: if len(local_slnx) == 1:
@ -88,9 +92,9 @@ def discover_dotnet_target(project_root: str, cwd: str):
return None 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() root_path = pathlib.Path(root).resolve()
results = [] results: list[str] = []
for current_root, dirs, files in os.walk(root_path): for current_root, dirs, files in os.walk(root_path):
rel = pathlib.Path(current_root).resolve().relative_to(root_path) rel = pathlib.Path(current_root).resolve().relative_to(root_path)
depth = len(rel.parts) depth = len(rel.parts)
@ -105,7 +109,7 @@ def bounded_find_files(root: str, extension: str, max_depth: int):
return sorted(results) 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) cwd = resolve_cwd(project_root, working_dir)
target = discover_dotnet_target(project_root, cwd) target = discover_dotnet_target(project_root, cwd)
if not target: if not target:
@ -124,10 +128,14 @@ def run_dotnet_action(project_root, working_dir, verb):
args = [verb, target] args = [verb, target]
step = run_step("dotnet", args, cwd) step = run_step("dotnet", args, cwd)
step["resolved_target"] = target 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() h = hashlib.sha256()
for name in ("package.json", "package-lock.json"): for name in ("package.json", "package-lock.json"):
p = pathlib.Path(app_root) / name p = pathlib.Path(app_root) / name
@ -136,7 +144,7 @@ def _deps_hash(app_root):
return h.hexdigest() 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" package_json = pathlib.Path(app_root) / "package.json"
if not package_json.exists(): if not package_json.exists():
return {"installed": False, "reason": "not_applicable"} return {"installed": False, "reason": "not_applicable"}
@ -174,7 +182,7 @@ def ensure_npm_dependencies(app_root):
return {"installed": True, "reason": "installed", "step": install_step} 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" package_json = pathlib.Path(cwd) / "package.json"
if not package_json.exists(): if not package_json.exists():
return None 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) 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") 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") 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") 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") 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) cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "package.json").exists(): if not (pathlib.Path(cwd) / "package.json").exists():
return 0, { return 0, {
@ -224,10 +232,10 @@ def action_npm_install(args):
"skip_reason": "not_applicable_no_package_json", "skip_reason": "not_applicable_no_package_json",
} }
step = run_step("npm", ["install"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "package.json").exists(): if not (pathlib.Path(cwd) / "package.json").exists():
return 0, { return 0, {
@ -241,10 +249,10 @@ def action_npm_ci(args):
"skip_reason": "not_applicable_no_package_json", "skip_reason": "not_applicable_no_package_json",
} }
step = run_step("npm", ["ci"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "package.json").exists(): if not (pathlib.Path(cwd) / "package.json").exists():
return 0, { return 0, {
@ -283,12 +291,12 @@ def action_npm_build(args):
if deps.get("reason") == "install_failed": if deps.get("reason") == "install_failed":
step = deps["step"] step = deps["step"]
step["failure_reason"] = "deps_install_failed" step["failure_reason"] = "deps_install_failed"
return step["exit_code"], step return int(step["exit_code"]), step
step = run_step("npm", ["run", "build"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "package.json").exists(): if not (pathlib.Path(cwd) / "package.json").exists():
return 0, { return 0, {
@ -327,12 +335,12 @@ def action_npm_test(args):
if deps.get("reason") == "install_failed": if deps.get("reason") == "install_failed":
step = deps["step"] step = deps["step"]
step["failure_reason"] = "deps_install_failed" step["failure_reason"] = "deps_install_failed"
return step["exit_code"], step return int(step["exit_code"]), step
step = run_step("npm", ["test"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "package.json").exists(): if not (pathlib.Path(cwd) / "package.json").exists():
return 0, { return 0, {
@ -346,37 +354,37 @@ def action_npm_audit(args):
"skip_reason": "not_applicable_no_package_json", "skip_reason": "not_applicable_no_package_json",
} }
step = run_step("npm", ["audit"], cwd) 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, ".") 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) 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, ".") 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) 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, ".") 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) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step(resolve_python_executable(), ["-m", "pytest"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "Cargo.toml").exists(): if not (pathlib.Path(cwd) / "Cargo.toml").exists():
return 0, { return 0, {
@ -390,10 +398,10 @@ def action_cargo_build(args):
"skip_reason": "not_applicable_no_cargo_toml", "skip_reason": "not_applicable_no_cargo_toml",
} }
step = run_step("cargo", ["build"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "Cargo.toml").exists(): if not (pathlib.Path(cwd) / "Cargo.toml").exists():
return 0, { return 0, {
@ -407,10 +415,10 @@ def action_cargo_test(args):
"skip_reason": "not_applicable_no_cargo_toml", "skip_reason": "not_applicable_no_cargo_toml",
} }
step = run_step("cargo", ["test"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
tauri_conf = pathlib.Path(cwd) / "src-tauri" / "tauri.conf.json" tauri_conf = pathlib.Path(cwd) / "src-tauri" / "tauri.conf.json"
if not tauri_conf.exists(): if not tauri_conf.exists():
@ -431,133 +439,94 @@ def action_tauri_build(args):
if deps.get("reason") == "install_failed": if deps.get("reason") == "install_failed":
step = deps["step"] step = deps["step"]
step["failure_reason"] = "deps_install_failed" step["failure_reason"] = "deps_install_failed"
return step["exit_code"], step return int(step["exit_code"]), step
tauri_args = ["run", "tauri", "build"] tauri_args = ["run", "tauri", "build"]
if args.no_bundle: if hasattr(args, "no_bundle") and args.no_bundle:
tauri_args.extend(["--", "--no-bundle"]) tauri_args.extend(["--", "--no-bundle"])
step = run_step("npm", tauri_args, cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["status"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["fetch"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["pull"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["clean", "-fd"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("docker", ["build", "."], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("docker", ["compose", "up", "-d"], cwd) 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) cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("docker", ["compose", "down"], cwd) 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") parser = argparse.ArgumentParser(description="SDT normalized build actions")
sub = parser.add_subparsers(dest="action", required=True) sub = parser.add_subparsers(dest="action", required=True)
p0 = sub.add_parser("dotnet-restore") p0 = sub.add_parser("dotnet-restore"); parse_common(p0)
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") p4 = sub.add_parser("python-venv-create"); parse_common(p4)
parse_common(p1) _ = p4.add_argument("--venv-dir", default=".venv")
p1b = sub.add_parser("dotnet-test") p5 = sub.add_parser("python-pip-install"); parse_common(p5)
parse_common(p1b) _ = p5.add_argument("--requirements", required=True)
p1c = sub.add_parser("dotnet-publish") p5b = sub.add_parser("python-pip-sync"); parse_common(p5b)
parse_common(p1c) _ = p5b.add_argument("--requirements", required=True)
p2 = sub.add_parser("npm-install") p5c = sub.add_parser("python-pytest"); parse_common(p5c)
parse_common(p2) p6 = sub.add_parser("cargo-build"); parse_common(p6)
p6b = sub.add_parser("cargo-test"); parse_common(p6b)
p2b = sub.add_parser("npm-ci") p7 = sub.add_parser("tauri-build"); parse_common(p7)
parse_common(p2b) _ = p7.add_argument("--no-bundle", action="store_true")
p3 = sub.add_parser("npm-build") p8 = sub.add_parser("git-status"); parse_common(p8)
parse_common(p3) p9 = sub.add_parser("git-fetch"); parse_common(p9)
p10 = sub.add_parser("git-pull"); parse_common(p10)
p3b = sub.add_parser("npm-test") p11 = sub.add_parser("git-clean"); parse_common(p11)
parse_common(p3b) p12 = sub.add_parser("docker-build"); parse_common(p12)
p13 = sub.add_parser("docker-compose-up"); parse_common(p13)
p3c = sub.add_parser("npm-audit") p14 = sub.add_parser("docker-compose-down"); parse_common(p14)
parse_common(p3c)
p4 = sub.add_parser("python-venv-create")
parse_common(p4)
p4.add_argument("--venv-dir", default=".venv")
p5 = sub.add_parser("python-pip-install")
parse_common(p5)
p5.add_argument("--requirements", required=True)
p5b = sub.add_parser("python-pip-sync")
parse_common(p5b)
p5b.add_argument("--requirements", required=True)
p5c = sub.add_parser("python-pytest")
parse_common(p5c)
p6 = sub.add_parser("cargo-build")
parse_common(p6)
p6b = sub.add_parser("cargo-test")
parse_common(p6b)
p7 = sub.add_parser("tauri-build")
parse_common(p7)
p7.add_argument("--no-bundle", action="store_true")
p8 = sub.add_parser("git-status")
parse_common(p8)
p9 = sub.add_parser("git-fetch")
parse_common(p9)
p10 = sub.add_parser("git-pull")
parse_common(p10)
p11 = sub.add_parser("git-clean")
parse_common(p11)
p12 = sub.add_parser("docker-build")
parse_common(p12)
p13 = sub.add_parser("docker-compose-up")
parse_common(p13)
p14 = sub.add_parser("docker-compose-down")
parse_common(p14)
args = parser.parse_args() args = parser.parse_args()
@ -590,7 +559,8 @@ def main():
code, summary = handlers[args.action](args) code, summary = handlers[args.action](args)
if args.json: if args.json:
print(json.dumps(summary)) print(json.dumps(summary))
return code
return int(code)
if __name__ == "__main__": if __name__ == "__main__":

View File

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

View File

@ -1,22 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import json
import shutil import shutil
import subprocess
import sys import sys
import os
import json
from pathlib import Path from pathlib import Path
from script_common import find_csproj_by_keyword, find_node_app_root, resolve_repo_root, run # type: ignore
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
def has_package_script(app_root: Path, script_name: str) -> bool: def has_package_script(app_root: Path, script_name: str) -> bool:
package_json = app_root / "package.json" package_json = app_root / "package.json"
@ -35,18 +24,18 @@ def has_package_script(app_root: Path, script_name: str) -> bool:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Publish bundled outputs using Python script entrypoints") parser = argparse.ArgumentParser(description="Publish bundled outputs using Python script entrypoints")
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release") _ = parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
parser.add_argument("--runtime", default="win-x64") _ = parser.add_argument("--runtime", default="win-x64")
parser.add_argument("--skip-sidecar", action="store_true") _ = parser.add_argument("--skip-sidecar", action="store_true")
parser.add_argument("--skip-web", action="store_true") _ = parser.add_argument("--skip-web", action="store_true")
parser.add_argument("--skip-webgateway", action="store_true") _ = parser.add_argument("--skip-webgateway", action="store_true")
parser.add_argument("--skip-tauri", action="store_true") _ = parser.add_argument("--skip-tauri", action="store_true")
parser.add_argument("--dry-run", action="store_true") _ = parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--repo-root", default=None) _ = parser.add_argument("--repo-root", default=None)
parser.add_argument("--sidecar-project", default=None) _ = parser.add_argument("--sidecar-project", default=None)
parser.add_argument("--gateway-project", default=None) _ = parser.add_argument("--gateway-project", default=None)
parser.add_argument("--app-root", default=None) _ = parser.add_argument("--app-root", default=None)
parser.add_argument("--output-dir", default="output") _ = parser.add_argument("--output-dir", default="output")
args = parser.parse_args() args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root) 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"]) 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"]) 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) app_root = (repo_root / args.app_root).resolve() if args.app_root else find_node_app_root(repo_root, None)
tauri_conf = None tauri_conf = None
if app_root is not None: if app_root is not None:
candidate_a = app_root / "src-tauri" / "tauri.conf.json" tauri_conf = next((p for p in [app_root/"src-tauri"/"tauri.conf.json", app_root/"tauri.conf.json"] if p.exists()), None)
candidate_b = app_root / "tauri.conf.json"
if candidate_a.exists():
tauri_conf = candidate_a
elif candidate_b.exists():
tauri_conf = candidate_b
py = sys.executable py = sys.executable
scripts_dir = Path(__file__).parent
if not args.skip_sidecar: if not args.skip_sidecar:
if sidecar_project is None: if sidecar_project is None:
print("Skipping sidecar: no sidecar csproj detected.") print("Skipping sidecar: no sidecar csproj detected.")
else: else:
cmd = [py, "scripts/publish-sidecar.py", "--configuration", args.configuration, "--runtime", args.runtime] cmd = ["-m", "scripts.publish-sidecar" if __package__ else "publish-sidecar",
cmd.extend(["--project", str(sidecar_project)]) "--configuration", args.configuration, "--runtime", args.runtime, "--project", str(sidecar_project)]
code = run_step("Publish sidecar", cmd, repo_root, args.dry_run) print(f"\n> Publishing Sidecar\n$ {py} {' '.join(cmd)}")
if code != 0: if not args.dry_run:
return code code = run(py, cmd, scripts_dir)
if code != 0: return code
if not args.skip_web: if not args.skip_web:
if app_root is None: 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"): elif not has_package_script(app_root, "build"):
print("Skipping web: package.json has no 'build' script.") print("Skipping web: package.json has no 'build' script.")
else: else:
cmd = [py, "scripts/publish-app.py", "--target", "web", "--configuration", args.configuration, "--app-root", str(app_root)] cmd = ["-m", "scripts.publish-app" if __package__ else "publish-app",
code = run_step("Build web", cmd, repo_root, args.dry_run) "--target", "web", "--configuration", args.configuration, "--app-root", str(app_root)]
if code != 0: print(f"\n> Building Web\n$ {py} {' '.join(cmd)}")
return code if not args.dry_run:
code = run(py, cmd, scripts_dir)
if code != 0: return code
if not args.skip_webgateway: if not args.skip_webgateway:
if gateway_project is None: if gateway_project is None:
print("Skipping web gateway: no gateway csproj detected.") print("Skipping web gateway: no gateway csproj detected.")
else: else:
cmd = [py, "scripts/publish-webgateway.py", "--configuration", args.configuration, "--runtime", args.runtime, "--project", str(gateway_project)] cmd = ["-m", "scripts.publish-webgateway" if __package__ else "publish-webgateway",
code = run_step("Publish web gateway", cmd, repo_root, args.dry_run) "--configuration", args.configuration, "--runtime", args.runtime, "--project", str(gateway_project)]
if code != 0: print(f"\n> Publishing Web Gateway\n$ {py} {' '.join(cmd)}")
return code if not args.dry_run:
code = run(py, cmd, scripts_dir)
if code != 0: return code
if not args.skip_tauri: if not args.skip_tauri:
if app_root is None or tauri_conf is None: 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"): elif not has_package_script(app_root, "tauri"):
print("Skipping tauri: package.json has no 'tauri' script.") print("Skipping tauri: package.json has no 'tauri' script.")
else: else:
cmd = [py, "scripts/publish-app.py", "--target", "tauri", "--configuration", args.configuration, "--tauri-bundles", "none", "--app-root", str(app_root)] cmd = ["-m", "scripts.publish-app" if __package__ else "publish-app",
code = run_step("Build tauri", cmd, repo_root, args.dry_run) "--target", "tauri", "--configuration", args.configuration, "--tauri-bundles", "none", "--app-root", str(app_root)]
if code != 0: print(f"\n> Building Tauri\n$ {py} {' '.join(cmd)}")
return code 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") 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"): pattern = "*.exe" if os.name == "nt" else "*"
exes = sorted(target_dir.glob("*.exe"), key=lambda p: p.stat().st_mtime, reverse=True) 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)
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)
if exes: if exes:
staged = output_root / exes[0].name staged = output_root / exes[0].name
if args.dry_run: if args.dry_run: print(f"Would copy: {exes[0]} -> {staged}")
print(f"Would copy: {exes[0]} -> {staged}")
else: else:
shutil.copy2(exes[0], staged) shutil.copy2(exes[0], staged)
print(f"Staged desktop executable: {staged}") print(f"Staged desktop executable: {staged}")

View File

@ -1,51 +1,48 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
from typing import cast
from script_common import ensure_dotnet_publish, find_csproj_by_keyword, resolve_repo_root, SdtResult
from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform .NET sidecar publish helper") parser = argparse.ArgumentParser(description="Cross-platform .NET sidecar publish helper")
parser.add_argument("--configuration", default="Release") _ = parser.add_argument("--configuration", default="Release")
parser.add_argument("--runtime", default="win-x64") _ = parser.add_argument("--runtime", default="win-x64")
parser.add_argument("--repo-root", default=None) _ = parser.add_argument("--repo-root", default=None)
parser.add_argument("--project", default=None, help="Relative/absolute path to sidecar csproj") _ = parser.add_argument("--project", default=None, help="Relative/absolute path to sidecar csproj")
parser.add_argument("--output-dir", default="output") _ = parser.add_argument("--output-dir", default="output")
args = parser.parse_args() args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root) repo_root_val = cast(str | None, args.repo_root)
output_dir = (repo_root / args.output_dir).resolve() 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) output_dir.mkdir(parents=True, exist_ok=True)
if args.project: project_val = cast(str | None, args.project)
csproj = (repo_root / args.project).resolve() if project_val:
csproj = (repo_root / project_val).resolve()
else: else:
csproj = find_csproj_by_keyword(repo_root, ["sidecar"]) csproj = find_csproj_by_keyword(repo_root, ["sidecar"])
if csproj is None or not csproj.exists(): if csproj is None or not csproj.exists():
print("Could not locate sidecar project. Pass --project <path/to/project.csproj>.") print("Could not locate sidecar project. Pass --project <path/to/project.csproj>.")
return 2 return 2
publish_args = [ res: SdtResult = ensure_dotnet_publish(
"publish", csproj=csproj,
str(csproj), output_dir=output_dir,
"-c", configuration=str(args.configuration),
args.configuration, runtime=str(args.runtime),
"-r", single_file=True,
args.runtime, self_contained=True
"--self-contained", )
"-p:PublishSingleFile=true",
"-p:IncludeNativeLibrariesForSelfExtract=true",
"-p:RestoreIgnoreFailedSources=true",
"-p:NuGetAudit=false",
"-o",
str(output_dir),
]
code = run("dotnet", publish_args, repo_root, env=dotnet_env(repo_root))
if code != 0:
return code
binary_name = csproj.stem + (".exe" if args.runtime.startswith("win-") else "") if res["exit_code"] != 0:
return int(res["exit_code"])
runtime_val = str(args.runtime)
binary_name = csproj.stem + (".exe" if runtime_val.startswith("win-") else "")
binary_path = output_dir / binary_name binary_path = output_dir / binary_name
if binary_path.exists(): if binary_path.exists():
print(f"Published executable: {binary_path}") print(f"Published executable: {binary_path}")

View File

@ -1,74 +1,67 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import shutil import shutil
from typing import cast
from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run from script_common import ensure_dotnet_publish, find_csproj_by_keyword, resolve_repo_root, SdtResult
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform ASP.NET gateway publish helper") parser = argparse.ArgumentParser(description="Cross-platform ASP.NET gateway publish helper")
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release") _ = parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
parser.add_argument("--runtime", default="win-x64") _ = parser.add_argument("--runtime", default="win-x64")
parser.add_argument("--self-contained", action="store_true") _ = parser.add_argument("--self-contained", action="store_true")
parser.add_argument("--skip-web-assets", action="store_true") _ = parser.add_argument("--skip-web-assets", action="store_true")
parser.add_argument("--repo-root", default=None) _ = parser.add_argument("--repo-root", default=None)
parser.add_argument("--project", default=None, help="Relative/absolute path to gateway csproj") _ = 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("--web-build-dir", default=None, help="Relative path to web build assets root")
parser.add_argument("--output-dir", default="output/webgateway") _ = parser.add_argument("--output-dir", default="output/webgateway")
args = parser.parse_args() args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root) repo_root_val = cast(str | None, args.repo_root)
output_dir = (repo_root / args.output_dir).resolve() 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) output_dir.mkdir(parents=True, exist_ok=True)
if args.project: project_val = cast(str | None, args.project)
csproj = (repo_root / args.project).resolve() if project_val:
csproj = (repo_root / project_val).resolve()
else: else:
csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"]) csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"])
if csproj is None or not csproj.exists(): if csproj is None or not csproj.exists():
print("Could not locate web gateway project. Pass --project <path/to/project.csproj>.") print("Could not locate web gateway project. Pass --project <path/to/project.csproj>.")
return 2 return 2
publish_args = [ res: SdtResult = ensure_dotnet_publish(
"publish", csproj=csproj,
str(csproj), output_dir=output_dir,
"-c", configuration=str(args.configuration),
args.configuration, runtime=str(args.runtime),
"-r", self_contained=bool(args.self_contained),
args.runtime, single_file=False
"--self-contained", )
"true" if args.self_contained else "false",
"-p:RestoreIgnoreFailedSources=true", if res["exit_code"] != 0:
"-p:NuGetAudit=false", return int(res["exit_code"])
"-o",
str(output_dir),
]
code = run("dotnet", publish_args, repo_root, env=dotnet_env(repo_root))
if code != 0:
return code
if not args.skip_web_assets: if not args.skip_web_assets:
if args.web_build_dir: web_build_dir_val = cast(str | None, args.web_build_dir)
web_build_dir = (repo_root / args.web_build_dir).resolve() if web_build_dir_val:
web_build_dir = (repo_root / web_build_dir_val).resolve()
else: else:
web_build_dir = next((p.parent for p in repo_root.rglob("package.json") if (p.parent / "build").exists()), None) # Look for recent web build output
if web_build_dir is not None: # (Note: rglob is costly but necessary for discovery here)
web_build_dir = web_build_dir / "build" 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(): 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.") print("Web assets not found. Skip with --skip-web-assets or pass --web-build-dir.")
else: else:
web_out = output_dir / "wwwroot" web_out = output_dir / "wwwroot"
web_out.mkdir(parents=True, exist_ok=True) print(f"Copying web assets: {web_build_dir} -> {web_out}")
for item in web_build_dir.iterdir(): shutil.copytree(web_build_dir, web_out, dirs_exist_ok=True)
dst = web_out / item.name print(f"Copied web assets to {web_out}")
if item.is_dir():
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(item, dst)
else:
shutil.copy2(item, dst)
print(f"Copied web assets: {web_out}")
print(f"Publish completed: {output_dir}") print(f"Publish completed: {output_dir}")
return 0 return 0

View File

@ -16,6 +16,15 @@ function Clear-SdtProxyEnv {
Remove-Item Env:PIP_NO_INDEX -ErrorAction SilentlyContinue 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 { function Resolve-SdtRepoRoot {
param([string]$StartPath) param([string]$StartPath)
@ -34,7 +43,7 @@ function Resolve-SdtRepoRoot {
} }
if (-not [string]::IsNullOrWhiteSpace($override)) { if (-not [string]::IsNullOrWhiteSpace($override)) {
$overridePath = [System.IO.Path]::GetFullPath($override) $overridePath = [System.IO.Path]::GetFullPath($override)
if (Test-Path (Join-Path $overridePath "devtool.json")) { if (Test-SdtConfigExists -Path $overridePath) {
return $overridePath return $overridePath
} }
} }
@ -42,7 +51,7 @@ function Resolve-SdtRepoRoot {
foreach ($start in $candidateStarts) { foreach ($start in $candidateStarts) {
$cursor = [System.IO.Path]::GetFullPath($start) $cursor = [System.IO.Path]::GetFullPath($start)
while (-not [string]::IsNullOrWhiteSpace($cursor)) { while (-not [string]::IsNullOrWhiteSpace($cursor)) {
if (Test-Path (Join-Path $cursor "devtool.json")) { if (Test-SdtConfigExists -Path $cursor) {
return $cursor return $cursor
} }
$parent = [System.IO.Directory]::GetParent($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 { function Initialize-SdtDotnetEnv {

View File

@ -6,357 +6,298 @@ import pathlib
import shutil import shutil
import subprocess import subprocess
import sys 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 = [ PROXY_VARS = [
"HTTP_PROXY", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy",
"HTTPS_PROXY", "GIT_HTTP_PROXY", "GIT_HTTPS_PROXY", "PIP_NO_INDEX"
"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: def sha256_files(paths: Iterable[pathlib.Path]) -> str:
base = pathlib.Path(start or os.getcwd()).resolve() 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. def first_existing(paths: Iterable[pathlib.Path]) -> Optional[pathlib.Path]:
for cur in [base, *base.parents]:
cfg = cur / "devtool.json"
if cfg.exists():
hints = load_project_root_hints(cur)
if not hints:
return cur
if any(_hint_matches(cur, hint) for hint in hints):
return cur
# Fall back to git root when available.
try:
proc = subprocess.run(
["git", "-C", str(base), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if proc.returncode == 0:
git_root = proc.stdout.strip()
if git_root:
return pathlib.Path(git_root).resolve()
except Exception:
pass
return base
def load_project_root_hints(repo_root: pathlib.Path) -> list[str]:
cfg = repo_root / "devtool.json"
if not cfg.exists():
return []
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
hints = data.get("project", {}).get("rootHints", [])
return [str(x) for x in hints if isinstance(x, str) and x.strip()]
except Exception:
return []
def ensure_dirs(paths: List[pathlib.Path]) -> None:
for p in paths: 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: def ensure_dirs(paths: list[pathlib.Path]) -> None:
for k in PROXY_VARS: for p in paths: p.mkdir(parents=True, exist_ok=True)
env.pop(k, None)
def dotnet_env(repo_root: pathlib.Path) -> Dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
dotnet_cli_home = repo_root / ".dotnet_home"
nuget_packages = repo_root / ".nuget" / "packages"
nuget_http_cache = repo_root / ".nuget" / "http-cache"
ensure_dirs([dotnet_cli_home, nuget_packages, nuget_http_cache])
env["DOTNET_CLI_HOME"] = str(dotnet_cli_home)
env["NUGET_PACKAGES"] = str(nuget_packages)
env["NUGET_HTTP_CACHE_PATH"] = str(nuget_http_cache)
env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
env["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "0"
env["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "0"
env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
env["NUGET_CERT_REVOCATION_MODE"] = "offline"
return env
def pip_env(repo_root: pathlib.Path) -> Dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
pip_cache = repo_root / ".pip" / "cache"
pip_tmp = repo_root / ".tmp" / "pip-temp"
ensure_dirs([pip_cache, pip_tmp])
env["PIP_CACHE_DIR"] = str(pip_cache)
env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
env["PIP_DEFAULT_TIMEOUT"] = "30"
env["PIP_RETRIES"] = "2"
env["TEMP"] = str(pip_tmp)
env["TMP"] = str(pip_tmp)
return env
def run(command: str, args: List[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> int:
resolved = resolve_command(command)
try:
proc = subprocess.run([resolved, *args], cwd=str(cwd), env=env, check=False)
return proc.returncode
except FileNotFoundError:
print(f"Command not found: {resolved}", file=sys.stderr)
return 127
def run_capture(command: str, args: Sequence[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> tuple[int, str, str]:
resolved = resolve_command(command)
try:
proc = subprocess.run(
[resolved, *args],
cwd=str(cwd),
env=env,
capture_output=True,
text=True,
check=False,
)
return proc.returncode, proc.stdout, proc.stderr
except FileNotFoundError:
return 127, "", f"Command not found: {resolved}"
def resolve_command(command: str) -> str:
if not command:
return command
if os.name != "nt":
return command
if any(sep in command for sep in ("\\", "/")):
return command
if pathlib.Path(command).suffix:
found = shutil.which(command)
return found or command
candidates = []
lowered = command.lower()
if lowered in ("npm", "npx", "pnpm", "yarn", "tauri"):
candidates.extend([f"{command}.cmd", f"{command}.exe", f"{command}.bat", command])
else:
candidates.append(command)
for c in candidates:
found = _which_windows(c)
if found:
name = pathlib.Path(found).name.lower()
if name in ("npm", "npx", "pnpm", "yarn", "tauri"):
shim = pathlib.Path(found).with_name(name + ".cmd")
if shim.exists():
return str(shim)
return found
if lowered in ("npm", "npx", "pnpm", "yarn"):
node = _which_windows("node.exe") or _which_windows("node")
if node:
node_dir = pathlib.Path(node).parent
shim = node_dir / f"{lowered}.cmd"
if shim.exists():
return str(shim)
return candidates[-1]
def _hint_matches(root: pathlib.Path, hint: str) -> bool: def _hint_matches(root: pathlib.Path, hint: str) -> bool:
h = hint.strip() h = hint.strip()
if not h: if not h:
return False return False
try:
has_glob = any(ch in h for ch in ("*", "?", "[")) has_glob = any(ch in h for ch in ("*", "?", "["))
if has_glob: if has_glob:
# Match both anywhere in root and directly at root-level for common hints like "*.sln". if any(root.glob(h)):
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():
return True 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 return False
def _is_tauri_root(candidate_dir: pathlib.Path) -> bool: # --- Domain: Project Discovery ---
return (candidate_dir / "src-tauri" / "tauri.conf.json").exists() or (candidate_dir / "tauri.conf.json").exists()
def _iter_package_jsons() -> list[pathlib.Path]: def resolve_repo_root(start: str | None = None) -> pathlib.Path:
excluded = {"node_modules", "dist", "build", ".git", ".sdt", ".venv", "venv", "bin", "obj"} base = pathlib.Path(start or os.getcwd()).resolve()
found: list[pathlib.Path] = [] for cur in [base, *base.parents]:
for current_root, dirs, files in os.walk(repo_root): sdt_configs = list(cur.glob("sdtconfig-*.json"))
dirs[:] = [d for d in dirs if d not in excluded] cfg = sdt_configs[0] if sdt_configs else (cur / "devtool.json")
if "package.json" in files: if cfg.exists():
found.append(pathlib.Path(current_root) / "package.json") hints = load_project_root_hints(cur, cfg)
found.sort(key=lambda p: len(p.parts)) if not hints or any(_hint_matches(cur, hint) for hint in hints): return cur
return found 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: def load_project_root_hints(repo_root: pathlib.Path, cfg: pathlib.Path | None = None) -> list[str]:
p = (repo_root / preferred).resolve() if cfg is None:
package_json = p / "package.json" sdt_configs = list(repo_root.glob("sdtconfig-*.json"))
if package_json.exists(): cfg = sdt_configs[0] if sdt_configs else (repo_root / "devtool.json")
# Keep explicit preferred root only when it appears runnable for node workflows. if not cfg.exists(): return []
if _is_tauri_root(p) or _has_scripts(package_json, ("build", "dev", "start", "test", "tauri")): try:
return p 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() def _resolve_project_config_path(repo_root: pathlib.Path) -> Optional[pathlib.Path]:
if not package_files: sdt_configs = sorted(repo_root.glob("sdtconfig-*.json"), key=lambda p: p.name.lower())
return None if sdt_configs:
return sdt_configs[0]
# Strong preference: a tauri app root with tauri config and package.json. legacy = repo_root / "devtool.json"
tauri_candidates = [p.parent for p in package_files if _is_tauri_root(p.parent)] if legacy.exists():
if len(tauri_candidates) == 1: return legacy
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
return None 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: def find_csproj(repo_root: pathlib.Path, hints: Sequence[str] | None = None) -> Optional[pathlib.Path]:
if not search_root.exists(): if hints:
return None for h in hints:
files = [p for p in search_root.rglob(pattern) if p.is_file() and "\\obj\\" not in str(p).replace("/", "\\")] p = (repo_root / h).resolve()
if not files: if p.exists() and p.suffix == ".csproj": return p
return None hits = [h for h in repo_root.rglob("*.csproj") if not any(x in h.parts for x in [".git", "node_modules", "bin", "obj"])]
files.sort(key=lambda p: p.stat().st_mtime, reverse=True) return hits[0] if len(hits) == 1 else None
return files[0]
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)

View File

@ -4,7 +4,7 @@ import os
import shutil import shutil
from pathlib import Path 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: 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: def main() -> int:
parser = argparse.ArgumentParser(description="Sync newest built assets into output folder") parser = argparse.ArgumentParser(description="Sync newest built assets into output folder")
parser.add_argument("--repo-root", default=None) _ = parser.add_argument("--repo-root", default=None)
parser.add_argument("--output-dir", default="output") _ = parser.add_argument("--output-dir", default="output")
parser.add_argument("--web-build-dir", default=None, help="Path to web build 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("--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("--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("--tauri-target-dir", default=None, help="Path to tauri target root")
args = parser.parse_args() args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root) repo_root = resolve_repo_root(args.repo_root)
output_dir = (repo_root / args.output_dir).resolve() output_dir = (repo_root / args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True) 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: if web_build is None:
web_build = next((p for p in repo_root.rglob("build") if (p.parent / "package.json").exists()), 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(): if web_build is not None and web_build.exists():
web_out = output_dir / "webgateway" / "wwwroot" web_out = output_dir / "webgateway" / "wwwroot"
copy_tree_contents(web_build, web_out) copy_tree_contents(web_build, web_out)
print(f"Synced web assets -> {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: 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_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 sidecar_bin = sidecar_proj / "bin" if sidecar_proj else None
if sidecar_bin is not None: if sidecar_bin is not None:
sidecar_exe = None
if os.name == "nt": if os.name == "nt":
sidecar_exe = newest_file(sidecar_bin, "*.exe") sidecar_exe = newest_file(sidecar_bin, "*.exe")
else: else:
@ -56,11 +61,14 @@ def main() -> int:
copy_tree_contents(sidecar_exe.parent, output_dir) copy_tree_contents(sidecar_exe.parent, output_dir)
print(f"Synced sidecar -> {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: 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_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 gateway_bin = gateway_proj / "bin" if gateway_proj else None
if gateway_bin is not None: if gateway_bin is not None:
gw_exe = None
if os.name == "nt": if os.name == "nt":
gw_exe = newest_file(gateway_bin, "*.exe") gw_exe = newest_file(gateway_bin, "*.exe")
else: else:
@ -72,11 +80,14 @@ def main() -> int:
copy_tree_contents(gw_exe.parent, gw_out) copy_tree_contents(gw_exe.parent, gw_out)
print(f"Synced gateway -> {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: if tauri_target is None:
tauri_target = next((p for p in repo_root.rglob("src-tauri") if (p / "target").exists()), None) tauri_src = 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_target = tauri_src / "target" if tauri_src else None
if tauri_target is not None: if tauri_target is not None:
app_exe = None
if os.name == "nt": if os.name == "nt":
app_exe = newest_file(tauri_target, "*.exe") app_exe = newest_file(tauri_target, "*.exe")
else: else:

View File

@ -5,23 +5,23 @@ import pathlib
import shutil import shutil
import subprocess import subprocess
import sys 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 from script_common import resolve_command, resolve_repo_root
def load_config(project_root: pathlib.Path) -> dict: def load_config(project_root: pathlib.Path) -> dict[str, Any]:
config_path = project_root / "devtool.json" 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(): 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")) 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", []) workflows = config.get("workflows", [])
if not isinstance(workflows, list): if not isinstance(workflows, list):
return [] 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: if selected:
normalized = [w for w in normalized if w["id"] in selected] normalized = [w for w in normalized if w["id"] in selected]
return normalized return normalized
@ -45,21 +45,25 @@ def resolve_script_arg(project_root: pathlib.Path, working_dir: pathlib.Path, ar
return b return b
def static_check_workflow(project_root: pathlib.Path, workflow: dict) -> dict: def static_check_workflow(project_root: pathlib.Path, workflow: dict[str, Any]) -> dict[str, Any]:
result = { result: dict[str, Any] = {
"workflowId": workflow.get("id"), "workflowId": workflow.get("id"),
"ok": True, "ok": True,
"issues": [], "issues": [],
"steps": [], "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): if not isinstance(step, dict):
continue continue
step_id = step.get("id", "<unknown>") step_id = step.get("id", "<unknown>")
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() working_dir = (project_root / working_dir_rel).resolve()
if not working_dir.exists(): if not working_dir.exists():
step_result["ok"] = False 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}") step_result["issues"].append(f"command_not_found:{command}")
if command.lower() in ("python", "py", "python3", "python.exe", "py.exe"): 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]) script_path = resolve_script_arg(project_root, working_dir, args[0])
if not script_path.exists(): if not script_path.exists():
step_result["ok"] = False step_result["ok"] = False
step_result["issues"].append(f"python_script_not_found:{script_path}") step_result["issues"].append(f"python_script_not_found:{script_path}")
if isinstance(action, str) and action.strip(): if isinstance(action, str) and action.strip():
# Action-based steps still require workingDir existence for reliable execution.
if not working_dir.exists(): if not working_dir.exists():
step_result["ok"] = False step_result["ok"] = False
step_result["issues"].append("action_working_dir_not_found") 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 return result
def sdt_attempts(repo_root: pathlib.Path) -> List[List[str]]: def sdt_attempts(repo_root: pathlib.Path) -> list[list[str]]:
attempts: List[List[str]] = [] attempts: list[list[str]] = []
attempts.append(["sdt"]) attempts.append(["sdt"])
if sys.platform.startswith("win"): if sys.platform.startswith("win"):
attempts.append(["sdt.exe"]) attempts.append(["sdt.exe"])
@ -110,9 +113,8 @@ def sdt_attempts(repo_root: pathlib.Path) -> List[List[str]]:
if devtool_csproj.exists(): if devtool_csproj.exists():
attempts.append(["dotnet", "run", "--project", str(devtool_csproj), "--"]) attempts.append(["dotnet", "run", "--project", str(devtool_csproj), "--"])
# Preserve order but dedupe exact attempts. seen: set[tuple[str, ...]] = set()
seen = set() unique: list[list[str]] = []
unique: List[List[str]] = []
for a in attempts: for a in attempts:
key = tuple(a) key = tuple(a)
if key in seen: if key in seen:
@ -126,8 +128,8 @@ def try_run_sdt(
repo_root: pathlib.Path, repo_root: pathlib.Path,
command_args: Sequence[str], command_args: Sequence[str],
timeout_seconds: int, timeout_seconds: int,
) -> Tuple[Optional[subprocess.CompletedProcess], Optional[str]]: ) -> tuple[Optional[subprocess.CompletedProcess[str]], Optional[str]]:
errors: List[str] = [] errors: list[str] = []
for base in sdt_attempts(repo_root): for base in sdt_attempts(repo_root):
cmd = [*base, *command_args] cmd = [*base, *command_args]
try: try:
@ -147,7 +149,7 @@ def try_run_sdt(
return None, "; ".join(errors) if errors else "no_sdt_attempts" 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()] lines = [line.strip() for line in stdout.splitlines() if line.strip()]
for line in reversed(lines): for line in reversed(lines):
if not line.startswith("{"): if not line.startswith("{"):
@ -167,8 +169,8 @@ def execute_check_workflow(
workflow_id: str, workflow_id: str,
env_profile: Optional[str], env_profile: Optional[str],
timeout_seconds: int, timeout_seconds: int,
) -> dict: ) -> dict[str, Any]:
args = [ run_args = [
"run", "run",
workflow_id, workflow_id,
"--json", "--json",
@ -177,9 +179,9 @@ def execute_check_workflow(
"--non-interactive", "--non-interactive",
] ]
if env_profile: 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: if proc is None:
return { return {
"workflowId": workflow_id, "workflowId": workflow_id,
@ -215,13 +217,13 @@ def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Verify SDT workflow routes (static path checks + optional headless execution)." 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("--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("--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("--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("--execute", action="store_true", help="Also run each workflow via `sdt run ... --json`")
parser.add_argument("--env-profile", default=None) _ = parser.add_argument("--env-profile", default=None)
parser.add_argument("--timeout-seconds", type=int, default=600) _ = 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("--output-json", default=None, help="Write full report JSON to file")
args = parser.parse_args() args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root) repo_root = resolve_repo_root(args.repo_root)
@ -235,10 +237,10 @@ def main() -> int:
return 2 return 2
static_results = [static_check_workflow(project_root, w) for w in workflows] 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: if args.execute:
for w in workflows: for w in workflows:
wid = w["id"] wid = str(w["id"])
execute_results.append( execute_results.append(
execute_check_workflow( execute_check_workflow(
repo_root=repo_root, repo_root=repo_root,
@ -252,7 +254,7 @@ def main() -> int:
static_failures = [r for r in static_results if not r["ok"]] static_failures = [r for r in static_results if not r["ok"]]
exec_failures = [r for r in execute_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), "repoRoot": str(repo_root),
"projectRoot": str(project_root), "projectRoot": str(project_root),
"totalWorkflows": len(workflows), "totalWorkflows": len(workflows),
@ -283,13 +285,13 @@ def main() -> int:
if static_failures: if static_failures:
print("\nStatic failures:") print("\nStatic failures:")
for f in static_failures: for sf in static_failures:
print(f"- {f['workflowId']}: {', '.join(f['issues'])}") print(f"- {sf['workflowId']}: {', '.join(sf['issues'])}")
if exec_failures: if exec_failures:
print("\nExecution failures:") print("\nExecution failures:")
for f in exec_failures: for ef in exec_failures:
print(f"- {f['workflowId']}: stopReason={f.get('stopReason')} message={f.get('message')}") print(f"- {ef['workflowId']}: stopReason={ef.get('stopReason')} message={ef.get('message')}")
return 1 if static_failures or exec_failures else 0 return 1 if static_failures or exec_failures else 0

View File

@ -46,6 +46,16 @@ public static class ConfigBootstrapper
WriteIndented = true WriteIndented = true
}; };
private static readonly HashSet<string> 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) public static BootstrapScanResult Scan(string startDir)
{ {
var root = FindProjectRoot(startDir); var root = FindProjectRoot(startDir);
@ -166,12 +176,14 @@ public static class ConfigBootstrapper
rootHints.Add("scripts"); rootHints.Add("scripts");
} }
var projectName = new DirectoryInfo(root).Name;
if (rootHints.Count == 0) if (rootHints.Count == 0)
rootHints.Add("devtool.json"); rootHints.Add($"sdtconfig-{projectName}.json");
return new BootstrapScanResult( return new BootstrapScanResult(
ProjectRoot: root, ProjectRoot: root,
ProjectName: new DirectoryInfo(root).Name, ProjectName: projectName,
ProjectType: InferProjectType(toolFamilies), ProjectType: InferProjectType(toolFamilies),
ToolFamilies: toolFamilies.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(), ToolFamilies: toolFamilies.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(),
DotnetWorkingDir: dotnetWorkingDir, 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 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) 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)); File.WriteAllText(path, ToJson(config));
return path; return path;
@ -314,14 +327,14 @@ public static class ConfigBootstrapper
var has = new Func<string, bool>(tool => scan.ToolFamilies.Contains(tool, StringComparer.OrdinalIgnoreCase)); var has = new Func<string, bool>(tool => scan.ToolFamilies.Contains(tool, StringComparer.OrdinalIgnoreCase));
var scripts = DetectScriptHelpers(scan.ProjectRoot); var scripts = DetectScriptHelpers(scan.ProjectRoot);
if (scripts.Contains("publish-sidecar.py") || if (scripts.ContainsKey("publish-sidecar.py") ||
scripts.Contains("publish-app.py") || scripts.ContainsKey("publish-app.py") ||
scripts.Contains("publish-webgateway.py") || scripts.ContainsKey("publish-webgateway.py") ||
scripts.Contains("publish-output.py") || scripts.ContainsKey("publish-output.py") ||
scripts.Contains("sync-output.py") || scripts.ContainsKey("sync-output.py") ||
scripts.Contains("run-webgateway.py")) scripts.ContainsKey("run-webgateway.py"))
{ {
foreach (var workflow in BuildScriptDrivenWorkflows(scripts)) foreach (var workflow in BuildScriptDrivenWorkflows(scripts, scan.NodeWorkingDir))
yield return workflow; yield return workflow;
} }
@ -554,32 +567,76 @@ public static class ConfigBootstrapper
}; };
} }
private static HashSet<string> DetectScriptHelpers(string projectRoot) private static Dictionary<string, string> DetectScriptHelpers(string projectRoot)
{ {
var scriptsDir = Path.Combine(projectRoot, "scripts"); var candidates = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
if (!Directory.Exists(scriptsDir)) foreach (var file in EnumerateFilesBounded(projectRoot, "*.py", MaxScanDepth))
return new HashSet<string>(StringComparer.OrdinalIgnoreCase); {
var fileName = Path.GetFileName(file);
if (string.IsNullOrWhiteSpace(fileName) || !KnownScriptHelpers.Contains(fileName))
continue;
return Directory.EnumerateFiles(scriptsDir, "*.py", SearchOption.TopDirectoryOnly) var dir = Path.GetDirectoryName(file);
.Select(Path.GetFileName) if (string.IsNullOrWhiteSpace(dir))
.Where(f => !string.IsNullOrWhiteSpace(f)) continue;
.Cast<string>()
.ToHashSet(StringComparer.OrdinalIgnoreCase); 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<string, string>(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<string, string>(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<WorkflowDefinition> BuildScriptDrivenWorkflows(HashSet<string> scripts) private static IEnumerable<WorkflowDefinition> BuildScriptDrivenWorkflows(
IReadOnlyDictionary<string, string> 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, var args = new List<string> { scriptPath };
Label = label, args.AddRange(extraArgs);
Command = "python", return new WorkflowStep
Args = scriptArgs.ToList(), {
WorkingDir = ".", Id = id,
Requires = [new ToolRequirement { Tool = "python", InstallPolicy = InstallPolicy.Prompt }] 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 yield return new WorkflowDefinition
{ {
@ -587,11 +644,12 @@ public static class ConfigBootstrapper
Label = "Publish Sidecar", Label = "Publish Sidecar",
Description = "Publish sidecar service", Description = "Publish sidecar service",
Group = "Build", 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 yield return new WorkflowDefinition
{ {
@ -601,8 +659,9 @@ public static class ConfigBootstrapper
Group = "Build", Group = "Build",
Steps = 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 yield return new WorkflowDefinition
@ -611,16 +670,17 @@ public static class ConfigBootstrapper
Label = "Build Tauri Desktop App", Label = "Build Tauri Desktop App",
Description = "Build desktop binary", Description = "Build desktop binary",
Group = "Build", Group = "Build",
DependsOn = scripts.Contains("publish-sidecar.py") ? ["sidecar"] : [], DependsOn = scripts.ContainsKey("publish-sidecar.py") ? ["sidecar"] : [],
Steps = Steps =
[ [
ScriptStep("tauri:run", "python scripts/publish-app.py --target tauri --tauri-bundles none", ScriptStep("tauri:run", $"python {publishAppPath} --target tauri --tauri-bundles none",
"scripts/publish-app.py", "--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 yield return new WorkflowDefinition
{ {
@ -628,12 +688,13 @@ public static class ConfigBootstrapper
Label = "Publish WebGateway", Label = "Publish WebGateway",
Description = "Publish ASP.NET gateway", Description = "Publish ASP.NET gateway",
Group = "Build", Group = "Build",
DependsOn = scripts.Contains("publish-app.py") ? ["web"] : [], DependsOn = scripts.ContainsKey("publish-app.py") ? ["web"] : [],
Steps = [ScriptStep("webgateway:run", "python scripts/publish-webgateway.py", "scripts/publish-webgateway.py")] 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 yield return new WorkflowDefinition
{ {
@ -641,11 +702,12 @@ public static class ConfigBootstrapper
Label = "Sync Output", Label = "Sync Output",
Description = "Sync newest artifacts to output", Description = "Sync newest artifacts to output",
Group = "Build", 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 yield return new WorkflowDefinition
{ {
@ -653,11 +715,12 @@ public static class ConfigBootstrapper
Label = "Stage Output Bundle", Label = "Stage Output Bundle",
Description = "Publish and stage distributable output", Description = "Publish and stage distributable output",
Group = "Build", 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 yield return new WorkflowDefinition
{ {
@ -667,13 +730,21 @@ public static class ConfigBootstrapper
Group = "Dev", Group = "Dev",
Steps = Steps =
[ [
ScriptStep("run-gateway-dev:run", "python scripts/run-webgateway.py --mode Dev", ScriptStep("run-gateway-dev:run", $"python {runWebgatewayPath} --mode Dev",
"scripts/run-webgateway.py", "--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) private static string FindProjectRoot(string startDir)
{ {
var start = Path.GetFullPath(startDir); var start = Path.GetFullPath(startDir);
@ -786,6 +857,18 @@ public static class ConfigBootstrapper
if (candidates.Count == 0) if (candidates.Count == 0)
return null; 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) foreach (var candidate in candidates)
{ {
if (HasNodeScripts(candidate)) 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<string> EnumerateFilesBounded(string root, string pattern, int maxDepth) private static IEnumerable<string> EnumerateFilesBounded(string root, string pattern, int maxDepth)
{ {
var queue = new Queue<(string Dir, int Depth)>(); var queue = new Queue<(string Dir, int Depth)>();

View File

@ -28,7 +28,7 @@ public static class ConfigLoader
}; };
/// <summary> /// <summary>
/// Walks up from <paramref name="startDir"/> (or CWD) until it finds devtool.json. /// Walks up from <paramref name="startDir"/> (or CWD) until it finds sdtconfig-*.json or devtool.json.
/// Returns null if not found. /// Returns null if not found.
/// </summary> /// </summary>
public static string? FindConfigPath(string? startDir = null) public static string? FindConfigPath(string? startDir = null)
@ -36,10 +36,14 @@ public static class ConfigLoader
var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory()); var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory());
while (dir is not null) 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"); var candidate = Path.Combine(dir.FullName, "devtool.json");
if (File.Exists(candidate)) if (File.Exists(candidate))
return candidate; return candidate;
dir = dir.Parent!; dir = dir.Parent;
} }
return null; return null;
} }
@ -76,7 +80,7 @@ public static class ConfigLoader
throw new InvalidOperationException( throw new InvalidOperationException(
$"Legacy targets-only config detected at {configPath}. Strict mode requires workflows. " + $"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."); "Temporary rollback: set SDT_LEGACY_MODE=compat.");
} }
@ -87,7 +91,7 @@ public static class ConfigLoader
catch (Exception ex) catch (Exception ex)
{ {
throw new InvalidOperationException( 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 json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<DevToolConfig>(json, JsonOptions) var config = JsonSerializer.Deserialize<DevToolConfig>(json, JsonOptions)
?? throw new InvalidOperationException("devtool.json deserialized to null."); ?? throw new InvalidOperationException("Config deserialized to null.");
if (config.Targets.Count == 0) if (config.Targets.Count == 0)
return new LegacyMigrationApplyResult(false, "No legacy targets found to migrate.", ConfigPath: configPath); 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) private static DevToolConfig DeserializeConfig(JsonObject obj, string sourcePath)
{ {
return obj.Deserialize<DevToolConfig>(JsonOptions) return obj.Deserialize<DevToolConfig>(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) private static JsonObject MergeObjects(JsonObject baseObj, JsonObject overlayObj)

View File

@ -67,6 +67,7 @@ public sealed class WorkflowDefinition
public string Description { get; init; } = ""; public string Description { get; init; } = "";
public string Group { get; init; } = "General"; public string Group { get; init; } = "General";
public List<string> DependsOn { get; init; } = []; public List<string> DependsOn { get; init; } = [];
public List<string> RequireFiles { get; init; } = [];
public List<WorkflowStep> Steps { get; init; } = []; public List<WorkflowStep> Steps { get; init; } = [];
} }
@ -100,6 +101,9 @@ public enum InstallPolicy
public sealed class ToolingConfig public sealed class ToolingConfig
{ {
[JsonConverter(typeof(JsonStringEnumConverter))]
public InstallPolicy DefaultInstallPolicy { get; init; } = InstallPolicy.Prompt;
public List<ToolInstallDefinition> Tools { get; init; } = []; public List<ToolInstallDefinition> Tools { get; init; } = [];
} }

View File

@ -15,7 +15,7 @@ public sealed class WorkspaceProject
/// <summary> /// <summary>
/// Relative or absolute path to the project root /// Relative or absolute path to the project root
/// (the directory containing devtool.json). /// (the directory containing SDT project config: sdtconfig-*.json or devtool.json).
/// </summary> /// </summary>
public string Path { get; init; } = ""; public string Path { get; init; } = "";
public List<string> Tags { get; init; } = []; public List<string> Tags { get; init; } = [];
@ -65,5 +65,6 @@ public sealed class WorkspaceInventorySettings
"*.sln", "*.sln",
"*.csproj", "*.csproj",
"devtool.json", "devtool.json",
"sdtconfig-*.json",
]; ];
} }

View File

@ -143,6 +143,13 @@ public sealed class WorkspaceInventoryService : IWorkspaceInventoryService
continue; 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 (string.Equals(marker, "*.slnx", StringComparison.OrdinalIgnoreCase))
{ {
if (Directory.EnumerateFiles(dir, "*.slnx", SearchOption.TopDirectoryOnly).Any()) if (Directory.EnumerateFiles(dir, "*.slnx", SearchOption.TopDirectoryOnly).Any())
@ -245,7 +252,7 @@ public sealed class WorkspaceInventoryService : IWorkspaceInventoryService
var warnings = new List<string>(); var warnings = new List<string>();
if (!hasDevtool) if (!hasDevtool)
warnings.Add("No devtool.json found."); warnings.Add("No SDT project config found.");
if (full.Equals(currentRoot, StringComparison.OrdinalIgnoreCase)) if (full.Equals(currentRoot, StringComparison.OrdinalIgnoreCase))
warnings.Add("Current project root."); warnings.Add("Current project root.");

View File

@ -144,6 +144,7 @@ public sealed class PrereqInstallerService : IPrereqInstaller
"java" => isWindows "java" => isWindows
? new InstallCommand("winget", ["install", "Microsoft.OpenJDK.21"]) ? new InstallCommand("winget", ["install", "Microsoft.OpenJDK.21"])
: new InstallCommand("sh", ["-c", "echo Install JDK via package manager"]), : 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}'"]), _ => new InstallCommand("sh", ["-c", $"echo No installer template for '{tool}'"]),
}; };

View File

@ -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<bool> ScaffoldAsync(string projectRoot, ScaffoldingOptions options, Action<string, bool> 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<string, bool> 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<string, bool> 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<string, bool> 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<string, bool> 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<string, bool> 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<string, bool> 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<string, bool> 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);
}
}
}

View File

@ -52,7 +52,7 @@ public sealed class RequirementResolver : IRequirementResolver
"npm" => [Req("node"), Req("npm")], "npm" => [Req("node"), Req("npm")],
"pnpm" => [Req("node"), Req("pnpm")], "pnpm" => [Req("node"), Req("pnpm")],
"yarn" => [Req("node"), Req("yarn")], "yarn" => [Req("node"), Req("yarn")],
"python" or "py" => [Req("python")], "python" or "py" => InferPythonRequirements(args),
"cargo" => [Req("cargo")], "cargo" => [Req("cargo")],
"tauri" => [Req("cargo"), Req("node"), Req("npm")], "tauri" => [Req("cargo"), Req("node"), Req("npm")],
"git" => [Req("git")], "git" => [Req("git")],
@ -65,6 +65,18 @@ public sealed class RequirementResolver : IRequirementResolver
}; };
} }
private static List<ToolRequirement> InferPythonRequirements(IReadOnlyList<string> args)
{
var result = new List<ToolRequirement> { 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() private static ToolRequirement Req(string tool) => new()
{ {
Tool = tool, Tool = tool,

View File

@ -75,6 +75,7 @@ public sealed class ToolProbeService : IToolProbe
var command = tool.ToLowerInvariant() switch var command = tool.ToLowerInvariant() switch
{ {
"python" => PythonResolver.ResolveExecutable(), "python" => PythonResolver.ResolveExecutable(),
"pyinstaller" => PythonResolver.ResolveExecutable(),
"dotnet" => "dotnet", "dotnet" => "dotnet",
"node" => "node", "node" => "node",
"npm" => "npm", "npm" => "npm",
@ -91,7 +92,7 @@ public sealed class ToolProbeService : IToolProbe
var resolution = CommandResolver.ResolveWithTrace(command, config, tool); var resolution = CommandResolver.ResolveWithTrace(command, config, tool);
command = resolution.Resolved; command = resolution.Resolved;
var versionArg = command is "python" ? "--version" : "--version"; var versionArg = tool.ToLowerInvariant() == "pyinstaller" ? "-m PyInstaller --version" : "--version";
try try
{ {
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
@ -105,9 +106,21 @@ public sealed class ToolProbeService : IToolProbe
if (envOverrides is not null) if (envOverrides is not null)
{ {
foreach (var kvp in envOverrides) foreach (var kvp in envOverrides)
{
psi.Environment[kvp.Key] = kvp.Value; 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 }; using var process = new Process { StartInfo = psi };
process.Start(); process.Start();

View File

@ -1,4 +1,5 @@
using Sdt.Config; using Sdt.Config;
using Sdt.Runner;
namespace Sdt.Core; namespace Sdt.Core;
@ -222,12 +223,73 @@ public sealed class WorkflowExecutor(
} }
} }
var runResult = await _actionRunner.RunStepAsync( RunResult runResult;
step, int retryCount = 0;
projectRoot, const int MaxRetries = 1;
onOutput,
envOverrides, while (true)
cancellationToken).ConfigureAwait(false); {
var capturedOutput = new List<string>();
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( results.Add(new WorkflowStepResult(
workflow.Id, workflow.Id,
@ -273,4 +335,32 @@ public sealed class WorkflowExecutor(
Message: "Workflow completed successfully.", Message: "Workflow completed successfully.",
Steps: results); Steps: results);
} }
private static string? DetectMissingToolFromOutput(List<string> 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;
}
} }

View File

@ -150,7 +150,7 @@ public sealed class BridgeStdioServer
if (!Directory.Exists(absoluteCandidate)) if (!Directory.Exists(absoluteCandidate))
throw new BridgeValidationException($"Candidate path does not exist: {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 scan = ConfigBootstrapper.Scan(absoluteCandidate);
var generated = ConfigBootstrapper.BuildDefaultConfig(scan); var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
@ -632,7 +632,7 @@ public sealed class BridgeStdioServer
private LoadedProjectConfig LoadProject(JsonElement @params) private LoadedProjectConfig LoadProject(JsonElement @params)
{ {
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); 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) private string ResolveProjectRootForProjectScopedMethod(JsonElement @params)
@ -684,12 +684,12 @@ public sealed class BridgeStdioServer
{ {
var path = ConfigLoader.FindConfigPath(projectRoot); var path = ConfigLoader.FindConfigPath(projectRoot);
if (string.IsNullOrWhiteSpace(path)) 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}"; var backup = path + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}";
File.Copy(path, backup, overwrite: false); File.Copy(path, backup, overwrite: false);
File.WriteAllText(path, ConfigBootstrapper.ToJson(config)); 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) catch (Exception ex)
{ {

View File

@ -51,7 +51,7 @@ public sealed class App
_startupWorkflowId = startupWorkflowId; _startupWorkflowId = startupWorkflowId;
_activeEnvProfile = config.EnvProfiles?.Active; _activeEnvProfile = config.EnvProfiles?.Active;
var normalized = WorkflowModelBuilder.Normalize(config, ResolveLegacyMode(), _requirementResolver); var normalized = WorkflowModelBuilder.Normalize(config, ResolveLegacyMode(), _requirementResolver);
_workflows = normalized.Workflows.ToList(); _workflows = normalized.Workflows.Where(IsWorkflowApplicable).ToList();
_warnings = []; _warnings = [];
_debugRunner = new DebugProfileRunner(new ToolProbeService(), new PrereqInstallerService()); _debugRunner = new DebugProfileRunner(new ToolProbeService(), new PrereqInstallerService());
_diagnostics = new DiagnosticsBundleService(); _diagnostics = new DiagnosticsBundleService();
@ -60,6 +60,30 @@ public sealed class App
_warnings.AddRange(normalized.Warnings); _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() private static LegacyMode ResolveLegacyMode()
{ {
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE"); var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
@ -364,7 +388,7 @@ public sealed class App
if (_config.Targets.Count > 0) if (_config.Targets.Count > 0)
systemItems.Insert(0, new MenuItem( 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__")); "__migrate_legacy__"));
systemItems.Add(new MenuItem($"[{Theme.GreenDim}]✗ Quit[/]", "__quit__")); systemItems.Add(new MenuItem($"[{Theme.GreenDim}]✗ Quit[/]", "__quit__"));

View File

@ -26,6 +26,37 @@ public sealed class SetupWizardScreen
Emit(new RunEvent("setup", RunEventType.WorkflowStarted, "Setup wizard started.")); Emit(new RunEvent("setup", RunEventType.WorkflowStarted, "Setup wizard started."));
Emit(new RunEvent("setup", RunEventType.WorkflowPlanned, "Setup lifecycle initialized.")); 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...")); AnsiConsole.MarkupLine(Theme.G("Step 1/4: Running config doctor..."));
Emit(new RunEvent("setup", RunEventType.WorkflowStepStarted, "Running config doctor.", StepId: "setup:doctor")); Emit(new RunEvent("setup", RunEventType.WorkflowStepStarted, "Running config doctor.", StepId: "setup:doctor"));
var doctor = new ConfigDoctorService(new ToolProbeService(), new RequirementResolver()); var doctor = new ConfigDoctorService(new ToolProbeService(), new RequirementResolver());
@ -120,7 +151,7 @@ public sealed class SetupWizardScreen
var applyConfig = nonInteractive var applyConfig = nonInteractive
? true ? true
: AnsiConsole.Confirm( : 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); defaultValue: true);
if (applyConfig) if (applyConfig)
{ {
@ -151,6 +182,38 @@ public sealed class SetupWizardScreen
return true; return true;
} }
private async Task RunScaffoldingWizardAsync(string projectRoot)
{
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule("NEW PROJECT WIZARD"));
var projectName = AnsiConsole.Ask<string>($"[{Theme.Green}]Project Name:[/]", new DirectoryInfo(projectRoot).Name);
var backend = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title($"[{Theme.Green}]Select Backend:[/]")
.AddChoices("None", "C# (.NET)", "Python"));
var frontend = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.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<bool> ConfirmInstallAsync(string tool, InstallPlan plan) private static Task<bool> ConfirmInstallAsync(string tool, InstallPlan plan)
{ {
if (RuntimePolicy.IsNonInteractive()) if (RuntimePolicy.IsNonInteractive())
@ -176,12 +239,12 @@ public sealed class SetupWizardScreen
{ {
var path = ConfigLoader.FindConfigPath(projectRoot); var path = ConfigLoader.FindConfigPath(projectRoot);
if (string.IsNullOrWhiteSpace(path)) 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}"; var backup = path + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}";
File.Copy(path, backup, overwrite: false); File.Copy(path, backup, overwrite: false);
File.WriteAllText(path, ConfigBootstrapper.ToJson(config)); 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) catch (Exception ex)
{ {

View File

@ -23,7 +23,7 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
var tc = _config.Toolchains; var tc = _config.Toolchains;
if (tc is null) 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(Theme.Faint("Add a \"toolchains\" section with \"python\" and/or \"node\" entries."));
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back...")); AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
Console.ReadKey(intercept: true); Console.ReadKey(intercept: true);

View File

@ -50,9 +50,9 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR
foreach (var proj in projects) foreach (var proj in projects)
{ {
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj); 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 isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
var exists = File.Exists(devtoolPath); var exists = configPath is not null;
var label = isCurrent var label = isCurrent
? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/] [{Theme.GreenDim}](current)[/]" ? $"[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)[/]"; label += $" [{Theme.Amber}](disabled)[/]";
var desc = !exists var desc = !exists
? $" [{Theme.Red}]devtool.json not found[/]" ? $" [{Theme.Red}]project config not found[/]"
: string.IsNullOrWhiteSpace(proj.Description) : string.IsNullOrWhiteSpace(proj.Description)
? $" [{Theme.GreenDim}]{Markup.Escape(absPath)}[/]" ? $" [{Theme.GreenDim}]{Markup.Escape(absPath)}[/]"
: $" [{Theme.GreenDim}]{Markup.Escape(proj.Description)}[/]"; : $" [{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 absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase); 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 var status = proj.Disabled
? Theme.Warn("disabled") ? Theme.Warn("disabled")
: hasConfig ? Theme.Ok("ready") : Theme.Fail("no config"); : hasConfig ? Theme.Ok("ready") : Theme.Fail("no config");
@ -223,15 +223,17 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR
return; return;
} }
var configPath = Path.Combine(absolutePath, "devtool.json"); var configPath = ConfigLoader.FindConfigPath(absolutePath);
if (!File.Exists(configPath)) if (configPath is null)
{ {
var create = AnsiConsole.Confirm( 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); defaultValue: true);
if (!create) if (!create)
return; 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"); 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; return;
} }
var configPath = Path.Combine(absolutePath, "devtool.json"); var configPath = ConfigLoader.FindConfigPath(absolutePath);
if (initializeConfig && !File.Exists(configPath)) if (initializeConfig && configPath is null)
{ {
try try
{ {
var scan = ConfigBootstrapper.Scan(absolutePath); var scan = ConfigBootstrapper.Scan(absolutePath);
var generated = ConfigBootstrapper.BuildDefaultConfig(scan); var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false); configPath = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
} }
catch (Exception ex) 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); Thread.Sleep(900);
return; return;
} }

View File

@ -160,6 +160,24 @@ public sealed class ConfigBootstrapperTests
Assert.Equal("AppB", scan.DotnetWorkingDir, ignoreCase: true); 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() private static string CreateTempDir()
{ {
var path = Path.Combine(Path.GetTempPath(), "sdt-bootstrap-" + Guid.NewGuid().ToString("N")); var path = Path.Combine(Path.GetTempPath(), "sdt-bootstrap-" + Guid.NewGuid().ToString("N"));

View File

@ -180,7 +180,7 @@ public sealed class HeadlessExecutionTests
{ {
var root = Path.Combine(Path.GetTempPath(), "sdt-headless-" + Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "sdt-headless-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root); Directory.CreateDirectory(root);
File.WriteAllText(Path.Combine(root, "devtool.json"), devtoolJson); File.WriteAllText(Path.Combine(root, "sdtconfig-headless.json"), devtoolJson);
return root; return root;
} }
} }

View File

@ -13,7 +13,7 @@ public sealed class ScriptCommonTests
var nested = Path.Combine(root, "src", "app"); var nested = Path.Combine(root, "src", "app");
Directory.CreateDirectory(nested); Directory.CreateDirectory(nested);
await File.WriteAllTextAsync(Path.Combine(root, "sample.sln"), ""); 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", "name": "demo",
"version": "0.1.0", "version": "0.1.0",
@ -35,7 +35,7 @@ public sealed class ScriptCommonTests
var nested = Path.Combine(root, "child", "leaf"); var nested = Path.Combine(root, "child", "leaf");
Directory.CreateDirectory(nested); Directory.CreateDirectory(nested);
Directory.CreateDirectory(Path.Combine(root, ".git")); 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", "name": "demo",
"version": "0.1.0", "version": "0.1.0",

View File

@ -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", "name": "Project A",
"version": "1.0.0", "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", "name": "Project B",
"version": "1.0.0", "version": "1.0.0",

View File

@ -16,7 +16,7 @@ public sealed class WorkspaceInventoryServiceTests
Directory.CreateDirectory(b); Directory.CreateDirectory(b);
Directory.CreateDirectory(a); 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(b, "B.slnx"), "");
File.WriteAllText(Path.Combine(a, "A.csproj"), "<Project />"); File.WriteAllText(Path.Combine(a, "A.csproj"), "<Project />");
@ -64,7 +64,7 @@ public sealed class WorkspaceInventoryServiceTests
var excluded = Path.Combine(root, "node_modules", "Pkg"); var excluded = Path.Combine(root, "node_modules", "Pkg");
Directory.CreateDirectory(excluded); Directory.CreateDirectory(excluded);
File.WriteAllText(Path.Combine(excluded, "Pkg.csproj"), "<Project />"); File.WriteAllText(Path.Combine(excluded, "Pkg.csproj"), "<Project />");
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 service = new WorkspaceInventoryService();
var result = service.Scan(root, root, new WorkspaceInventorySettings(), new WorkspaceConfig()); var result = service.Scan(root, root, new WorkspaceInventorySettings(), new WorkspaceConfig());
@ -82,7 +82,7 @@ public sealed class WorkspaceInventoryServiceTests
Directory.CreateDirectory(b); Directory.CreateDirectory(b);
File.WriteAllText(Path.Combine(a, "a.csproj"), "<Project />"); File.WriteAllText(Path.Combine(a, "a.csproj"), "<Project />");
File.WriteAllText(Path.Combine(b, "b.csproj"), "<Project />"); File.WriteAllText(Path.Combine(b, "b.csproj"), "<Project />");
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 service = new WorkspaceInventoryService();
var first = service.Scan(root, root, new WorkspaceInventorySettings(), new WorkspaceConfig()); var first = service.Scan(root, root, new WorkspaceInventorySettings(), new WorkspaceConfig());

View File

@ -10,7 +10,7 @@ public sealed class WorkspaceLoaderTests
{ {
var root = Path.Combine(Path.GetTempPath(), "sdt-workspace-" + Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "sdt-workspace-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root); Directory.CreateDirectory(root);
File.WriteAllText(Path.Combine(root, "devtool.json"), """ File.WriteAllText(Path.Combine(root, "sdtconfig-legacy.json"), """
{ {
"name": "legacy", "name": "legacy",
"version": "0.1.0", "version": "0.1.0",
@ -72,7 +72,7 @@ public sealed class WorkspaceLoaderTests
var markerOnly = Path.Combine(workspaceRoot, "marker"); var markerOnly = Path.Combine(workspaceRoot, "marker");
Directory.CreateDirectory(current); Directory.CreateDirectory(current);
Directory.CreateDirectory(markerOnly); 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"), "<Project />"); File.WriteAllText(Path.Combine(markerOnly, "marker.csproj"), "<Project />");
var loaded = WorkspaceLoader.FindAndLoad(current); var loaded = WorkspaceLoader.FindAndLoad(current);