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)
{
AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No devtool.json found[/] in current directory or any parent.");
AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No SDT project config found[/] (expected `sdtconfig-*.json` or `devtool.json`) in current directory or any parent.");
var bootstrap = AnsiConsole.Confirm(
$"[{Theme.Amber}]Generate a default devtool.json for this project now?[/]",
$"[{Theme.Amber}]Generate a default SDT project config for this project now?[/]",
defaultValue: true);
if (!bootstrap)
{
AnsiConsole.MarkupLine(Theme.Faint("Create a devtool.json in your project root to get started."));
AnsiConsole.MarkupLine(Theme.Faint("Create an sdtconfig-<ProjectName>.json (or devtool.json) in your project root to get started."));
return 1;
}
@ -53,10 +53,10 @@ try
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
var preview = ConfigBootstrapper.ToJson(generated);
AnsiConsole.Write(new Panel(Markup.Escape(preview)).Header("Generated devtool.json preview").BorderStyle(Theme.DimStyle));
AnsiConsole.Write(new Panel(Markup.Escape(preview)).Header("Generated project config preview").BorderStyle(Theme.DimStyle));
var confirmWrite = AnsiConsole.Confirm(
$"[{Theme.Amber}]Write generated devtool.json to {scan.ProjectRoot}?[/]",
$"[{Theme.Amber}]Write generated project config to {scan.ProjectRoot}?[/]",
defaultValue: true);
if (!confirmWrite)
return 1;
@ -110,7 +110,7 @@ try
}
if (loaded is null)
{
AnsiConsole.MarkupLine(Theme.Fail($"No devtool.json found at: {result.NewProjectRoot}"));
AnsiConsole.MarkupLine(Theme.Fail($"No SDT project config found at: {result.NewProjectRoot}"));
AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
Console.ReadKey(intercept: true);
continue;
@ -289,7 +289,7 @@ static async Task<int> RunHeadlessAsync(IReadOnlyList<string> cliArgs, string ki
var loaded = ConfigLoader.FindAndLoad(startDir);
if (loaded is null)
{
Console.WriteLine("{\"success\":false,\"message\":\"No devtool.json found for headless command.\"}");
Console.WriteLine("{\"success\":false,\"message\":\"No SDT project config found (expected sdtconfig-*.json or devtool.json).\"}");
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
}

View File

@ -47,7 +47,7 @@
## In Progress (next focus)
- [ ] Execute full OS matrix verification on Windows/Linux/macOS runners
- [ ] Execute full OS matrix verification on Linux runners
- [ ] Native GUI shell over headless core services (Tauri-first in v1.x; Avalonia re-evaluate later)
- [x] GUI parity phase 1 bridge foundation shipped (`sdt bridge --stdio` + `DevTool.Host.Bridge`)
- [x] GUI debug + failure UX slice shipped (debug run, failure card, run context/lifecycle panel)
@ -66,7 +66,6 @@
- [x] Bootstrap first Tauri command bridge: `sdt workspace scan --json`
- [x] Structural refactor to domain-separated projects under `src/` (`DevTool.Engine`, `DevTool.Runtime`, `DevTool.Host.Tui`)
- [x] Add second Tauri bridge command: `sdt run <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] Expand GUI command palette coverage across workspace/run/setup/history/events/favorites actions
- [x] Add full GUI env var definition editor parity (`env[]` model editing with validation)
@ -88,7 +87,7 @@
- [x] Add project-type matrix coverage tests (`dotnet`, `node/npm`, `tauri/cargo`)
- [x] Add deterministic headless stop-reason/exit-code tests
- [ ] Execute full OS matrix verification on Windows/Linux/macOS runners
- [ ] Execute Full OS matrix verification on Linux runners because the linux runner in theory should be able to be able to build/publish/release to windows/linux/ macos (but fuck apple.)
- [x] Publish reliability matrix runbook + results artifact in docs
- Blocked in current local workspace: no `.git` repo context and no `gh` CLI/auth; execute from Git-connected checkout.
- v1.4 policy: ship Windows/Linux, macOS delegated with checklist in `docs/matrix-status.md`.

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,15 @@ function Clear-SdtProxyEnv {
Remove-Item Env:PIP_NO_INDEX -ErrorAction SilentlyContinue
}
function Test-SdtConfigExists {
param([string]$Path)
if (Test-Path (Join-Path $Path "devtool.json")) {
return $true
}
$sdtConfigs = Get-ChildItem -Path $Path -Filter "sdtconfig-*.json" -File -ErrorAction SilentlyContinue
return ($null -ne $sdtConfigs) -and ($sdtConfigs.Count -gt 0)
}
function Resolve-SdtRepoRoot {
param([string]$StartPath)
@ -34,7 +43,7 @@ function Resolve-SdtRepoRoot {
}
if (-not [string]::IsNullOrWhiteSpace($override)) {
$overridePath = [System.IO.Path]::GetFullPath($override)
if (Test-Path (Join-Path $overridePath "devtool.json")) {
if (Test-SdtConfigExists -Path $overridePath) {
return $overridePath
}
}
@ -42,7 +51,7 @@ function Resolve-SdtRepoRoot {
foreach ($start in $candidateStarts) {
$cursor = [System.IO.Path]::GetFullPath($start)
while (-not [string]::IsNullOrWhiteSpace($cursor)) {
if (Test-Path (Join-Path $cursor "devtool.json")) {
if (Test-SdtConfigExists -Path $cursor) {
return $cursor
}
$parent = [System.IO.Directory]::GetParent($cursor)
@ -65,7 +74,7 @@ function Resolve-SdtRepoRoot {
}
}
throw "Could not locate repository root. Ensure a devtool.json exists in the project root."
throw "Could not locate repository root. Ensure a project config (sdtconfig-*.json or devtool.json) exists in the project root."
}
function Initialize-SdtDotnetEnv {

View File

@ -6,357 +6,298 @@ import pathlib
import shutil
import subprocess
import sys
from typing import Dict, Iterable, List, Sequence
import time
from typing import Any, Iterable, Optional, Sequence
# --- Domain: SDT Types ---
# SDT Normalized Result Object
# result["exit_code"]: int
# result["status"]: "ok" | "failed" | "skipped"
# result["elapsed_seconds"]: float
# result["failure_reason"]: Optional[str]
# result["skip_reason"]: Optional[str]
SdtResult = dict[str, Any]
PROXY_VARS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"GIT_HTTP_PROXY",
"GIT_HTTPS_PROXY",
"PIP_NO_INDEX",
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy",
"GIT_HTTP_PROXY", "GIT_HTTPS_PROXY", "PIP_NO_INDEX"
]
# --- Domain: FS Utilities ---
def resolve_repo_root(start: str | None = None) -> pathlib.Path:
base = pathlib.Path(start or os.getcwd()).resolve()
def sha256_files(paths: Iterable[pathlib.Path]) -> str:
h = hashlib.sha256()
for p in sorted(paths):
if p.exists(): h.update(p.read_bytes())
return h.hexdigest()
# Preferred marker for SDT-managed projects.
for cur in [base, *base.parents]:
cfg = cur / "devtool.json"
if cfg.exists():
hints = load_project_root_hints(cur)
if not hints:
return cur
if any(_hint_matches(cur, hint) for hint in hints):
return cur
# Fall back to git root when available.
try:
proc = subprocess.run(
["git", "-C", str(base), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if proc.returncode == 0:
git_root = proc.stdout.strip()
if git_root:
return pathlib.Path(git_root).resolve()
except Exception:
pass
return base
def load_project_root_hints(repo_root: pathlib.Path) -> list[str]:
cfg = repo_root / "devtool.json"
if not cfg.exists():
return []
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
hints = data.get("project", {}).get("rootHints", [])
return [str(x) for x in hints if isinstance(x, str) and x.strip()]
except Exception:
return []
def ensure_dirs(paths: List[pathlib.Path]) -> None:
def first_existing(paths: Iterable[pathlib.Path]) -> Optional[pathlib.Path]:
for p in paths:
p.mkdir(parents=True, exist_ok=True)
if p.exists(): return p
return None
def newest_file(search_root: pathlib.Path, pattern: str) -> Optional[pathlib.Path]:
hits = sorted(search_root.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
return hits[0] if hits else None
def clean_proxy_env(env: Dict[str, str]) -> None:
for k in PROXY_VARS:
env.pop(k, None)
def dotnet_env(repo_root: pathlib.Path) -> Dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
dotnet_cli_home = repo_root / ".dotnet_home"
nuget_packages = repo_root / ".nuget" / "packages"
nuget_http_cache = repo_root / ".nuget" / "http-cache"
ensure_dirs([dotnet_cli_home, nuget_packages, nuget_http_cache])
env["DOTNET_CLI_HOME"] = str(dotnet_cli_home)
env["NUGET_PACKAGES"] = str(nuget_packages)
env["NUGET_HTTP_CACHE_PATH"] = str(nuget_http_cache)
env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
env["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "0"
env["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "0"
env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
env["NUGET_CERT_REVOCATION_MODE"] = "offline"
return env
def pip_env(repo_root: pathlib.Path) -> Dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
pip_cache = repo_root / ".pip" / "cache"
pip_tmp = repo_root / ".tmp" / "pip-temp"
ensure_dirs([pip_cache, pip_tmp])
env["PIP_CACHE_DIR"] = str(pip_cache)
env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
env["PIP_DEFAULT_TIMEOUT"] = "30"
env["PIP_RETRIES"] = "2"
env["TEMP"] = str(pip_tmp)
env["TMP"] = str(pip_tmp)
return env
def run(command: str, args: List[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> int:
resolved = resolve_command(command)
try:
proc = subprocess.run([resolved, *args], cwd=str(cwd), env=env, check=False)
return proc.returncode
except FileNotFoundError:
print(f"Command not found: {resolved}", file=sys.stderr)
return 127
def run_capture(command: str, args: Sequence[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> tuple[int, str, str]:
resolved = resolve_command(command)
try:
proc = subprocess.run(
[resolved, *args],
cwd=str(cwd),
env=env,
capture_output=True,
text=True,
check=False,
)
return proc.returncode, proc.stdout, proc.stderr
except FileNotFoundError:
return 127, "", f"Command not found: {resolved}"
def resolve_command(command: str) -> str:
if not command:
return command
if os.name != "nt":
return command
if any(sep in command for sep in ("\\", "/")):
return command
if pathlib.Path(command).suffix:
found = shutil.which(command)
return found or command
candidates = []
lowered = command.lower()
if lowered in ("npm", "npx", "pnpm", "yarn", "tauri"):
candidates.extend([f"{command}.cmd", f"{command}.exe", f"{command}.bat", command])
else:
candidates.append(command)
for c in candidates:
found = _which_windows(c)
if found:
name = pathlib.Path(found).name.lower()
if name in ("npm", "npx", "pnpm", "yarn", "tauri"):
shim = pathlib.Path(found).with_name(name + ".cmd")
if shim.exists():
return str(shim)
return found
if lowered in ("npm", "npx", "pnpm", "yarn"):
node = _which_windows("node.exe") or _which_windows("node")
if node:
node_dir = pathlib.Path(node).parent
shim = node_dir / f"{lowered}.cmd"
if shim.exists():
return str(shim)
return candidates[-1]
def ensure_dirs(paths: list[pathlib.Path]) -> None:
for p in paths: p.mkdir(parents=True, exist_ok=True)
def _hint_matches(root: pathlib.Path, hint: str) -> bool:
h = hint.strip()
if not h:
return False
has_glob = any(ch in h for ch in ("*", "?", "["))
if has_glob:
# Match both anywhere in root and directly at root-level for common hints like "*.sln".
if any(root.glob(h)):
return True
return any(root.rglob(h))
marker = root / h
if marker.exists():
return True
# If hint is just a filename marker, look bounded in tree.
if not any(sep in h for sep in ("\\", "/")):
return any(p.name == h for p in root.rglob(h))
return False
def _expand_windows_path_segment(segment: str) -> str:
expanded = segment
# Expand %VAR% tokens repeatedly for nested references.
for _ in range(4):
next_value = os.path.expandvars(expanded)
if next_value == expanded:
break
expanded = next_value
return expanded
def _which_windows(command: str) -> str | None:
found = shutil.which(command)
if found:
return found
if os.name != "nt":
return None
path_value = os.environ.get("PATH", "")
pathext = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD")
exts = [e.lower() for e in pathext.split(";") if e]
has_ext = pathlib.Path(command).suffix != ""
names = [command] if has_ext else [command, *(command + e.lower() for e in exts)]
for raw_segment in path_value.split(os.pathsep):
segment = _expand_windows_path_segment(raw_segment.strip())
if not segment:
continue
base = pathlib.Path(segment)
for name in names:
candidate = base / name
if candidate.exists():
return str(candidate)
return None
def sha256_files(paths: Iterable[pathlib.Path]) -> str:
h = hashlib.sha256()
for p in paths:
if not p.exists():
continue
h.update(p.read_bytes())
return h.hexdigest()
def first_existing(paths: Iterable[pathlib.Path]) -> pathlib.Path | None:
for p in paths:
if p.exists():
return p
return None
def find_csproj(repo_root: pathlib.Path, hints: Sequence[str] | None = None) -> pathlib.Path | None:
if hints:
for hint in hints:
candidate = (repo_root / hint).resolve()
if candidate.exists() and candidate.suffix.lower() == ".csproj":
return candidate
csprojs = sorted(repo_root.rglob("*.csproj"))
if not csprojs:
return None
if len(csprojs) == 1:
return csprojs[0]
return None
def find_csproj_by_keyword(repo_root: pathlib.Path, keywords: Sequence[str]) -> pathlib.Path | None:
kws = [k.lower() for k in keywords]
matches: list[pathlib.Path] = []
for p in repo_root.rglob("*.csproj"):
text = str(p).lower()
if any(k in text for k in kws):
matches.append(p)
if len(matches) == 1:
return matches[0]
return None
def find_node_app_root(repo_root: pathlib.Path, preferred: str | None = None) -> pathlib.Path | None:
def _read_package_json(package_json: pathlib.Path) -> dict | None:
if not package_json.exists():
return None
try:
data = json.loads(package_json.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else None
except Exception:
return None
def _has_scripts(package_json: pathlib.Path, names: Sequence[str]) -> bool:
data = _read_package_json(package_json)
if not data:
return False
scripts = data.get("scripts")
if not isinstance(scripts, dict):
return False
for name in names:
value = scripts.get(name)
if isinstance(value, str) and value.strip():
try:
has_glob = any(ch in h for ch in ("*", "?", "["))
if has_glob:
if any(root.glob(h)):
return True
return any(root.rglob(h))
marker = root / h
if marker.exists():
return True
# If hint is a plain filename marker, allow bounded search in root tree.
if not any(sep in h for sep in ("/", "\\")):
return any(p.name == h for p in root.rglob(h))
return False
except:
return False
def _is_tauri_root(candidate_dir: pathlib.Path) -> bool:
return (candidate_dir / "src-tauri" / "tauri.conf.json").exists() or (candidate_dir / "tauri.conf.json").exists()
# --- Domain: Project Discovery ---
def _iter_package_jsons() -> list[pathlib.Path]:
excluded = {"node_modules", "dist", "build", ".git", ".sdt", ".venv", "venv", "bin", "obj"}
found: list[pathlib.Path] = []
for current_root, dirs, files in os.walk(repo_root):
dirs[:] = [d for d in dirs if d not in excluded]
if "package.json" in files:
found.append(pathlib.Path(current_root) / "package.json")
found.sort(key=lambda p: len(p.parts))
return found
def resolve_repo_root(start: str | None = None) -> pathlib.Path:
base = pathlib.Path(start or os.getcwd()).resolve()
for cur in [base, *base.parents]:
sdt_configs = list(cur.glob("sdtconfig-*.json"))
cfg = sdt_configs[0] if sdt_configs else (cur / "devtool.json")
if cfg.exists():
hints = load_project_root_hints(cur, cfg)
if not hints or any(_hint_matches(cur, hint) for hint in hints): return cur
try:
proc = subprocess.run(["git", "-C", str(base), "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False)
if proc.returncode == 0 and proc.stdout.strip(): return pathlib.Path(proc.stdout.strip()).resolve()
except: pass
return base
if preferred:
p = (repo_root / preferred).resolve()
package_json = p / "package.json"
if package_json.exists():
# Keep explicit preferred root only when it appears runnable for node workflows.
if _is_tauri_root(p) or _has_scripts(package_json, ("build", "dev", "start", "test", "tauri")):
return p
def load_project_root_hints(repo_root: pathlib.Path, cfg: pathlib.Path | None = None) -> list[str]:
if cfg is None:
sdt_configs = list(repo_root.glob("sdtconfig-*.json"))
cfg = sdt_configs[0] if sdt_configs else (repo_root / "devtool.json")
if not cfg.exists(): return []
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
hints = data.get("project", {}).get("rootHints", [])
return [str(x) for x in hints if isinstance(x, str) and x.strip()]
except: return []
package_files = _iter_package_jsons()
if not package_files:
return None
# Strong preference: a tauri app root with tauri config and package.json.
tauri_candidates = [p.parent for p in package_files if _is_tauri_root(p.parent)]
if len(tauri_candidates) == 1:
return tauri_candidates[0]
if len(tauri_candidates) > 1:
tauri_candidates.sort(key=lambda p: len(p.parts))
return tauri_candidates[0]
runnable_candidates = [
p.parent for p in package_files if _has_scripts(p, ("build", "dev", "start", "test", "tauri"))
]
if len(runnable_candidates) == 1:
return runnable_candidates[0]
if len(runnable_candidates) > 1:
runnable_candidates.sort(key=lambda p: len(p.parts))
return runnable_candidates[0]
# As a last fallback, return unique package root only.
if len(package_files) == 1:
return package_files[0].parent
def _resolve_project_config_path(repo_root: pathlib.Path) -> Optional[pathlib.Path]:
sdt_configs = sorted(repo_root.glob("sdtconfig-*.json"), key=lambda p: p.name.lower())
if sdt_configs:
return sdt_configs[0]
legacy = repo_root / "devtool.json"
if legacy.exists():
return legacy
return None
def load_node_working_dir(repo_root: pathlib.Path) -> Optional[str]:
cfg = _resolve_project_config_path(repo_root)
if cfg is None:
return None
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
node = data.get("toolchains", {}).get("node", {})
value = node.get("workingDir")
if isinstance(value, str) and value.strip():
return value.strip()
except:
pass
return None
def newest_file(search_root: pathlib.Path, pattern: str) -> pathlib.Path | None:
if not search_root.exists():
return None
files = [p for p in search_root.rglob(pattern) if p.is_file() and "\\obj\\" not in str(p).replace("/", "\\")]
if not files:
return None
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return files[0]
def find_csproj(repo_root: pathlib.Path, hints: Sequence[str] | None = None) -> Optional[pathlib.Path]:
if hints:
for h in hints:
p = (repo_root / h).resolve()
if p.exists() and p.suffix == ".csproj": return p
hits = [h for h in repo_root.rglob("*.csproj") if not any(x in h.parts for x in [".git", "node_modules", "bin", "obj"])]
return hits[0] if len(hits) == 1 else None
def find_csproj_by_keyword(repo_root: pathlib.Path, keywords: Sequence[str]) -> Optional[pathlib.Path]:
hits = [h for h in repo_root.rglob("*.csproj") if not any(x in h.parts for x in [".git", "node_modules", "bin", "obj"])]
for kw in keywords:
matches = [h for h in hits if kw.lower() in h.name.lower()]
if len(matches) == 1: return matches[0]
return None
def find_node_app_root(repo_root: pathlib.Path, preferred: str | None = None) -> Optional[pathlib.Path]:
def _read_pj(p: pathlib.Path):
try: return json.loads(p.read_text(encoding="utf-8"))
except: return None
def _has_scr(p: pathlib.Path, names: Sequence[str]):
src = _read_pj(p)
return any(src.get("scripts", {}).get(n) for n in names) if src else False
def _is_tauri(d: pathlib.Path): return (d / "src-tauri" / "tauri.conf.json").exists() or (d / "tauri.conf.json").exists()
def _iter_pj():
excluded = {".git", "node_modules", ".sdt", "dist", "build", ".venv", "venv", "bin", "obj"}
for p in repo_root.rglob("package.json"):
if any(x in p.parts for x in excluded):
continue
yield p
if not preferred:
preferred = load_node_working_dir(repo_root)
if preferred:
p = (repo_root / preferred).resolve()
p = p.parent if p.is_file() else p
if (p / "package.json").exists():
return p
tauri = [p.parent for p in _iter_pj() if _is_tauri(p.parent)]
if len(tauri) == 1: return tauri[0]
if len(tauri) > 1:
tauri = sorted(set(tauri), key=lambda d: (len(d.parts), str(d).lower()))
return tauri[0]
scripts = [p.parent for p in _iter_pj() if _has_scr(p, ["tauri", "build"])]
if len(scripts) == 1: return scripts[0]
if len(scripts) > 1:
scripts = sorted(set(scripts), key=lambda d: (len(d.parts), str(d).lower()))
return scripts[0]
all_pj = [p.parent for p in _iter_pj()]
return all_pj[0] if len(all_pj) == 1 else None
# --- Domain: Environment Setup ---
def clean_proxy_env(env: dict[str, str]) -> None:
for k in PROXY_VARS: env.pop(k, None)
def dotnet_env(repo_root: pathlib.Path) -> dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
h, p, c = repo_root/".dotnet_home", repo_root/".nuget"/"packages", repo_root/".nuget"/"http-cache"
ensure_dirs([h, p, c])
env.update({"DOTNET_CLI_HOME":str(h), "NUGET_PACKAGES":str(p), "NUGET_HTTP_CACHE_PATH":str(c),
"DOTNET_SKIP_FIRST_TIME_EXPERIENCE":"1", "DOTNET_ADD_GLOBAL_TOOLS_TO_PATH":"0",
"DOTNET_GENERATE_ASPNET_CERTIFICATE":"0",
"DOTNET_CLI_TELEMETRY_OPTOUT":"1", "NUGET_CERT_REVOCATION_MODE":"offline"})
return env
def pip_env(repo_root: pathlib.Path) -> dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
c, t = repo_root/".pip"/"cache", repo_root/".tmp"/"pip-temp"
ensure_dirs([c, t])
env.update({"PIP_CACHE_DIR":str(c), "PIP_DISABLE_PIP_VERSION_CHECK":"1", "PIP_DEFAULT_TIMEOUT":"30", "PIP_RETRIES":"2", "TEMP":str(t), "TMP":str(t)})
return env
# --- Domain: Process Execution ---
def resolve_command(command: str) -> str:
if not command or os.name != "nt" or any(s in command for s in ("\\", "/")): return command
if pathlib.Path(command).suffix: return shutil.which(command) or command
low = command.lower()
cands = [f"{command}.cmd", f"{command}.exe", f"{command}.bat", command] if low in ("npm", "npx", "pnpm", "yarn", "tauri") else [command]
for c in cands:
found = _which_windows(c)
if found:
if pathlib.Path(found).name.lower() in ("npm", "npx", "pnpm", "yarn", "tauri"):
shim = pathlib.Path(found).with_name(pathlib.Path(found).name.lower() + ".cmd")
if shim.exists(): return str(shim)
return found
if low in ("npm", "npx", "pnpm", "yarn"):
node = _which_windows("node.exe") or _which_windows("node")
if node and (pathlib.Path(node).parent / f"{low}.cmd").exists(): return str(pathlib.Path(node).parent / f"{low}.cmd")
return cands[-1]
def _which_windows(command: str) -> Optional[str]:
found = shutil.which(command)
if found: return str(pathlib.Path(found).resolve())
for p in ["C:\\Program Files\\dotnet", "C:\\Program Files\\nodejs", "C:\\Program Files\\Git\\bin", "C:\\Program Files\\Git\\usr\\bin"]:
if (pathlib.Path(p) / command).exists(): return str((pathlib.Path(p) / command).resolve())
return None
def run(command: str, args: list[str], cwd: pathlib.Path, env: dict[str, str] | None = None) -> int:
try:
proc = subprocess.run([resolve_command(command), *args], cwd=str(cwd), env=env, check=False)
return proc.returncode
except: return 127
def run_capture(command: str, args: Sequence[str], cwd: pathlib.Path, env: dict[str, str] | None = None) -> tuple[int, str, str]:
try:
proc = subprocess.run([resolve_command(command), *args], cwd=str(cwd), env=env, capture_output=True, text=True, check=False, encoding="utf-8", errors="replace")
return proc.returncode, proc.stdout, proc.stderr
except: return 127, "", "Command not found"
def _safe_stream_write(stream: Any, text: str) -> None:
if not text:
return
try:
stream.write(text)
return
except UnicodeEncodeError:
pass
encoding = getattr(stream, "encoding", None) or "utf-8"
data = text.encode(encoding, errors="replace")
buffer = getattr(stream, "buffer", None)
if buffer is not None:
buffer.write(data)
buffer.flush()
return
# Last resort when no binary buffer is exposed.
stream.write(data.decode(encoding, errors="replace"))
def run_step_with_summary(label: str, command: str, args: Sequence[str], cwd: pathlib.Path, env: dict[str, str] | None = None) -> SdtResult:
print(f"\n> {label}\n$ {command} {' '.join(args)}")
start = time.time()
code, out, err = run_capture(command, args, cwd, env)
elapsed = round(time.time() - start, 3)
if out: _safe_stream_write(sys.stdout, out)
if err: _safe_stream_write(sys.stderr, err)
return {"exit_code": code, "status": "ok" if code == 0 else "failed", "elapsed_seconds": elapsed, "stdout": out, "stderr": err, "failure_reason": None, "skip_reason": None}
# --- Domain: High-Level Build Logic ---
def ensure_dotnet_publish(csproj: pathlib.Path, output_dir: pathlib.Path, configuration: str = "Release", runtime: str = "win-x64", single_file: bool = False, self_contained: bool = True) -> SdtResult:
repo_root = resolve_repo_root(str(csproj.parent))
env = dotnet_env(repo_root)
args = ["publish", str(csproj), "-c", configuration, "-r", runtime, "--self-contained", "true" if self_contained else "false",
f"-p:PublishSingleFile={'true' if single_file else 'false'}", "-p:RestoreIgnoreFailedSources=true", "-p:NuGetAudit=false", "-o", str(output_dir)]
if single_file: args.append("-p:IncludeNativeLibrariesForSelfExtract=true")
res = run_step_with_summary(f"Publishing {csproj.name}", "dotnet", args, repo_root, env)
if res["exit_code"] != 0 and single_file:
comb = (res["stdout"] + res["stderr"]).lower()
if "generatebundle" in comb and "same bundlerelativepath" in comb:
print("Duplicate bundle entries detected; retrying without single-file optimization...")
retry = [a if "PublishSingleFile=true" not in a else "-p:PublishSingleFile=false" for a in args]
res = run_step_with_summary(f"Publishing {csproj.name} (retry)", "dotnet", retry, repo_root, env)
if res["exit_code"] != 0:
comb = (res["stdout"] + res["stderr"]).lower()
if "netsdk1152" in comb and "multiple publish output files with the same relative path" in comb:
print("Duplicate publish output files detected (NETSDK1152); retrying with ErrorOnDuplicatePublishOutputFiles=false...")
retry = list(args)
if not any("ErrorOnDuplicatePublishOutputFiles" in a for a in retry):
retry.append("-p:ErrorOnDuplicatePublishOutputFiles=false")
res = run_step_with_summary(f"Publishing {csproj.name} (dedupe retry)", "dotnet", retry, repo_root, env)
return res
def ensure_npm_build(app_root: pathlib.Path, target: str = "web", configuration: str = "Release", tauri_bundles: str = "none") -> SdtResult:
pj, lock = app_root / "package.json", first_existing([app_root / "package-lock.json", app_root / "npm-shrinkwrap.json"])
nm = app_root / "node_modules"
h_file, exp_h = nm / ".sdt-deps.sha256", sha256_files([pj, lock] if lock else [pj])
should_inst = not nm.exists() or not h_file.exists() or h_file.read_text(encoding="utf-8").strip() != exp_h
if should_inst:
args = ["ci", "--no-audit", "--fund=false"] if lock else ["install", "--no-audit", "--fund=false"]
res = run_step_with_summary("Installing NPM dependencies", "npm", args, app_root)
if res["exit_code"] != 0 and lock and args[0] == "ci":
res = run_step_with_summary("Installing NPM dependencies (retry)", "npm", ["install", "--no-audit", "--fund=false"], app_root)
if res["exit_code"] != 0: return res
ensure_dirs([nm]); h_file.write_text(exp_h, encoding="utf-8")
if target == "web": return run_step_with_summary(f"Building Web ({configuration})", "npm", ["run", "build"], app_root)
t_cmd = ["run", "tauri", "build"]
tail = (["--no-bundle"] if tauri_bundles == "none" else ["--bundles", tauri_bundles]) + (["--debug"] if configuration == "Debug" else [])
if tail: t_cmd.extend(["--", *tail])
return run_step_with_summary(f"Building Tauri ({configuration})", "npm", t_cmd, app_root)

View File

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

View File

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

View File

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

View File

@ -28,7 +28,7 @@ public static class ConfigLoader
};
/// <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.
/// </summary>
public static string? FindConfigPath(string? startDir = null)
@ -36,10 +36,14 @@ public static class ConfigLoader
var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory());
while (dir is not null)
{
var sdtCandidates = dir.GetFiles("sdtconfig-*.json");
if (sdtCandidates.Length > 0)
return sdtCandidates[0].FullName;
var candidate = Path.Combine(dir.FullName, "devtool.json");
if (File.Exists(candidate))
return candidate;
dir = dir.Parent!;
dir = dir.Parent;
}
return null;
}
@ -76,7 +80,7 @@ public static class ConfigLoader
throw new InvalidOperationException(
$"Legacy targets-only config detected at {configPath}. Strict mode requires workflows. " +
"Use migration preview file 'devtool.generated.workflows.json' and migrate devtool.json. " +
"Use migration preview file 'devtool.generated.workflows.json' and migrate your config. " +
"Temporary rollback: set SDT_LEGACY_MODE=compat.");
}
@ -87,7 +91,7 @@ public static class ConfigLoader
catch (Exception ex)
{
throw new InvalidOperationException(
$"Failed to parse devtool.json at {configPath}: {ex.Message}", ex);
$"Failed to parse config at {configPath}: {ex.Message}", ex);
}
}
@ -102,7 +106,7 @@ public static class ConfigLoader
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<DevToolConfig>(json, JsonOptions)
?? throw new InvalidOperationException("devtool.json deserialized to null.");
?? throw new InvalidOperationException("Config deserialized to null.");
if (config.Targets.Count == 0)
return new LegacyMigrationApplyResult(false, "No legacy targets found to migrate.", ConfigPath: configPath);
@ -200,7 +204,7 @@ public static class ConfigLoader
private static DevToolConfig DeserializeConfig(JsonObject obj, string sourcePath)
{
return obj.Deserialize<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)

View File

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

View File

@ -15,7 +15,7 @@ public sealed class WorkspaceProject
/// <summary>
/// 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>
public string Path { get; init; } = "";
public List<string> Tags { get; init; } = [];
@ -65,5 +65,6 @@ public sealed class WorkspaceInventorySettings
"*.sln",
"*.csproj",
"devtool.json",
"sdtconfig-*.json",
];
}

View File

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

View File

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

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

View File

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

View File

@ -1,4 +1,5 @@
using Sdt.Config;
using Sdt.Runner;
namespace Sdt.Core;
@ -222,12 +223,73 @@ public sealed class WorkflowExecutor(
}
}
var runResult = await _actionRunner.RunStepAsync(
step,
projectRoot,
onOutput,
envOverrides,
cancellationToken).ConfigureAwait(false);
RunResult runResult;
int retryCount = 0;
const int MaxRetries = 1;
while (true)
{
var capturedOutput = new List<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(
workflow.Id,
@ -273,4 +335,32 @@ public sealed class WorkflowExecutor(
Message: "Workflow completed successfully.",
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))
throw new BridgeValidationException($"Candidate path does not exist: {absoluteCandidate}");
if (initConfig && !File.Exists(Path.Combine(absoluteCandidate, "devtool.json")))
if (initConfig && ConfigLoader.FindConfigPath(absoluteCandidate) is null)
{
var scan = ConfigBootstrapper.Scan(absoluteCandidate);
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
@ -632,7 +632,7 @@ public sealed class BridgeStdioServer
private LoadedProjectConfig LoadProject(JsonElement @params)
{
var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory();
return ConfigLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No devtool.json found for project.");
return ConfigLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No project config found for project.");
}
private string ResolveProjectRootForProjectScopedMethod(JsonElement @params)
@ -684,12 +684,12 @@ public sealed class BridgeStdioServer
{
var path = ConfigLoader.FindConfigPath(projectRoot);
if (string.IsNullOrWhiteSpace(path))
return new LegacyMigrationApplyResult(false, "Could not find devtool.json for saving.");
return new LegacyMigrationApplyResult(false, "Could not find project config for saving.");
var backup = path + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}";
File.Copy(path, backup, overwrite: false);
File.WriteAllText(path, ConfigBootstrapper.ToJson(config));
return new LegacyMigrationApplyResult(true, "Saved updated devtool.json.", BackupPath: backup, ConfigPath: path);
return new LegacyMigrationApplyResult(true, "Saved updated project config.", BackupPath: backup, ConfigPath: path);
}
catch (Exception ex)
{

View File

@ -51,7 +51,7 @@ public sealed class App
_startupWorkflowId = startupWorkflowId;
_activeEnvProfile = config.EnvProfiles?.Active;
var normalized = WorkflowModelBuilder.Normalize(config, ResolveLegacyMode(), _requirementResolver);
_workflows = normalized.Workflows.ToList();
_workflows = normalized.Workflows.Where(IsWorkflowApplicable).ToList();
_warnings = [];
_debugRunner = new DebugProfileRunner(new ToolProbeService(), new PrereqInstallerService());
_diagnostics = new DiagnosticsBundleService();
@ -60,6 +60,30 @@ public sealed class App
_warnings.AddRange(normalized.Warnings);
}
private bool IsWorkflowApplicable(WorkflowDefinition workflow)
{
if (workflow.RequireFiles is null || workflow.RequireFiles.Count == 0)
return true;
foreach (var req in workflow.RequireFiles)
{
var path = Path.GetFullPath(Path.Combine(_projectRoot, req));
if (!File.Exists(path) && !Directory.Exists(path))
{
if (req.Contains('*'))
{
var dir = Path.GetDirectoryName(path);
if (string.IsNullOrWhiteSpace(dir)) dir = _projectRoot;
var file = Path.GetFileName(req);
if (Directory.Exists(dir) && Directory.EnumerateFileSystemEntries(dir, file).Any())
continue;
}
return false;
}
}
return true;
}
private static LegacyMode ResolveLegacyMode()
{
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
@ -364,7 +388,7 @@ public sealed class App
if (_config.Targets.Count > 0)
systemItems.Insert(0, new MenuItem(
$"[{Theme.Green}]⇪ Migrate legacy targets → workflows[/] [{Theme.GreenDim}]writes devtool.json + backup[/]",
$"[{Theme.Green}]⇪ Migrate legacy targets → workflows[/] [{Theme.GreenDim}]writes project config + backup[/]",
"__migrate_legacy__"));
systemItems.Add(new MenuItem($"[{Theme.GreenDim}]✗ Quit[/]", "__quit__"));

View File

@ -26,6 +26,37 @@ public sealed class SetupWizardScreen
Emit(new RunEvent("setup", RunEventType.WorkflowStarted, "Setup wizard started."));
Emit(new RunEvent("setup", RunEventType.WorkflowPlanned, "Setup lifecycle initialized."));
if (!nonInteractive)
{
bool isNewProject = true;
try
{
isNewProject = !Directory.EnumerateFileSystemEntries(projectRoot)
.Any(x => !x.EndsWith(".sdt") && !x.EndsWith(".git") && !x.EndsWith("sdtconfig"));
}
catch {}
var runWizard = false;
if (isNewProject)
{
runWizard = AnsiConsole.Confirm($"\n[{Theme.Amber}]This appears to be an empty directory. Would you like to scaffold a new project?[/]", defaultValue: true);
}
else
{
runWizard = AnsiConsole.Confirm($"\n[{Theme.Amber}]Would you like to scaffold a new project in this directory?[/]", defaultValue: false);
}
if (runWizard)
{
await RunScaffoldingWizardAsync(projectRoot);
// Clear and re-render header because scaffolding can produce a lot of output
AnsiConsole.Clear();
AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor));
AnsiConsole.Write(new Rule($"[bold {Theme.GreenBold}]Setup Wizard[/]").RuleStyle(Theme.DimStyle));
AnsiConsole.MarkupLine(Theme.Faint($"root: {projectRoot}") + "\n");
}
}
AnsiConsole.MarkupLine(Theme.G("Step 1/4: Running config doctor..."));
Emit(new RunEvent("setup", RunEventType.WorkflowStepStarted, "Running config doctor.", StepId: "setup:doctor"));
var doctor = new ConfigDoctorService(new ToolProbeService(), new RequirementResolver());
@ -120,7 +151,7 @@ public sealed class SetupWizardScreen
var applyConfig = nonInteractive
? true
: AnsiConsole.Confirm(
$"[{Theme.Amber}]Apply {update.Changes.Count} recommended config update(s) to devtool.json?[/]",
$"[{Theme.Amber}]Apply {update.Changes.Count} recommended update(s) to your project config?[/]",
defaultValue: true);
if (applyConfig)
{
@ -151,6 +182,38 @@ public sealed class SetupWizardScreen
return true;
}
private async Task RunScaffoldingWizardAsync(string projectRoot)
{
AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule("NEW PROJECT WIZARD"));
var projectName = AnsiConsole.Ask<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)
{
if (RuntimePolicy.IsNonInteractive())
@ -176,12 +239,12 @@ public sealed class SetupWizardScreen
{
var path = ConfigLoader.FindConfigPath(projectRoot);
if (string.IsNullOrWhiteSpace(path))
return new LegacyMigrationApplyResult(false, "Could not find devtool.json for saving.");
return new LegacyMigrationApplyResult(false, "Could not find project config for saving.");
var backup = path + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}";
File.Copy(path, backup, overwrite: false);
File.WriteAllText(path, ConfigBootstrapper.ToJson(config));
return new LegacyMigrationApplyResult(true, "Saved updated devtool.json.", BackupPath: backup, ConfigPath: path);
return new LegacyMigrationApplyResult(true, "Saved updated project config.", BackupPath: backup, ConfigPath: path);
}
catch (Exception ex)
{

View File

@ -23,7 +23,7 @@ public sealed class ToolchainScreen(DevToolConfig config, string projectRoot)
var tc = _config.Toolchains;
if (tc is null)
{
AnsiConsole.MarkupLine(Theme.Warn("No toolchains configured in devtool.json."));
AnsiConsole.MarkupLine(Theme.Warn("No toolchains configured in project config."));
AnsiConsole.MarkupLine(Theme.Faint("Add a \"toolchains\" section with \"python\" and/or \"node\" entries."));
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back..."));
Console.ReadKey(intercept: true);

View File

@ -50,9 +50,9 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR
foreach (var proj in projects)
{
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
var devtoolPath = Path.Combine(absPath, "devtool.json");
var configPath = ConfigLoader.FindConfigPath(absPath);
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
var exists = File.Exists(devtoolPath);
var exists = configPath is not null;
var label = isCurrent
? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/] [{Theme.GreenDim}](current)[/]"
@ -61,7 +61,7 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR
label += $" [{Theme.Amber}](disabled)[/]";
var desc = !exists
? $" [{Theme.Red}]devtool.json not found[/]"
? $" [{Theme.Red}]project config not found[/]"
: string.IsNullOrWhiteSpace(proj.Description)
? $" [{Theme.GreenDim}]{Markup.Escape(absPath)}[/]"
: $" [{Theme.GreenDim}]{Markup.Escape(proj.Description)}[/]";
@ -160,7 +160,7 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR
{
var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj);
var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase);
var hasConfig = File.Exists(Path.Combine(absPath, "devtool.json"));
var hasConfig = ConfigLoader.FindConfigPath(absPath) is not null;
var status = proj.Disabled
? Theme.Warn("disabled")
: hasConfig ? Theme.Ok("ready") : Theme.Fail("no config");
@ -223,15 +223,17 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR
return;
}
var configPath = Path.Combine(absolutePath, "devtool.json");
if (!File.Exists(configPath))
var configPath = ConfigLoader.FindConfigPath(absolutePath);
if (configPath is null)
{
var create = AnsiConsole.Confirm(
$"[{Theme.Amber}]No devtool.json found. Create a minimal template?[/]",
$"[{Theme.Amber}]No project config found. Create a minimal template?[/]",
defaultValue: true);
if (!create)
return;
var newConfigName = $"sdtconfig-{new DirectoryInfo(absolutePath).Name}.json";
configPath = Path.Combine(absolutePath, newConfigName);
File.WriteAllText(configPath, "{\n \"name\": \"SDT Project\",\n \"version\": \"0.1.0\",\n \"workflows\": []\n}\n");
}
@ -270,14 +272,14 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR
return;
}
var configPath = Path.Combine(absolutePath, "devtool.json");
if (initializeConfig && !File.Exists(configPath))
var configPath = ConfigLoader.FindConfigPath(absolutePath);
if (initializeConfig && configPath is null)
{
try
{
var scan = ConfigBootstrapper.Scan(absolutePath);
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
configPath = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
}
catch (Exception ex)
{
@ -287,9 +289,9 @@ public sealed class WorkspaceScreen(WorkspaceConfig workspace, string workspaceR
}
}
if (!File.Exists(configPath))
if (configPath is null)
{
AnsiConsole.MarkupLine(Theme.Warn("Candidate has no devtool.json yet. Use Add + initialize."));
AnsiConsole.MarkupLine(Theme.Warn("Candidate has no project config yet. Use Add + initialize."));
Thread.Sleep(900);
return;
}

View File

@ -160,6 +160,24 @@ public sealed class ConfigBootstrapperTests
Assert.Equal("AppB", scan.DotnetWorkingDir, ignoreCase: true);
}
[Fact]
public void Scan_PrefersTauriPackageRoot_ForNodeWorkingDir()
{
var root = CreateTempDir();
var workspacePkg = Path.Combine(root, "journal");
var appPkg = Path.Combine(workspacePkg, "Journal.App");
Directory.CreateDirectory(appPkg);
Directory.CreateDirectory(Path.Combine(appPkg, "src-tauri"));
File.WriteAllText(Path.Combine(workspacePkg, "package.json"), """{ "name": "workspace", "scripts": { "build": "echo build" } }""");
File.WriteAllText(Path.Combine(appPkg, "package.json"), """{ "name": "journal-app", "scripts": { "build": "echo build", "tauri": "echo tauri" } }""");
File.WriteAllText(Path.Combine(appPkg, "src-tauri", "tauri.conf.json"), "{}");
var scan = ConfigBootstrapper.Scan(root);
Assert.Equal(Path.Combine("journal", "Journal.App"), scan.NodeWorkingDir, ignoreCase: true);
}
private static string CreateTempDir()
{
var path = Path.Combine(Path.GetTempPath(), "sdt-bootstrap-" + Guid.NewGuid().ToString("N"));

View File

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

View File

@ -13,7 +13,7 @@ public sealed class ScriptCommonTests
var nested = Path.Combine(root, "src", "app");
Directory.CreateDirectory(nested);
await File.WriteAllTextAsync(Path.Combine(root, "sample.sln"), "");
await File.WriteAllTextAsync(Path.Combine(root, "devtool.json"), """
await File.WriteAllTextAsync(Path.Combine(root, "sdtconfig-demo.json"), """
{
"name": "demo",
"version": "0.1.0",
@ -35,7 +35,7 @@ public sealed class ScriptCommonTests
var nested = Path.Combine(root, "child", "leaf");
Directory.CreateDirectory(nested);
Directory.CreateDirectory(Path.Combine(root, ".git"));
await File.WriteAllTextAsync(Path.Combine(root, "devtool.json"), """
await File.WriteAllTextAsync(Path.Combine(root, "sdtconfig-demo.json"), """
{
"name": "demo",
"version": "0.1.0",

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",
"version": "1.0.0",
@ -97,7 +97,7 @@ public sealed class WorkspaceDefaultsTests
}
""");
File.WriteAllText(Path.Combine(projectRoot, "devtool.json"), """
File.WriteAllText(Path.Combine(projectRoot, "sdtconfig-project-b.json"), """
{
"name": "Project B",
"version": "1.0.0",

View File

@ -16,7 +16,7 @@ public sealed class WorkspaceInventoryServiceTests
Directory.CreateDirectory(b);
Directory.CreateDirectory(a);
File.WriteAllText(Path.Combine(c, "devtool.json"), """{"name":"c","version":"0.1.0","workflows":[]}""");
File.WriteAllText(Path.Combine(c, "sdtconfig-c.json"), """{"name":"c","version":"0.1.0","workflows":[]}""");
File.WriteAllText(Path.Combine(b, "B.slnx"), "");
File.WriteAllText(Path.Combine(a, "A.csproj"), "<Project />");
@ -64,7 +64,7 @@ public sealed class WorkspaceInventoryServiceTests
var excluded = Path.Combine(root, "node_modules", "Pkg");
Directory.CreateDirectory(excluded);
File.WriteAllText(Path.Combine(excluded, "Pkg.csproj"), "<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 result = service.Scan(root, root, new WorkspaceInventorySettings(), new WorkspaceConfig());
@ -82,7 +82,7 @@ public sealed class WorkspaceInventoryServiceTests
Directory.CreateDirectory(b);
File.WriteAllText(Path.Combine(a, "a.csproj"), "<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 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"));
Directory.CreateDirectory(root);
File.WriteAllText(Path.Combine(root, "devtool.json"), """
File.WriteAllText(Path.Combine(root, "sdtconfig-legacy.json"), """
{
"name": "legacy",
"version": "0.1.0",
@ -72,7 +72,7 @@ public sealed class WorkspaceLoaderTests
var markerOnly = Path.Combine(workspaceRoot, "marker");
Directory.CreateDirectory(current);
Directory.CreateDirectory(markerOnly);
File.WriteAllText(Path.Combine(current, "devtool.json"), """{"name":"current","version":"0.1.0","workflows":[]}""");
File.WriteAllText(Path.Combine(current, "sdtconfig-current.json"), """{"name":"current","version":"0.1.0","workflows":[]}""");
File.WriteAllText(Path.Combine(markerOnly, "marker.csproj"), "<Project />");
var loaded = WorkspaceLoader.FindAndLoad(current);