SDT is standalone now, journal can use the bridge.

sdt can compile journal and other projects.
it using a json config system.
this program's Repo exists on the Gitea under stan.
Readme included as well.
This commit is contained in:
stan44 2026-03-02 19:54:35 -06:00
parent ae70fbdae9
commit ad199c338c
39 changed files with 2516 additions and 9 deletions

8
.gitignore vendored
View File

@ -56,10 +56,4 @@ journalapp(1).exe
.cache/
scripts/__pycache__/
.sdt/
devtool.backup.json
sdt.deps.json
sdt.dll
sdt.exe
sdt.pdb
sdt.runtimeconfig.json
Spectre.Console.dll
devtool.backup.json

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,47 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Test-SdtIsWindows {
if (Get-Variable -Name IsWindows -Scope Global -ErrorAction SilentlyContinue) {
return [bool]$global:IsWindows
}
return $env:OS -eq 'Windows_NT'
}
function Resolve-SdtPython {
$candidates = @('python')
if (Test-SdtIsWindows) { $candidates += 'py' } else { $candidates += 'python3' }
foreach ($c in $candidates) {
try {
& $c --version *> $null
if ($LASTEXITCODE -eq 0) { return $c }
} catch {}
}
return 'python'
}
function Resolve-SdtScriptPath {
param([Parameter(Mandatory=$true)][string]$ScriptName)
$bundled = Join-Path $PSScriptRoot $ScriptName
if (Test-Path $bundled) { return $bundled }
$project = Join-Path (Join-Path $PSScriptRoot '..') ('scripts\\' + $ScriptName)
if (Test-Path $project) { return (Resolve-Path $project).Path }
throw "Python helper script not found: $ScriptName"
}
function Invoke-SdtPythonScript {
param(
[Parameter(Mandatory=$true)][string]$ScriptName,
[string[]]$ForwardArgs = @()
)
$python = Resolve-SdtPython
$scriptPath = Resolve-SdtScriptPath -ScriptName $ScriptName
& $python $scriptPath @ForwardArgs
exit $LASTEXITCODE
}

View File

@ -0,0 +1,597 @@
#!/usr/bin/env python3
import argparse
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
import time
from script_common import resolve_command
def run_step(command, args, cwd):
resolved = resolve_command(command)
if shutil.which(resolved) is None and not pathlib.Path(resolved).exists():
return {
"command": resolved,
"args": args,
"cwd": cwd,
"exit_code": 127,
"elapsed_seconds": 0.0,
"status": "failed",
"failure_reason": f"command_not_found:{resolved}",
}
started = time.time()
proc = subprocess.run([resolved, *args], cwd=cwd, check=False)
elapsed = round(time.time() - started, 3)
return {
"command": resolved,
"args": args,
"cwd": cwd,
"exit_code": proc.returncode,
"elapsed_seconds": elapsed,
"status": "ok" if proc.returncode == 0 else "failed",
"failure_reason": None if proc.returncode == 0 else f"non_zero_exit:{proc.returncode}",
}
def resolve_python_executable():
candidates = ["py", "python"] if os.name == "nt" else ["python3", "python"]
for c in candidates:
if shutil.which(c):
return c
return "python"
def parse_common(parser):
parser.add_argument("--project-root", required=True)
parser.add_argument("--working-dir", default=".")
parser.add_argument("--json", action="store_true")
def resolve_cwd(project_root, working_dir):
return os.path.abspath(os.path.join(project_root, working_dir))
EXCLUDED_SCAN_DIRS = {".git", "node_modules", "bin", "obj", ".venv", "venv", ".sdt", "dist", "build"}
def discover_dotnet_target(project_root: str, cwd: str):
# Prefer 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:
return str(local_slnx[0])
local_sln = sorted(pathlib.Path(cwd).glob("*.sln"))
if len(local_sln) == 1:
return str(local_sln[0])
local_csproj = sorted(pathlib.Path(cwd).glob("*.csproj"))
if len(local_csproj) == 1:
return str(local_csproj[0])
slnx_hits = bounded_find_files(project_root, ".slnx", max_depth=4)
if len(slnx_hits) == 1:
return slnx_hits[0]
sln_hits = bounded_find_files(project_root, ".sln", max_depth=4)
if len(sln_hits) == 1:
return sln_hits[0]
csproj_hits = bounded_find_files(project_root, ".csproj", max_depth=4)
if len(csproj_hits) == 1:
return csproj_hits[0]
return None
def bounded_find_files(root: str, extension: str, max_depth: int):
root_path = pathlib.Path(root).resolve()
results = []
for current_root, dirs, files in os.walk(root_path):
rel = pathlib.Path(current_root).resolve().relative_to(root_path)
depth = len(rel.parts)
dirs[:] = [d for d in dirs if d not in EXCLUDED_SCAN_DIRS]
if depth > max_depth:
dirs[:] = []
continue
for name in files:
if name.lower().endswith(extension.lower()):
results.append(str(pathlib.Path(current_root) / name))
return sorted(results)
def run_dotnet_action(project_root, working_dir, verb):
cwd = resolve_cwd(project_root, working_dir)
target = discover_dotnet_target(project_root, cwd)
if not target:
return 0, {
"command": "dotnet",
"args": [verb],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"skip_reason": "not_applicable_no_dotnet_target",
"message": "No .slnx/.sln/.csproj found for this step. Skipping dotnet action.",
}
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
def _deps_hash(app_root):
h = hashlib.sha256()
for name in ("package.json", "package-lock.json"):
p = pathlib.Path(app_root) / name
if p.exists():
h.update(p.read_bytes())
return h.hexdigest()
def ensure_npm_dependencies(app_root):
package_json = pathlib.Path(app_root) / "package.json"
if not package_json.exists():
return {"installed": False, "reason": "not_applicable"}
node_modules = pathlib.Path(app_root) / "node_modules"
deps_hash_file = node_modules / ".sdt-deps.sha256"
expected = _deps_hash(app_root)
should_install = not node_modules.exists()
if not should_install:
if not deps_hash_file.exists():
should_install = True
else:
current = deps_hash_file.read_text(encoding="utf-8").strip()
should_install = current != expected
if not should_install:
return {"installed": False, "reason": "deps_unchanged"}
lock_exists = (pathlib.Path(app_root) / "package-lock.json").exists()
install_args = ["ci", "--no-audit", "--fund=false"] if lock_exists else ["install", "--no-audit", "--fund=false"]
install_step = run_step("npm", install_args, app_root)
if install_step["exit_code"] != 0:
if lock_exists and install_args[0] == "ci":
fallback = run_step("npm", ["install", "--no-audit", "--fund=false"], app_root)
if fallback["exit_code"] != 0:
fallback["failure_reason"] = "deps_install_failed_after_ci_fallback"
return {"installed": True, "reason": "install_failed", "step": fallback}
install_step = fallback
else:
return {"installed": True, "reason": "install_failed", "step": install_step}
node_modules.mkdir(parents=True, exist_ok=True)
deps_hash_file.write_text(expected, encoding="utf-8")
return {"installed": True, "reason": "installed", "step": install_step}
def read_package_json(cwd: str):
package_json = pathlib.Path(cwd) / "package.json"
if not package_json.exists():
return None
try:
return json.loads(package_json.read_text(encoding="utf-8"))
except Exception:
return None
def has_npm_script(cwd: str, script_name: str) -> bool:
data = read_package_json(cwd)
if not isinstance(data, dict):
return False
scripts = data.get("scripts")
if not isinstance(scripts, dict):
return False
return script_name in scripts and isinstance(scripts.get(script_name), str)
def action_dotnet_build(args):
return run_dotnet_action(args.project_root, args.working_dir, "build")
def action_dotnet_restore(args):
return run_dotnet_action(args.project_root, args.working_dir, "restore")
def action_dotnet_test(args):
return run_dotnet_action(args.project_root, args.working_dir, "test")
def action_dotnet_publish(args):
return run_dotnet_action(args.project_root, args.working_dir, "publish")
def action_npm_install(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "package.json").exists():
return 0, {
"command": "npm",
"args": ["install"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"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
def action_npm_ci(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "package.json").exists():
return 0, {
"command": "npm",
"args": ["ci"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"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
def action_npm_build(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "package.json").exists():
return 0, {
"command": "npm",
"args": ["run", "build"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"skip_reason": "not_applicable_no_package_json",
}
if not has_npm_script(cwd, "build"):
return 0, {
"command": "npm",
"args": ["run", "build"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"skip_reason": "not_applicable_missing_build_script",
}
deps = ensure_npm_dependencies(cwd)
if deps.get("reason") == "not_applicable":
return 0, {
"command": "npm",
"args": ["run", "build"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"skip_reason": "not_applicable_no_package_json",
}
if deps.get("reason") == "install_failed":
step = deps["step"]
step["failure_reason"] = "deps_install_failed"
return step["exit_code"], step
step = run_step("npm", ["run", "build"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_npm_test(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "package.json").exists():
return 0, {
"command": "npm",
"args": ["test"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"skip_reason": "not_applicable_no_package_json",
}
if not has_npm_script(cwd, "test"):
return 0, {
"command": "npm",
"args": ["test"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"skip_reason": "not_applicable_missing_test_script",
}
deps = ensure_npm_dependencies(cwd)
if deps.get("reason") == "not_applicable":
return 0, {
"command": "npm",
"args": ["test"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"skip_reason": "not_applicable_no_package_json",
}
if deps.get("reason") == "install_failed":
step = deps["step"]
step["failure_reason"] = "deps_install_failed"
return step["exit_code"], step
step = run_step("npm", ["test"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_npm_audit(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "package.json").exists():
return 0, {
"command": "npm",
"args": ["audit"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"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
def action_python_venv_create(args):
cwd = resolve_cwd(args.project_root, ".")
venv_dir = args.venv_dir or ".venv"
step = run_step(resolve_python_executable(), ["-m", "venv", venv_dir], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_python_pip_install(args):
cwd = resolve_cwd(args.project_root, ".")
req = args.requirements
step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_python_pip_sync(args):
cwd = resolve_cwd(args.project_root, ".")
req = args.requirements
step = run_step(resolve_python_executable(), ["-m", "pip", "install", "-r", req], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_python_pytest(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step(resolve_python_executable(), ["-m", "pytest"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_cargo_build(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "Cargo.toml").exists():
return 0, {
"command": "cargo",
"args": ["build"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"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
def action_cargo_test(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
if not (pathlib.Path(cwd) / "Cargo.toml").exists():
return 0, {
"command": "cargo",
"args": ["test"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"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
def action_tauri_build(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
tauri_conf = pathlib.Path(cwd) / "src-tauri" / "tauri.conf.json"
if not tauri_conf.exists():
tauri_conf = pathlib.Path(cwd) / "tauri.conf.json"
if not tauri_conf.exists() or not (pathlib.Path(cwd) / "package.json").exists():
return 0, {
"command": "npm",
"args": ["run", "tauri", "build"],
"cwd": cwd,
"exit_code": 0,
"elapsed_seconds": 0.0,
"status": "skipped",
"failure_reason": None,
"skip_reason": "not_applicable_no_tauri_project",
}
deps = ensure_npm_dependencies(cwd)
if deps.get("reason") == "install_failed":
step = deps["step"]
step["failure_reason"] = "deps_install_failed"
return step["exit_code"], step
tauri_args = ["run", "tauri", "build"]
if args.no_bundle:
tauri_args.extend(["--", "--no-bundle"])
step = run_step("npm", tauri_args, cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_git_status(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["status"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_git_fetch(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["fetch"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_git_pull(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["pull"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_git_clean(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("git", ["clean", "-fd"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_docker_build(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("docker", ["build", "."], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_docker_compose_up(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("docker", ["compose", "up", "-d"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def action_docker_compose_down(args):
cwd = resolve_cwd(args.project_root, args.working_dir)
step = run_step("docker", ["compose", "down"], cwd)
return 0 if step["exit_code"] == 0 else step["exit_code"], step
def main():
parser = argparse.ArgumentParser(description="SDT normalized build actions")
sub = parser.add_subparsers(dest="action", required=True)
p0 = sub.add_parser("dotnet-restore")
parse_common(p0)
p1 = sub.add_parser("dotnet-build")
parse_common(p1)
p1b = sub.add_parser("dotnet-test")
parse_common(p1b)
p1c = sub.add_parser("dotnet-publish")
parse_common(p1c)
p2 = sub.add_parser("npm-install")
parse_common(p2)
p2b = sub.add_parser("npm-ci")
parse_common(p2b)
p3 = sub.add_parser("npm-build")
parse_common(p3)
p3b = sub.add_parser("npm-test")
parse_common(p3b)
p3c = sub.add_parser("npm-audit")
parse_common(p3c)
p4 = sub.add_parser("python-venv-create")
parse_common(p4)
p4.add_argument("--venv-dir", default=".venv")
p5 = sub.add_parser("python-pip-install")
parse_common(p5)
p5.add_argument("--requirements", required=True)
p5b = sub.add_parser("python-pip-sync")
parse_common(p5b)
p5b.add_argument("--requirements", required=True)
p5c = sub.add_parser("python-pytest")
parse_common(p5c)
p6 = sub.add_parser("cargo-build")
parse_common(p6)
p6b = sub.add_parser("cargo-test")
parse_common(p6b)
p7 = sub.add_parser("tauri-build")
parse_common(p7)
p7.add_argument("--no-bundle", action="store_true")
p8 = sub.add_parser("git-status")
parse_common(p8)
p9 = sub.add_parser("git-fetch")
parse_common(p9)
p10 = sub.add_parser("git-pull")
parse_common(p10)
p11 = sub.add_parser("git-clean")
parse_common(p11)
p12 = sub.add_parser("docker-build")
parse_common(p12)
p13 = sub.add_parser("docker-compose-up")
parse_common(p13)
p14 = sub.add_parser("docker-compose-down")
parse_common(p14)
args = parser.parse_args()
handlers = {
"dotnet-restore": action_dotnet_restore,
"dotnet-build": action_dotnet_build,
"dotnet-test": action_dotnet_test,
"dotnet-publish": action_dotnet_publish,
"npm-install": action_npm_install,
"npm-ci": action_npm_ci,
"npm-build": action_npm_build,
"npm-test": action_npm_test,
"npm-audit": action_npm_audit,
"python-venv-create": action_python_venv_create,
"python-pip-install": action_python_pip_install,
"python-pip-sync": action_python_pip_sync,
"python-pytest": action_python_pytest,
"cargo-build": action_cargo_build,
"cargo-test": action_cargo_test,
"tauri-build": action_tauri_build,
"git-status": action_git_status,
"git-fetch": action_git_fetch,
"git-pull": action_git_pull,
"git-clean": action_git_clean,
"docker-build": action_docker_build,
"docker-compose-up": action_docker_compose_up,
"docker-compose-down": action_docker_compose_down,
}
code, summary = handlers[args.action](args)
if args.json:
print(json.dumps(summary))
return code
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,17 @@
@echo off
set "SCRIPT_DIR=%~dp0"
where py >nul 2>nul
if %ERRORLEVEL%==0 (
set "PYEXE=py"
) else (
where python >nul 2>nul
if not %ERRORLEVEL%==0 (
echo python not found.
exit /b 1
)
set "PYEXE=python"
)
for /f "usebackq delims=" %%L in (`"%PYEXE%" "%SCRIPT_DIR%dev_shell.py" export --shell cmd`) do %%L
echo Development shell initialized from Python bootstrap script.

View File

@ -0,0 +1,21 @@
# Run this in PowerShell before development commands:
# . ./scripts/dev-shell.ps1
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot '_pwsh-python-shim.ps1')
$scriptPath = Resolve-SdtScriptPath -ScriptName 'dev_shell.py'
$python = Resolve-SdtPython
$lines = & $python $scriptPath export --shell pwsh
if ($LASTEXITCODE -ne 0) {
throw "Failed to initialize development shell via dev_shell.py"
}
foreach ($line in $lines) {
Invoke-Expression $line
}
Write-Host "Development shell initialized from Python bootstrap script."

View File

@ -0,0 +1,16 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
if command -v python3 >/dev/null 2>&1; then
PYTHON_EXE="python3"
elif command -v python >/dev/null 2>&1; then
PYTHON_EXE="python"
else
echo "python3/python not found." >&2
exit 1
fi
eval "$("$PYTHON_EXE" "$SCRIPT_DIR/dev_shell.py" export --shell bash)"
echo "Development shell initialized from Python bootstrap script."

View File

@ -0,0 +1,148 @@
#!/usr/bin/env python3
import argparse
import json
import pathlib
import sys
from script_common import PROXY_VARS, clean_proxy_env, dotnet_env, ensure_dirs, pip_env, resolve_repo_root
def huggingface_env(repo_root: pathlib.Path) -> dict[str, str]:
env = {}
hf_home = repo_root / ".cache" / "huggingface"
hf_hub_cache = hf_home / "hub"
ensure_dirs([hf_hub_cache])
env["HF_HOME"] = str(hf_home)
env["HUGGINGFACE_HUB_CACHE"] = str(hf_hub_cache)
env["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
return env
def resolved_env(repo_root: pathlib.Path) -> dict[str, str]:
env = {}
dotnet = dotnet_env(repo_root)
pip = pip_env(repo_root)
hf = huggingface_env(repo_root)
dotnet_keys = [
"DOTNET_CLI_HOME",
"NUGET_PACKAGES",
"NUGET_HTTP_CACHE_PATH",
"DOTNET_SKIP_FIRST_TIME_EXPERIENCE",
"DOTNET_ADD_GLOBAL_TOOLS_TO_PATH",
"DOTNET_GENERATE_ASPNET_CERTIFICATE",
"DOTNET_CLI_TELEMETRY_OPTOUT",
"NUGET_CERT_REVOCATION_MODE",
]
pip_keys = [
"PIP_CACHE_DIR",
"PIP_DISABLE_PIP_VERSION_CHECK",
"PIP_DEFAULT_TIMEOUT",
"PIP_RETRIES",
"TEMP",
"TMP",
]
for key in dotnet_keys:
env[key] = dotnet[key]
for key in pip_keys:
env[key] = pip[key]
env.update(hf)
clean_proxy_env(env)
return env
def export_lines(shell: str, env_map: dict[str, str]) -> list[str]:
def sh_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
if shell == "pwsh":
lines = [f"Remove-Item Env:{k} -ErrorAction SilentlyContinue" for k in PROXY_VARS]
lines.extend(f"$env:{k} = \"{v.replace('\"', '`\"')}\"" for k, v in env_map.items())
return lines
if shell in ("bash", "zsh"):
lines = [f"unset {k}" for k in PROXY_VARS]
lines.extend(f"export {k}={sh_quote(v)}" for k, v in env_map.items())
return lines
if shell == "cmd":
lines = [f"set {k}=" for k in PROXY_VARS]
lines.extend(f"set {k}={v}" for k, v in env_map.items())
return lines
raise ValueError(shell)
def cmd_export(args):
try:
repo_root = resolve_repo_root(args.project_root)
except Exception as ex:
print(f"Failed to resolve project root: {ex}", file=sys.stderr)
return 2
env_map = resolved_env(repo_root)
payload = {
"projectRoot": str(repo_root),
"env": env_map,
"createdDirs": [
str(repo_root / ".dotnet_home"),
str(repo_root / ".nuget" / "packages"),
str(repo_root / ".nuget" / "http-cache"),
str(repo_root / ".pip" / "cache"),
str(repo_root / ".tmp" / "pip-temp"),
str(repo_root / ".cache" / "huggingface" / "hub"),
],
"warnings": [],
}
try:
lines = export_lines(args.shell, env_map)
except ValueError:
print(f"Unsupported shell target: {args.shell}", file=sys.stderr)
return 3
if args.json:
print(json.dumps(payload))
else:
for line in lines:
print(line)
return 0
def cmd_doctor(args):
try:
repo_root = resolve_repo_root(args.project_root)
except Exception as ex:
print(f"Failed to resolve project root: {ex}", file=sys.stderr)
return 2
env_map = resolved_env(repo_root)
checks = {
"repo_root": str(repo_root),
"dotnet_home_exists": (repo_root / ".dotnet_home").exists(),
"nuget_cache_exists": (repo_root / ".nuget" / "packages").exists(),
"pip_cache_exists": (repo_root / ".pip" / "cache").exists(),
"hf_cache_exists": (repo_root / ".cache" / "huggingface" / "hub").exists(),
"env_count": len(env_map),
}
print(json.dumps(checks))
return 0
def main():
parser = argparse.ArgumentParser(description="SDT cross-platform shell bootstrap helper")
sub = parser.add_subparsers(dest="command", required=True)
p_export = sub.add_parser("export", help="Print env exports for a shell")
p_export.add_argument("--shell", required=True)
p_export.add_argument("--project-root")
p_export.add_argument("--json", action="store_true")
p_doctor = sub.add_parser("doctor", help="Validate env bootstrap paths")
p_doctor.add_argument("--project-root")
args = parser.parse_args()
if args.command == "export":
return cmd_export(args)
return cmd_doctor(args)
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,128 @@
#!/usr/bin/env python3
import argparse
import json
import os
import platform
import shutil
import subprocess
import sys
from script_common import resolve_command
def run_capture(cmd):
try:
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
out = (proc.stdout or "").strip()
err = (proc.stderr or "").strip()
text = out if out else err
return proc.returncode == 0, text
except Exception as ex:
return False, str(ex)
def probe_tool(tool):
mapping = {
"dotnet": ["dotnet", "--version"],
"node": ["node", "--version"],
"npm": ["npm", "--version"],
"python": ["python", "--version"],
"cargo": ["cargo", "--version"],
"tauri": ["tauri", "--version"],
"git": ["git", "--version"],
"docker": ["docker", "--version"],
}
cmd = mapping.get(tool, [tool, "--version"])
resolved = resolve_command(cmd[0])
if shutil.which(resolved) is None and not os.path.exists(resolved):
return {"tool": tool, "available": False, "version": None, "details": f"{cmd[0]} not found in PATH"}
cmd = [resolved, *cmd[1:]]
ok, text = run_capture(cmd)
return {"tool": tool, "available": ok, "version": text if ok else None, "details": None if ok else text}
def install_plan(tool):
is_windows = platform.system().lower().startswith("win")
if is_windows:
plans = {
"dotnet": [("winget", ["install", "Microsoft.DotNet.SDK.10"])],
"node": [("winget", ["install", "OpenJS.NodeJS.LTS"])],
"npm": [("winget", ["install", "OpenJS.NodeJS.LTS"])],
"python": [("winget", ["install", "Python.Python.3.12"])],
"cargo": [("winget", ["install", "Rustlang.Rustup"])],
"tauri": [("npm", ["install", "-g", "@tauri-apps/cli"])],
"git": [("winget", ["install", "Git.Git"])],
"docker": [("winget", ["install", "Docker.DockerDesktop"])],
}
else:
plans = {
"dotnet": [("sh", ["-c", "echo install dotnet sdk with your package manager"])],
"node": [("sh", ["-c", "echo install nodejs with your package manager"])],
"npm": [("sh", ["-c", "echo install npm with your package manager"])],
"python": [("sh", ["-c", "echo install python3 with your package manager"])],
"cargo": [("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh"])],
"tauri": [("npm", ["install", "-g", "@tauri-apps/cli"])],
"git": [("sh", ["-c", "echo install git with your package manager"])],
"docker": [("sh", ["-c", "echo install docker with your package manager"])],
}
cmds = plans.get(tool, [])
return {
"tool": tool,
"supported": len(cmds) > 0,
"summary": f"Install plan for {tool} on {platform.system()}",
"commands": [{"command": c, "args": a} for c, a in cmds],
}
def run_install(tool):
plan = install_plan(tool)
if not plan["supported"]:
return 2
for cmd in plan["commands"]:
proc = subprocess.run([cmd["command"], *cmd["args"]], check=False)
if proc.returncode != 0:
return proc.returncode
return 0
def main():
parser = argparse.ArgumentParser(description="SDT diagnostics and install planner")
sub = parser.add_subparsers(dest="cmd", required=True)
p_probe = sub.add_parser("probe")
p_probe.add_argument("--tool", required=True)
p_probe.add_argument("--json", action="store_true")
p_plan = sub.add_parser("install-plan")
p_plan.add_argument("--tool", required=True)
p_plan.add_argument("--json", action="store_true")
p_run = sub.add_parser("install-run")
p_run.add_argument("--tool", required=True)
args = parser.parse_args()
if args.cmd == "probe":
result = probe_tool(args.tool.lower())
if args.json:
print(json.dumps(result))
else:
print(result)
return 0 if result["available"] else 1
if args.cmd == "install-plan":
result = install_plan(args.tool.lower())
if args.json:
print(json.dumps(result))
else:
print(result)
return 0 if result["supported"] else 2
if args.cmd == "install-run":
return run_install(args.tool.lower())
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,34 @@
#!/usr/bin/env python3
import argparse
import sys
from script_common import dotnet_env, resolve_repo_root, run
DOTNET_SAFE_CMDS = {"restore", "build", "run", "test", "publish", "pack"}
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform minimal dotnet wrapper")
parser.add_argument("dotnet_args", nargs=argparse.REMAINDER)
parser.add_argument("--repo-root", default=None)
args = parser.parse_args()
if not args.dotnet_args:
print("Usage: python scripts/dotnet-min.py <dotnet args>", file=sys.stderr)
return 2
repo_root = resolve_repo_root(args.repo_root)
dotnet_args = list(args.dotnet_args)
cmd = dotnet_args[0].lower()
if cmd in DOTNET_SAFE_CMDS:
dotnet_args.extend(["-p:RestoreIgnoreFailedSources=true", "-p:NuGetAudit=false"])
if cmd == "restore":
dotnet_args.append("--ignore-failed-sources")
return run("dotnet", dotnet_args, repo_root, env=dotnet_env(repo_root))
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,44 @@
#!/usr/bin/env python3
import argparse
import subprocess
import sys
from pathlib import Path
from script_common import resolve_repo_root
def run_step(repo_root: Path, title: str, command: list[str]) -> int:
print(f"\n== {title} ==")
print("$", " ".join(command))
proc = subprocess.run(command, cwd=str(repo_root), check=False)
return proc.returncode
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform migration quality gate")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--skip-tests", action="store_true")
parser.add_argument("--test-project", default=None, help="Optional test csproj path")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
code = run_step(repo_root, "Build", [sys.executable, "scripts/dotnet-min.py", "build"])
if code != 0:
return code
if not args.skip_tests:
if args.test_project:
test_cmd = [sys.executable, "scripts/dotnet-min.py", "test", args.test_project]
else:
test_cmd = [sys.executable, "scripts/dotnet-min.py", "test"]
code = run_step(repo_root, "Tests", test_cmd)
if code != 0:
return code
print("\nMigration gate passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
import argparse
import shutil
from script_common import resolve_repo_root
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform node_modules cleanup")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--working-dir", default=".")
parser.add_argument("--also-cache", action="store_true")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
work_dir = (repo_root / args.working_dir).resolve()
node_modules = work_dir / "node_modules"
if node_modules.exists():
shutil.rmtree(node_modules)
print(f"Removed: {node_modules}")
else:
print(f"Not found: {node_modules}")
if args.also_cache:
npm_cache = repo_root / ".npm" / "cache"
if npm_cache.exists():
shutil.rmtree(npm_cache)
print(f"Removed: {npm_cache}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,42 @@
#!/usr/bin/env python3
import argparse
import shutil
import tempfile
from pathlib import Path
from script_common import resolve_repo_root
def main() -> int:
parser = argparse.ArgumentParser(description="Export local NuGet cache to zip")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--output-zip", default="nuget-cache-export.zip")
parser.add_argument("--include-dotnet-home", action="store_true")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
output_zip = (repo_root / args.output_zip).resolve()
nuget_dir = repo_root / ".nuget"
dotnet_home = repo_root / ".dotnet_home"
if not nuget_dir.exists():
print(f"NuGet cache not found: {nuget_dir}")
return 2
with tempfile.TemporaryDirectory() as td:
stage = Path(td) / "cache-export"
stage.mkdir(parents=True, exist_ok=True)
shutil.copytree(nuget_dir, stage / ".nuget")
if args.include_dotnet_home and dotnet_home.exists():
shutil.copytree(dotnet_home, stage / ".dotnet_home")
manifest = stage / "nuget-cache-manifest.txt"
manifest.write_text("exported_by=nuget-export-cache.py\n", encoding="utf-8")
archive_base = str(output_zip.with_suffix(""))
shutil.make_archive(archive_base, "zip", root_dir=str(stage))
print(f"Exported cache: {output_zip}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,28 @@
#!/usr/bin/env python3
import argparse
import shutil
from script_common import resolve_repo_root
def main() -> int:
parser = argparse.ArgumentParser(description="Import NuGet cache from zip")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--input-zip", default="nuget-cache-export.zip")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
input_zip = (repo_root / args.input_zip).resolve()
if not input_zip.exists():
print(f"Input zip not found: {input_zip}")
return 2
shutil.unpack_archive(str(input_zip), extract_dir=str(repo_root))
print(f"Imported cache from: {input_zip}")
print("Run `python scripts/dotnet-min.py restore` to validate restore in this repo.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
import argparse
import os
import sys
from script_common import pip_env, resolve_repo_root, run
def main() -> int:
parser = argparse.ArgumentParser(description="Cross-platform minimal pip wrapper")
parser.add_argument("pip_args", nargs=argparse.REMAINDER)
parser.add_argument("--repo-root", default=None)
args = parser.parse_args()
if not args.pip_args:
print("Usage: python scripts/pip-min.py <pip args>", file=sys.stderr)
return 2
repo_root = resolve_repo_root(args.repo_root)
pip_args = list(args.pip_args)
# Preserve legacy behavior: for bare install, default target to repo-local deps.
if pip_args and pip_args[0].lower() == "install":
has_target = any(a in ("--target", "--prefix") for a in pip_args)
if not has_target:
pip_args = [a for a in pip_args if a != "--user"]
target = repo_root / ".pydeps" / f"py{sys.version_info.major}{sys.version_info.minor}"
os.makedirs(target, exist_ok=True)
pip_args.extend(["--target", str(target)])
return run(sys.executable, ["-m", "pip", *pip_args], repo_root, env=pip_env(repo_root))
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,46 @@
from __future__ import annotations
import os
import tempfile
def _mkdtemp_compat(
suffix: str | None = None,
prefix: str | None = None,
dir: str | None = None,
) -> str:
# Python 3.14 on some Windows hosts creates mkdtemp dirs that are
# immediately non-writable by the same process when mode=0o700 is used.
# pip relies heavily on tempfile; force 0o777 for compatibility.
if dir is None:
dir = tempfile.gettempdir()
if prefix is None:
prefix = tempfile.template
if suffix is None:
suffix = ""
names = tempfile._get_candidate_names()
for _ in range(tempfile.TMP_MAX):
name = next(names)
path = os.path.join(dir, f"{prefix}{name}{suffix}")
try:
os.mkdir(path, 0o777)
return path
except FileExistsError:
continue
raise FileExistsError("No usable temporary directory name found.")
def main(argv: list[str]) -> int:
tempfile.mkdtemp = _mkdtemp_compat # type: ignore[assignment]
from pip._internal.cli.main import main as pip_main
return int(pip_main(argv))
if __name__ == "__main__":
raise SystemExit(main(__import__("sys").argv[1:]))

View File

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

View File

@ -0,0 +1,125 @@
#!/usr/bin/env python3
import argparse
import json
import shutil
import subprocess
import sys
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
def has_package_script(app_root: Path, script_name: str) -> bool:
package_json = app_root / "package.json"
if not package_json.exists():
return False
try:
data = json.loads(package_json.read_text(encoding="utf-8"))
except Exception:
return False
scripts = data.get("scripts")
if not isinstance(scripts, dict):
return False
value = scripts.get(script_name)
return isinstance(value, str) and value.strip() != ""
def main() -> int:
parser = argparse.ArgumentParser(description="Publish bundled outputs using Python script entrypoints")
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
parser.add_argument("--runtime", default="win-x64")
parser.add_argument("--skip-sidecar", action="store_true")
parser.add_argument("--skip-web", action="store_true")
parser.add_argument("--skip-webgateway", action="store_true")
parser.add_argument("--skip-tauri", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--sidecar-project", default=None)
parser.add_argument("--gateway-project", default=None)
parser.add_argument("--app-root", default=None)
parser.add_argument("--output-dir", default="output")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
output_root = (repo_root / args.output_dir).resolve()
output_root.mkdir(parents=True, exist_ok=True)
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
py = sys.executable
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
if not args.skip_web:
if app_root is None:
print("Skipping web: no app root with package.json 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
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
if not args.skip_tauri:
if app_root is None or tauri_conf is None:
print("Skipping tauri: tauri app not detected.")
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
target_dir = app_root / "src-tauri" / "target" / ("debug" if args.configuration == "Debug" else "release")
exes = sorted(target_dir.glob("*.exe"), key=lambda p: p.stat().st_mtime, reverse=True)
if exes:
staged = output_root / exes[0].name
if args.dry_run:
print(f"Would copy: {exes[0]} -> {staged}")
else:
shutil.copy2(exes[0], staged)
print(f"Staged desktop executable: {staged}")
print("\nPublish output workflow complete.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

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

View File

@ -0,0 +1,60 @@
#!/usr/bin/env python3
import argparse
import os
from pathlib import Path
from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run
def main() -> int:
parser = argparse.ArgumentParser(description="Run gateway in dev or output mode")
parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release")
parser.add_argument("--urls", default="http://0.0.0.0:5180")
parser.add_argument("--project-root", default=None, help="Runtime project root exposed via SDT_PROJECT_ROOT")
parser.add_argument("--mode", choices=["Dev", "Output"], default="Dev")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--project", default=None, help="Gateway csproj path")
parser.add_argument("--output-exe", default=None, help="Published gateway executable path")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
effective_project_root = Path(args.project_root).resolve() if args.project_root else repo_root
if not effective_project_root.exists():
print(f"Project root does not exist: {effective_project_root}")
return 2
env = dotnet_env(repo_root)
env["SDT_PROJECT_ROOT"] = str(effective_project_root)
if args.mode == "Output":
exe_path = Path(args.output_exe).resolve() if args.output_exe else (repo_root / "output" / "webgateway" / ("webgateway.exe" if os.name == "nt" else "webgateway"))
if not exe_path.exists():
print(f"Output executable not found: {exe_path}")
return 2
return run(str(exe_path), ["--urls", args.urls], repo_root, env=env)
if args.project:
csproj = (repo_root / args.project).resolve()
else:
csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"])
if csproj is None or not csproj.exists():
print("Could not locate gateway project. Pass --project <path/to/project.csproj>.")
return 2
run_args = [
"run",
"--project",
str(csproj),
"-c",
args.configuration,
"--no-launch-profile",
"--urls",
args.urls,
"-p:RestoreIgnoreFailedSources=true",
"-p:NuGetAudit=false",
]
return run("dotnet", run_args, repo_root, env=env)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,362 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
from typing import Dict, Iterable, List, Sequence
PROXY_VARS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"GIT_HTTP_PROXY",
"GIT_HTTPS_PROXY",
"PIP_NO_INDEX",
]
def resolve_repo_root(start: str | None = None) -> pathlib.Path:
base = pathlib.Path(start or os.getcwd()).resolve()
# Preferred marker for SDT-managed projects.
for cur in [base, *base.parents]:
cfg = cur / "devtool.json"
if cfg.exists():
hints = load_project_root_hints(cur)
if not hints:
return cur
if any(_hint_matches(cur, hint) for hint in hints):
return cur
# Fall back to git root when available.
try:
proc = subprocess.run(
["git", "-C", str(base), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if proc.returncode == 0:
git_root = proc.stdout.strip()
if git_root:
return pathlib.Path(git_root).resolve()
except Exception:
pass
return base
def load_project_root_hints(repo_root: pathlib.Path) -> list[str]:
cfg = repo_root / "devtool.json"
if not cfg.exists():
return []
try:
data = json.loads(cfg.read_text(encoding="utf-8"))
hints = data.get("project", {}).get("rootHints", [])
return [str(x) for x in hints if isinstance(x, str) and x.strip()]
except Exception:
return []
def ensure_dirs(paths: List[pathlib.Path]) -> None:
for p in paths:
p.mkdir(parents=True, exist_ok=True)
def clean_proxy_env(env: Dict[str, str]) -> None:
for k in PROXY_VARS:
env.pop(k, None)
def dotnet_env(repo_root: pathlib.Path) -> Dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
dotnet_cli_home = repo_root / ".dotnet_home"
nuget_packages = repo_root / ".nuget" / "packages"
nuget_http_cache = repo_root / ".nuget" / "http-cache"
ensure_dirs([dotnet_cli_home, nuget_packages, nuget_http_cache])
env["DOTNET_CLI_HOME"] = str(dotnet_cli_home)
env["NUGET_PACKAGES"] = str(nuget_packages)
env["NUGET_HTTP_CACHE_PATH"] = str(nuget_http_cache)
env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
env["DOTNET_ADD_GLOBAL_TOOLS_TO_PATH"] = "0"
env["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "0"
env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
env["NUGET_CERT_REVOCATION_MODE"] = "offline"
return env
def pip_env(repo_root: pathlib.Path) -> Dict[str, str]:
env = dict(os.environ)
clean_proxy_env(env)
pip_cache = repo_root / ".pip" / "cache"
pip_tmp = repo_root / ".tmp" / "pip-temp"
ensure_dirs([pip_cache, pip_tmp])
env["PIP_CACHE_DIR"] = str(pip_cache)
env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
env["PIP_DEFAULT_TIMEOUT"] = "30"
env["PIP_RETRIES"] = "2"
env["TEMP"] = str(pip_tmp)
env["TMP"] = str(pip_tmp)
return env
def run(command: str, args: List[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> int:
resolved = resolve_command(command)
try:
proc = subprocess.run([resolved, *args], cwd=str(cwd), env=env, check=False)
return proc.returncode
except FileNotFoundError:
print(f"Command not found: {resolved}", file=sys.stderr)
return 127
def run_capture(command: str, args: Sequence[str], cwd: pathlib.Path, env: Dict[str, str] | None = None) -> tuple[int, str, str]:
resolved = resolve_command(command)
try:
proc = subprocess.run(
[resolved, *args],
cwd=str(cwd),
env=env,
capture_output=True,
text=True,
check=False,
)
return proc.returncode, proc.stdout, proc.stderr
except FileNotFoundError:
return 127, "", f"Command not found: {resolved}"
def resolve_command(command: str) -> str:
if not command:
return command
if os.name != "nt":
return command
if any(sep in command for sep in ("\\", "/")):
return command
if pathlib.Path(command).suffix:
found = shutil.which(command)
return found or command
candidates = []
lowered = command.lower()
if lowered in ("npm", "npx", "pnpm", "yarn", "tauri"):
candidates.extend([f"{command}.cmd", f"{command}.exe", f"{command}.bat", command])
else:
candidates.append(command)
for c in candidates:
found = _which_windows(c)
if found:
name = pathlib.Path(found).name.lower()
if name in ("npm", "npx", "pnpm", "yarn", "tauri"):
shim = pathlib.Path(found).with_name(name + ".cmd")
if shim.exists():
return str(shim)
return found
if lowered in ("npm", "npx", "pnpm", "yarn"):
node = _which_windows("node.exe") or _which_windows("node")
if node:
node_dir = pathlib.Path(node).parent
shim = node_dir / f"{lowered}.cmd"
if shim.exists():
return str(shim)
return candidates[-1]
def _hint_matches(root: pathlib.Path, hint: str) -> bool:
h = hint.strip()
if not h:
return False
has_glob = any(ch in h for ch in ("*", "?", "["))
if has_glob:
# Match both anywhere in root and directly at root-level for common hints like "*.sln".
if any(root.glob(h)):
return True
return any(root.rglob(h))
marker = root / h
if marker.exists():
return True
# If hint is just a filename marker, look bounded in tree.
if not any(sep in h for sep in ("\\", "/")):
return any(p.name == h for p in root.rglob(h))
return False
def _expand_windows_path_segment(segment: str) -> str:
expanded = segment
# Expand %VAR% tokens repeatedly for nested references.
for _ in range(4):
next_value = os.path.expandvars(expanded)
if next_value == expanded:
break
expanded = next_value
return expanded
def _which_windows(command: str) -> str | None:
found = shutil.which(command)
if found:
return found
if os.name != "nt":
return None
path_value = os.environ.get("PATH", "")
pathext = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD")
exts = [e.lower() for e in pathext.split(";") if e]
has_ext = pathlib.Path(command).suffix != ""
names = [command] if has_ext else [command, *(command + e.lower() for e in exts)]
for raw_segment in path_value.split(os.pathsep):
segment = _expand_windows_path_segment(raw_segment.strip())
if not segment:
continue
base = pathlib.Path(segment)
for name in names:
candidate = base / name
if candidate.exists():
return str(candidate)
return None
def sha256_files(paths: Iterable[pathlib.Path]) -> str:
h = hashlib.sha256()
for p in paths:
if not p.exists():
continue
h.update(p.read_bytes())
return h.hexdigest()
def first_existing(paths: Iterable[pathlib.Path]) -> pathlib.Path | None:
for p in paths:
if p.exists():
return p
return None
def find_csproj(repo_root: pathlib.Path, hints: Sequence[str] | None = None) -> pathlib.Path | None:
if hints:
for hint in hints:
candidate = (repo_root / hint).resolve()
if candidate.exists() and candidate.suffix.lower() == ".csproj":
return candidate
csprojs = sorted(repo_root.rglob("*.csproj"))
if not csprojs:
return None
if len(csprojs) == 1:
return csprojs[0]
return None
def find_csproj_by_keyword(repo_root: pathlib.Path, keywords: Sequence[str]) -> pathlib.Path | None:
kws = [k.lower() for k in keywords]
matches: list[pathlib.Path] = []
for p in repo_root.rglob("*.csproj"):
text = str(p).lower()
if any(k in text for k in kws):
matches.append(p)
if len(matches) == 1:
return matches[0]
return None
def find_node_app_root(repo_root: pathlib.Path, preferred: str | None = None) -> pathlib.Path | None:
def _read_package_json(package_json: pathlib.Path) -> dict | None:
if not package_json.exists():
return None
try:
data = json.loads(package_json.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else None
except Exception:
return None
def _has_scripts(package_json: pathlib.Path, names: Sequence[str]) -> bool:
data = _read_package_json(package_json)
if not data:
return False
scripts = data.get("scripts")
if not isinstance(scripts, dict):
return False
for name in names:
value = scripts.get(name)
if isinstance(value, str) and value.strip():
return True
return 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()
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
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
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
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]

View File

@ -0,0 +1,82 @@
#!/usr/bin/env python3
import argparse
import os
import shutil
from pathlib import Path
from script_common import newest_file, resolve_repo_root
def copy_tree_contents(src: Path, dst: Path) -> None:
dst.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
target = dst / item.name
if item.is_dir():
if target.exists():
shutil.rmtree(target)
shutil.copytree(item, target)
else:
shutil.copy2(item, target)
def main() -> int:
parser = argparse.ArgumentParser(description="Sync newest built assets into output folder")
parser.add_argument("--repo-root", default=None)
parser.add_argument("--output-dir", default="output")
parser.add_argument("--web-build-dir", default=None, help="Path to web build output")
parser.add_argument("--sidecar-bin-dir", default=None, help="Path to sidecar bin root")
parser.add_argument("--gateway-bin-dir", default=None, help="Path to gateway bin root")
parser.add_argument("--tauri-target-dir", default=None, help="Path to tauri target root")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
output_dir = (repo_root / args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
web_build = (repo_root / args.web_build_dir).resolve() if args.web_build_dir else None
if web_build is None:
web_build = next((p for p in repo_root.rglob("build") if (p.parent / "package.json").exists()), None)
if web_build is not None and web_build.exists():
web_out = output_dir / "webgateway" / "wwwroot"
copy_tree_contents(web_build, web_out)
print(f"Synced web assets -> {web_out}")
sidecar_bin = (repo_root / args.sidecar_bin_dir).resolve() if args.sidecar_bin_dir else None
if sidecar_bin is None:
sidecar_proj = next((p.parent for p in repo_root.rglob("*.csproj") if "sidecar" in str(p).lower()), None)
sidecar_bin = sidecar_proj / "bin" if sidecar_proj else None
if sidecar_bin is not None:
sidecar_pattern = "*.exe" if os.name == "nt" else "*"
sidecar_exe = newest_file(sidecar_bin, sidecar_pattern)
if sidecar_exe is not None:
copy_tree_contents(sidecar_exe.parent, output_dir)
print(f"Synced sidecar -> {output_dir}")
gateway_bin = (repo_root / args.gateway_bin_dir).resolve() if args.gateway_bin_dir else None
if gateway_bin is None:
gateway_proj = next((p.parent for p in repo_root.rglob("*.csproj") if "gateway" in str(p).lower()), None)
gateway_bin = gateway_proj / "bin" if gateway_proj else None
if gateway_bin is not None:
gateway_pattern = "*.exe" if os.name == "nt" else "*"
gw_exe = newest_file(gateway_bin, gateway_pattern)
if gw_exe is not None:
gw_out = output_dir / "webgateway"
copy_tree_contents(gw_exe.parent, gw_out)
print(f"Synced gateway -> {gw_out}")
tauri_target = (repo_root / args.tauri_target_dir).resolve() if args.tauri_target_dir else None
if tauri_target is None:
tauri_target = next((p for p in repo_root.rglob("src-tauri") if (p / "target").exists()), None)
tauri_target = tauri_target / "target" if tauri_target else None
if tauri_target is not None:
app_exe = newest_file(tauri_target, "*.exe")
if app_exe is not None:
shutil.copy2(app_exe, output_dir / app_exe.name)
print(f"Synced desktop app ({app_exe.name}) -> {output_dir}")
print("Sync complete.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,298 @@
#!/usr/bin/env python3
import argparse
import json
import pathlib
import shutil
import subprocess
import sys
from typing import Any, Dict, List, Optional, Sequence, Tuple
from script_common import resolve_command, resolve_repo_root
def load_config(project_root: pathlib.Path) -> dict:
config_path = project_root / "devtool.json"
if not config_path.exists():
raise FileNotFoundError(f"devtool.json 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]:
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)]
if selected:
normalized = [w for w in normalized if w["id"] in selected]
return normalized
def is_command_available(command: str) -> bool:
resolved = resolve_command(command)
if pathlib.Path(resolved).is_file():
return True
return shutil.which(resolved) is not None
def resolve_script_arg(project_root: pathlib.Path, working_dir: pathlib.Path, arg: str) -> pathlib.Path:
p = pathlib.Path(arg)
if p.is_absolute():
return p
a = working_dir / p
if a.exists():
return a
b = project_root / p
return b
def static_check_workflow(project_root: pathlib.Path, workflow: dict) -> dict:
result = {
"workflowId": workflow.get("id"),
"ok": True,
"issues": [],
"steps": [],
}
for step in workflow.get("steps", []):
if not isinstance(step, dict):
continue
step_id = step.get("id", "<unknown>")
step_result = {"stepId": step_id, "ok": True, "issues": []}
working_dir_rel = step.get("workingDir") or "."
working_dir = (project_root / working_dir_rel).resolve()
if not working_dir.exists():
step_result["ok"] = False
step_result["issues"].append(f"workingDir_not_found:{working_dir}")
command = step.get("command")
args = step.get("args") or []
action = step.get("action")
if isinstance(command, str) and command.strip():
if not is_command_available(command):
step_result["ok"] = False
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"):
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")
if not step_result["ok"]:
result["ok"] = False
result["issues"].extend(step_result["issues"])
result["steps"].append(step_result)
return result
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"])
local_exe = repo_root / ("sdt.exe" if sys.platform.startswith("win") else "sdt")
if local_exe.exists():
attempts.append([str(local_exe)])
devtool_csproj = repo_root / "DevTool.csproj"
if devtool_csproj.exists():
attempts.append(["dotnet", "run", "--project", str(devtool_csproj), "--"])
# Preserve order but dedupe exact attempts.
seen = set()
unique: List[List[str]] = []
for a in attempts:
key = tuple(a)
if key in seen:
continue
seen.add(key)
unique.append(a)
return unique
def try_run_sdt(
repo_root: pathlib.Path,
command_args: Sequence[str],
timeout_seconds: int,
) -> Tuple[Optional[subprocess.CompletedProcess], Optional[str]]:
errors: List[str] = []
for base in sdt_attempts(repo_root):
cmd = [*base, *command_args]
try:
proc = subprocess.run(
cmd,
cwd=str(repo_root),
text=True,
capture_output=True,
timeout=timeout_seconds,
check=False,
)
return proc, " ".join(cmd)
except FileNotFoundError:
errors.append(f"not_found:{' '.join(cmd)}")
except subprocess.TimeoutExpired:
errors.append(f"timeout:{' '.join(cmd)}")
return None, "; ".join(errors) if errors else "no_sdt_attempts"
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("{"):
continue
try:
payload = json.loads(line)
except Exception:
continue
if isinstance(payload, dict) and "runId" in payload and "success" in payload:
return payload
return None
def execute_check_workflow(
repo_root: pathlib.Path,
project_root: pathlib.Path,
workflow_id: str,
env_profile: Optional[str],
timeout_seconds: int,
) -> dict:
args = [
"run",
workflow_id,
"--json",
"--project-root",
str(project_root),
"--non-interactive",
]
if env_profile:
args.extend(["--env-profile", env_profile])
proc, attempted = try_run_sdt(repo_root, args, timeout_seconds)
if proc is None:
return {
"workflowId": workflow_id,
"ok": False,
"attempted": attempted,
"exitCode": None,
"stopReason": "sdt_not_runnable",
"message": attempted,
}
summary = parse_headless_summary(proc.stdout)
if summary is None:
return {
"workflowId": workflow_id,
"ok": False,
"attempted": attempted,
"exitCode": proc.returncode,
"stopReason": "missing_summary",
"message": (proc.stderr or proc.stdout).strip(),
}
return {
"workflowId": workflow_id,
"ok": bool(summary.get("success", False)),
"attempted": attempted,
"exitCode": summary.get("exitCode"),
"stopReason": summary.get("stopReason"),
"message": summary.get("message"),
}
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")
args = parser.parse_args()
repo_root = resolve_repo_root(args.repo_root)
project_root = (repo_root / args.project_root).resolve() if not pathlib.Path(args.project_root).is_absolute() else pathlib.Path(args.project_root).resolve()
selected = set(args.workflow) if args.workflow else None
config = load_config(project_root)
workflows = iter_workflows(config, selected)
if not workflows:
print("No workflows selected/found.")
return 2
static_results = [static_check_workflow(project_root, w) for w in workflows]
execute_results: List[dict] = []
if args.execute:
for w in workflows:
wid = w["id"]
execute_results.append(
execute_check_workflow(
repo_root=repo_root,
project_root=project_root,
workflow_id=wid,
env_profile=args.env_profile,
timeout_seconds=args.timeout_seconds,
)
)
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 = {
"repoRoot": str(repo_root),
"projectRoot": str(project_root),
"totalWorkflows": len(workflows),
"static": {
"checked": len(static_results),
"failed": len(static_failures),
"results": static_results,
},
"execute": {
"enabled": args.execute,
"checked": len(execute_results),
"failed": len(exec_failures),
"results": execute_results,
},
}
if args.output_json:
out_path = pathlib.Path(args.output_json)
if not out_path.is_absolute():
out_path = repo_root / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"Report written: {out_path}")
print(f"Static checks: {len(static_results)} workflow(s), failures={len(static_failures)}")
if args.execute:
print(f"Execution checks: {len(execute_results)} workflow(s), failures={len(exec_failures)}")
if static_failures:
print("\nStatic failures:")
for f in static_failures:
print(f"- {f['workflowId']}: {', '.join(f['issues'])}")
if exec_failures:
print("\nExecution failures:")
for f in exec_failures:
print(f"- {f['workflowId']}: stopReason={f.get('stopReason')} message={f.get('message')}")
return 1 if static_failures or exec_failures else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,108 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v10.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v10.0": {
"sdt/1.0.0": {
"dependencies": {
"DevTool.Engine": "1.0.0",
"DevTool.Host.Bridge": "1.0.0",
"DevTool.Host.Tui": "1.0.0",
"DevTool.Runtime": "1.0.0",
"Spectre.Console": "0.49.1"
},
"runtime": {
"sdt.dll": {}
}
},
"Spectre.Console/0.49.1": {
"runtime": {
"lib/net8.0/Spectre.Console.dll": {
"assemblyVersion": "0.0.0.0",
"fileVersion": "0.49.1.0"
}
}
},
"DevTool.Engine/1.0.0": {
"dependencies": {
"DevTool.Runtime": "1.0.0"
},
"runtime": {
"DevTool.Engine.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
},
"DevTool.Host.Bridge/1.0.0": {
"dependencies": {
"DevTool.Engine": "1.0.0"
},
"runtime": {
"DevTool.Host.Bridge.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
},
"DevTool.Host.Tui/1.0.0": {
"dependencies": {
"DevTool.Engine": "1.0.0",
"DevTool.Runtime": "1.0.0",
"Spectre.Console": "0.49.1"
},
"runtime": {
"DevTool.Host.Tui.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
},
"DevTool.Runtime/1.0.0": {
"runtime": {
"DevTool.Runtime.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
}
}
},
"libraries": {
"sdt/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Spectre.Console/0.49.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-USV+pdu49OJ3nCjxNuw1K9Zw/c1HCBbwbjXZp0EOn6wM99tFdAtN34KEBZUMyRuJuXlUMDqhd8Yq9obW2MslYA==",
"path": "spectre.console/0.49.1",
"hashPath": "spectre.console.0.49.1.nupkg.sha512"
},
"DevTool.Engine/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"DevTool.Host.Bridge/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"DevTool.Host.Tui/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"DevTool.Runtime/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
}
}
}

BIN
Journal.DevTool/sdt.dll Normal file

Binary file not shown.

BIN
Journal.DevTool/sdt.exe Normal file

Binary file not shown.

BIN
Journal.DevTool/sdt.pdb Normal file

Binary file not shown.

View File

@ -0,0 +1,13 @@
{
"runtimeOptions": {
"tfm": "net10.0",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "10.0.0"
},
"configProperties": {
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

View File

@ -676,7 +676,7 @@
],
"values": {
"SDT_ENV_PROFILE": "ci",
"CI": "true",
"CI": "false",
"SDT_LOG_LEVEL": "warning"
}
},
@ -783,4 +783,4 @@
"bundleOnFailure": true
}
}
}
}