diff --git a/.gitignore b/.gitignore index c51e801..13d59ed 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Journal.DevTool/.gitkeep b/Journal.DevTool/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Journal.DevTool/DevTool.Engine.dll b/Journal.DevTool/DevTool.Engine.dll new file mode 100644 index 0000000..99c4dbf Binary files /dev/null and b/Journal.DevTool/DevTool.Engine.dll differ diff --git a/Journal.DevTool/DevTool.Engine.pdb b/Journal.DevTool/DevTool.Engine.pdb new file mode 100644 index 0000000..ededda9 Binary files /dev/null and b/Journal.DevTool/DevTool.Engine.pdb differ diff --git a/Journal.DevTool/DevTool.Host.Bridge.dll b/Journal.DevTool/DevTool.Host.Bridge.dll new file mode 100644 index 0000000..5d60e0d Binary files /dev/null and b/Journal.DevTool/DevTool.Host.Bridge.dll differ diff --git a/Journal.DevTool/DevTool.Host.Bridge.pdb b/Journal.DevTool/DevTool.Host.Bridge.pdb new file mode 100644 index 0000000..71b9127 Binary files /dev/null and b/Journal.DevTool/DevTool.Host.Bridge.pdb differ diff --git a/Journal.DevTool/DevTool.Host.Tui.dll b/Journal.DevTool/DevTool.Host.Tui.dll new file mode 100644 index 0000000..52f5ce3 Binary files /dev/null and b/Journal.DevTool/DevTool.Host.Tui.dll differ diff --git a/Journal.DevTool/DevTool.Host.Tui.pdb b/Journal.DevTool/DevTool.Host.Tui.pdb new file mode 100644 index 0000000..325d700 Binary files /dev/null and b/Journal.DevTool/DevTool.Host.Tui.pdb differ diff --git a/Journal.DevTool/DevTool.Runtime.dll b/Journal.DevTool/DevTool.Runtime.dll new file mode 100644 index 0000000..61e3279 Binary files /dev/null and b/Journal.DevTool/DevTool.Runtime.dll differ diff --git a/Journal.DevTool/DevTool.Runtime.pdb b/Journal.DevTool/DevTool.Runtime.pdb new file mode 100644 index 0000000..4c8b134 Binary files /dev/null and b/Journal.DevTool/DevTool.Runtime.pdb differ diff --git a/Journal.DevTool/Spectre.Console.dll b/Journal.DevTool/Spectre.Console.dll new file mode 100644 index 0000000..85cd7b4 Binary files /dev/null and b/Journal.DevTool/Spectre.Console.dll differ diff --git a/Journal.DevTool/scripts/_pwsh-python-shim.ps1 b/Journal.DevTool/scripts/_pwsh-python-shim.ps1 new file mode 100644 index 0000000..4a16a55 --- /dev/null +++ b/Journal.DevTool/scripts/_pwsh-python-shim.ps1 @@ -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 +} diff --git a/Journal.DevTool/scripts/build.py b/Journal.DevTool/scripts/build.py new file mode 100644 index 0000000..39676ce --- /dev/null +++ b/Journal.DevTool/scripts/build.py @@ -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()) diff --git a/Journal.DevTool/scripts/dev-shell.cmd b/Journal.DevTool/scripts/dev-shell.cmd new file mode 100644 index 0000000..b1614b7 --- /dev/null +++ b/Journal.DevTool/scripts/dev-shell.cmd @@ -0,0 +1,17 @@ +@echo off +set "SCRIPT_DIR=%~dp0" + +where py >nul 2>nul +if %ERRORLEVEL%==0 ( + set "PYEXE=py" +) else ( + where python >nul 2>nul + if not %ERRORLEVEL%==0 ( + echo python not found. + exit /b 1 + ) + set "PYEXE=python" +) + +for /f "usebackq delims=" %%L in (`"%PYEXE%" "%SCRIPT_DIR%dev_shell.py" export --shell cmd`) do %%L +echo Development shell initialized from Python bootstrap script. diff --git a/Journal.DevTool/scripts/dev-shell.ps1 b/Journal.DevTool/scripts/dev-shell.ps1 new file mode 100644 index 0000000..7f4a3f7 --- /dev/null +++ b/Journal.DevTool/scripts/dev-shell.ps1 @@ -0,0 +1,21 @@ +# Run this in PowerShell before development commands: +# . ./scripts/dev-shell.ps1 + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot '_pwsh-python-shim.ps1') + +$scriptPath = Resolve-SdtScriptPath -ScriptName 'dev_shell.py' +$python = Resolve-SdtPython + +$lines = & $python $scriptPath export --shell pwsh +if ($LASTEXITCODE -ne 0) { + throw "Failed to initialize development shell via dev_shell.py" +} + +foreach ($line in $lines) { + Invoke-Expression $line +} + +Write-Host "Development shell initialized from Python bootstrap script." diff --git a/Journal.DevTool/scripts/dev-shell.sh b/Journal.DevTool/scripts/dev-shell.sh new file mode 100644 index 0000000..83468f7 --- /dev/null +++ b/Journal.DevTool/scripts/dev-shell.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +if command -v python3 >/dev/null 2>&1; then + PYTHON_EXE="python3" +elif command -v python >/dev/null 2>&1; then + PYTHON_EXE="python" +else + echo "python3/python not found." >&2 + exit 1 +fi + +eval "$("$PYTHON_EXE" "$SCRIPT_DIR/dev_shell.py" export --shell bash)" +echo "Development shell initialized from Python bootstrap script." diff --git a/Journal.DevTool/scripts/dev_shell.py b/Journal.DevTool/scripts/dev_shell.py new file mode 100644 index 0000000..1a5d8ea --- /dev/null +++ b/Journal.DevTool/scripts/dev_shell.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import sys + +from script_common import PROXY_VARS, clean_proxy_env, dotnet_env, ensure_dirs, pip_env, resolve_repo_root + + +def huggingface_env(repo_root: pathlib.Path) -> dict[str, str]: + env = {} + hf_home = repo_root / ".cache" / "huggingface" + hf_hub_cache = hf_home / "hub" + ensure_dirs([hf_hub_cache]) + env["HF_HOME"] = str(hf_home) + env["HUGGINGFACE_HUB_CACHE"] = str(hf_hub_cache) + env["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" + return env + + +def resolved_env(repo_root: pathlib.Path) -> dict[str, str]: + env = {} + dotnet = dotnet_env(repo_root) + pip = pip_env(repo_root) + hf = huggingface_env(repo_root) + + dotnet_keys = [ + "DOTNET_CLI_HOME", + "NUGET_PACKAGES", + "NUGET_HTTP_CACHE_PATH", + "DOTNET_SKIP_FIRST_TIME_EXPERIENCE", + "DOTNET_ADD_GLOBAL_TOOLS_TO_PATH", + "DOTNET_GENERATE_ASPNET_CERTIFICATE", + "DOTNET_CLI_TELEMETRY_OPTOUT", + "NUGET_CERT_REVOCATION_MODE", + ] + pip_keys = [ + "PIP_CACHE_DIR", + "PIP_DISABLE_PIP_VERSION_CHECK", + "PIP_DEFAULT_TIMEOUT", + "PIP_RETRIES", + "TEMP", + "TMP", + ] + for key in dotnet_keys: + env[key] = dotnet[key] + for key in pip_keys: + env[key] = pip[key] + env.update(hf) + clean_proxy_env(env) + return env + + +def export_lines(shell: str, env_map: dict[str, str]) -> list[str]: + def sh_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + if shell == "pwsh": + lines = [f"Remove-Item Env:{k} -ErrorAction SilentlyContinue" for k in PROXY_VARS] + lines.extend(f"$env:{k} = \"{v.replace('\"', '`\"')}\"" for k, v in env_map.items()) + return lines + if shell in ("bash", "zsh"): + lines = [f"unset {k}" for k in PROXY_VARS] + lines.extend(f"export {k}={sh_quote(v)}" for k, v in env_map.items()) + return lines + if shell == "cmd": + lines = [f"set {k}=" for k in PROXY_VARS] + lines.extend(f"set {k}={v}" for k, v in env_map.items()) + return lines + raise ValueError(shell) + + +def cmd_export(args): + try: + repo_root = resolve_repo_root(args.project_root) + except Exception as ex: + print(f"Failed to resolve project root: {ex}", file=sys.stderr) + return 2 + + env_map = resolved_env(repo_root) + payload = { + "projectRoot": str(repo_root), + "env": env_map, + "createdDirs": [ + str(repo_root / ".dotnet_home"), + str(repo_root / ".nuget" / "packages"), + str(repo_root / ".nuget" / "http-cache"), + str(repo_root / ".pip" / "cache"), + str(repo_root / ".tmp" / "pip-temp"), + str(repo_root / ".cache" / "huggingface" / "hub"), + ], + "warnings": [], + } + + try: + lines = export_lines(args.shell, env_map) + except ValueError: + print(f"Unsupported shell target: {args.shell}", file=sys.stderr) + return 3 + + if args.json: + print(json.dumps(payload)) + else: + for line in lines: + print(line) + return 0 + + +def cmd_doctor(args): + try: + repo_root = resolve_repo_root(args.project_root) + except Exception as ex: + print(f"Failed to resolve project root: {ex}", file=sys.stderr) + return 2 + + env_map = resolved_env(repo_root) + checks = { + "repo_root": str(repo_root), + "dotnet_home_exists": (repo_root / ".dotnet_home").exists(), + "nuget_cache_exists": (repo_root / ".nuget" / "packages").exists(), + "pip_cache_exists": (repo_root / ".pip" / "cache").exists(), + "hf_cache_exists": (repo_root / ".cache" / "huggingface" / "hub").exists(), + "env_count": len(env_map), + } + print(json.dumps(checks)) + return 0 + + +def main(): + parser = argparse.ArgumentParser(description="SDT cross-platform shell bootstrap helper") + sub = parser.add_subparsers(dest="command", required=True) + + p_export = sub.add_parser("export", help="Print env exports for a shell") + p_export.add_argument("--shell", required=True) + p_export.add_argument("--project-root") + p_export.add_argument("--json", action="store_true") + + p_doctor = sub.add_parser("doctor", help="Validate env bootstrap paths") + p_doctor.add_argument("--project-root") + + args = parser.parse_args() + if args.command == "export": + return cmd_export(args) + return cmd_doctor(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Journal.DevTool/scripts/diag.py b/Journal.DevTool/scripts/diag.py new file mode 100644 index 0000000..20bf41b --- /dev/null +++ b/Journal.DevTool/scripts/diag.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import platform +import shutil +import subprocess +import sys +from script_common import resolve_command + + +def run_capture(cmd): + try: + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + out = (proc.stdout or "").strip() + err = (proc.stderr or "").strip() + text = out if out else err + return proc.returncode == 0, text + except Exception as ex: + return False, str(ex) + + +def probe_tool(tool): + mapping = { + "dotnet": ["dotnet", "--version"], + "node": ["node", "--version"], + "npm": ["npm", "--version"], + "python": ["python", "--version"], + "cargo": ["cargo", "--version"], + "tauri": ["tauri", "--version"], + "git": ["git", "--version"], + "docker": ["docker", "--version"], + } + cmd = mapping.get(tool, [tool, "--version"]) + resolved = resolve_command(cmd[0]) + if shutil.which(resolved) is None and not os.path.exists(resolved): + return {"tool": tool, "available": False, "version": None, "details": f"{cmd[0]} not found in PATH"} + cmd = [resolved, *cmd[1:]] + ok, text = run_capture(cmd) + return {"tool": tool, "available": ok, "version": text if ok else None, "details": None if ok else text} + + +def install_plan(tool): + is_windows = platform.system().lower().startswith("win") + if is_windows: + plans = { + "dotnet": [("winget", ["install", "Microsoft.DotNet.SDK.10"])], + "node": [("winget", ["install", "OpenJS.NodeJS.LTS"])], + "npm": [("winget", ["install", "OpenJS.NodeJS.LTS"])], + "python": [("winget", ["install", "Python.Python.3.12"])], + "cargo": [("winget", ["install", "Rustlang.Rustup"])], + "tauri": [("npm", ["install", "-g", "@tauri-apps/cli"])], + "git": [("winget", ["install", "Git.Git"])], + "docker": [("winget", ["install", "Docker.DockerDesktop"])], + } + else: + plans = { + "dotnet": [("sh", ["-c", "echo install dotnet sdk with your package manager"])], + "node": [("sh", ["-c", "echo install nodejs with your package manager"])], + "npm": [("sh", ["-c", "echo install npm with your package manager"])], + "python": [("sh", ["-c", "echo install python3 with your package manager"])], + "cargo": [("sh", ["-c", "curl https://sh.rustup.rs -sSf | sh"])], + "tauri": [("npm", ["install", "-g", "@tauri-apps/cli"])], + "git": [("sh", ["-c", "echo install git with your package manager"])], + "docker": [("sh", ["-c", "echo install docker with your package manager"])], + } + + cmds = plans.get(tool, []) + return { + "tool": tool, + "supported": len(cmds) > 0, + "summary": f"Install plan for {tool} on {platform.system()}", + "commands": [{"command": c, "args": a} for c, a in cmds], + } + + +def run_install(tool): + plan = install_plan(tool) + if not plan["supported"]: + return 2 + for cmd in plan["commands"]: + proc = subprocess.run([cmd["command"], *cmd["args"]], check=False) + if proc.returncode != 0: + return proc.returncode + return 0 + + +def main(): + parser = argparse.ArgumentParser(description="SDT diagnostics and install planner") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_probe = sub.add_parser("probe") + p_probe.add_argument("--tool", required=True) + p_probe.add_argument("--json", action="store_true") + + p_plan = sub.add_parser("install-plan") + p_plan.add_argument("--tool", required=True) + p_plan.add_argument("--json", action="store_true") + + p_run = sub.add_parser("install-run") + p_run.add_argument("--tool", required=True) + + args = parser.parse_args() + + if args.cmd == "probe": + result = probe_tool(args.tool.lower()) + if args.json: + print(json.dumps(result)) + else: + print(result) + return 0 if result["available"] else 1 + + if args.cmd == "install-plan": + result = install_plan(args.tool.lower()) + if args.json: + print(json.dumps(result)) + else: + print(result) + return 0 if result["supported"] else 2 + + if args.cmd == "install-run": + return run_install(args.tool.lower()) + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Journal.DevTool/scripts/dotnet-min.py b/Journal.DevTool/scripts/dotnet-min.py new file mode 100644 index 0000000..c8aa0f4 --- /dev/null +++ b/Journal.DevTool/scripts/dotnet-min.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import argparse +import sys + +from script_common import dotnet_env, resolve_repo_root, run + + +DOTNET_SAFE_CMDS = {"restore", "build", "run", "test", "publish", "pack"} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform minimal dotnet wrapper") + parser.add_argument("dotnet_args", nargs=argparse.REMAINDER) + parser.add_argument("--repo-root", default=None) + args = parser.parse_args() + + if not args.dotnet_args: + print("Usage: python scripts/dotnet-min.py ", file=sys.stderr) + return 2 + + repo_root = resolve_repo_root(args.repo_root) + dotnet_args = list(args.dotnet_args) + cmd = dotnet_args[0].lower() + + if cmd in DOTNET_SAFE_CMDS: + dotnet_args.extend(["-p:RestoreIgnoreFailedSources=true", "-p:NuGetAudit=false"]) + if cmd == "restore": + dotnet_args.append("--ignore-failed-sources") + + return run("dotnet", dotnet_args, repo_root, env=dotnet_env(repo_root)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/migration-gate.py b/Journal.DevTool/scripts/migration-gate.py new file mode 100644 index 0000000..398cf09 --- /dev/null +++ b/Journal.DevTool/scripts/migration-gate.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import argparse +import subprocess +import sys +from pathlib import Path + +from script_common import resolve_repo_root + + +def run_step(repo_root: Path, title: str, command: list[str]) -> int: + print(f"\n== {title} ==") + print("$", " ".join(command)) + proc = subprocess.run(command, cwd=str(repo_root), check=False) + return proc.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform migration quality gate") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--skip-tests", action="store_true") + parser.add_argument("--test-project", default=None, help="Optional test csproj path") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + + code = run_step(repo_root, "Build", [sys.executable, "scripts/dotnet-min.py", "build"]) + if code != 0: + return code + + if not args.skip_tests: + if args.test_project: + test_cmd = [sys.executable, "scripts/dotnet-min.py", "test", args.test_project] + else: + test_cmd = [sys.executable, "scripts/dotnet-min.py", "test"] + code = run_step(repo_root, "Tests", test_cmd) + if code != 0: + return code + + print("\nMigration gate passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/npm-clean.py b/Journal.DevTool/scripts/npm-clean.py new file mode 100644 index 0000000..48d8881 --- /dev/null +++ b/Journal.DevTool/scripts/npm-clean.py @@ -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()) diff --git a/Journal.DevTool/scripts/nuget-export-cache.py b/Journal.DevTool/scripts/nuget-export-cache.py new file mode 100644 index 0000000..17720f7 --- /dev/null +++ b/Journal.DevTool/scripts/nuget-export-cache.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +import argparse +import shutil +import tempfile +from pathlib import Path + +from script_common import resolve_repo_root + + +def main() -> int: + parser = argparse.ArgumentParser(description="Export local NuGet cache to zip") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--output-zip", default="nuget-cache-export.zip") + parser.add_argument("--include-dotnet-home", action="store_true") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + output_zip = (repo_root / args.output_zip).resolve() + + nuget_dir = repo_root / ".nuget" + dotnet_home = repo_root / ".dotnet_home" + if not nuget_dir.exists(): + print(f"NuGet cache not found: {nuget_dir}") + return 2 + + with tempfile.TemporaryDirectory() as td: + stage = Path(td) / "cache-export" + stage.mkdir(parents=True, exist_ok=True) + shutil.copytree(nuget_dir, stage / ".nuget") + if args.include_dotnet_home and dotnet_home.exists(): + shutil.copytree(dotnet_home, stage / ".dotnet_home") + manifest = stage / "nuget-cache-manifest.txt" + manifest.write_text("exported_by=nuget-export-cache.py\n", encoding="utf-8") + archive_base = str(output_zip.with_suffix("")) + shutil.make_archive(archive_base, "zip", root_dir=str(stage)) + + print(f"Exported cache: {output_zip}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/nuget-import-cache.py b/Journal.DevTool/scripts/nuget-import-cache.py new file mode 100644 index 0000000..608ed60 --- /dev/null +++ b/Journal.DevTool/scripts/nuget-import-cache.py @@ -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()) diff --git a/Journal.DevTool/scripts/pip-min.py b/Journal.DevTool/scripts/pip-min.py new file mode 100644 index 0000000..fd03343 --- /dev/null +++ b/Journal.DevTool/scripts/pip-min.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +import argparse +import os +import sys + +from script_common import pip_env, resolve_repo_root, run + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform minimal pip wrapper") + parser.add_argument("pip_args", nargs=argparse.REMAINDER) + parser.add_argument("--repo-root", default=None) + args = parser.parse_args() + + if not args.pip_args: + print("Usage: python scripts/pip-min.py ", file=sys.stderr) + return 2 + + repo_root = resolve_repo_root(args.repo_root) + pip_args = list(args.pip_args) + + # Preserve legacy behavior: for bare install, default target to repo-local deps. + if pip_args and pip_args[0].lower() == "install": + has_target = any(a in ("--target", "--prefix") for a in pip_args) + if not has_target: + pip_args = [a for a in pip_args if a != "--user"] + target = repo_root / ".pydeps" / f"py{sys.version_info.major}{sys.version_info.minor}" + os.makedirs(target, exist_ok=True) + pip_args.extend(["--target", str(target)]) + + return run(sys.executable, ["-m", "pip", *pip_args], repo_root, env=pip_env(repo_root)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/pip_safe.py b/Journal.DevTool/scripts/pip_safe.py new file mode 100644 index 0000000..f520fc2 --- /dev/null +++ b/Journal.DevTool/scripts/pip_safe.py @@ -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:])) + diff --git a/Journal.DevTool/scripts/publish-app.py b/Journal.DevTool/scripts/publish-app.py new file mode 100644 index 0000000..9f82a8f --- /dev/null +++ b/Journal.DevTool/scripts/publish-app.py @@ -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()) diff --git a/Journal.DevTool/scripts/publish-output.py b/Journal.DevTool/scripts/publish-output.py new file mode 100644 index 0000000..82c4aaa --- /dev/null +++ b/Journal.DevTool/scripts/publish-output.py @@ -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()) diff --git a/Journal.DevTool/scripts/publish-sidecar.py b/Journal.DevTool/scripts/publish-sidecar.py new file mode 100644 index 0000000..964f750 --- /dev/null +++ b/Journal.DevTool/scripts/publish-sidecar.py @@ -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 .") + return 2 + + publish_args = [ + "publish", + str(csproj), + "-c", + args.configuration, + "-r", + args.runtime, + "--self-contained", + "-p:PublishSingleFile=true", + "-p:IncludeNativeLibrariesForSelfExtract=true", + "-p:RestoreIgnoreFailedSources=true", + "-p:NuGetAudit=false", + "-o", + str(output_dir), + ] + code = run("dotnet", publish_args, repo_root, env=dotnet_env(repo_root)) + if code != 0: + return code + + binary_name = csproj.stem + (".exe" if args.runtime.startswith("win-") else "") + binary_path = output_dir / binary_name + if binary_path.exists(): + print(f"Published executable: {binary_path}") + else: + print(f"Publish completed. Output directory: {output_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/publish-webgateway.py b/Journal.DevTool/scripts/publish-webgateway.py new file mode 100644 index 0000000..6a2c9c0 --- /dev/null +++ b/Journal.DevTool/scripts/publish-webgateway.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +import argparse +import shutil + +from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cross-platform ASP.NET gateway publish helper") + parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release") + parser.add_argument("--runtime", default="win-x64") + parser.add_argument("--self-contained", action="store_true") + parser.add_argument("--skip-web-assets", action="store_true") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--project", default=None, help="Relative/absolute path to gateway csproj") + parser.add_argument("--web-build-dir", default=None, help="Relative path to web build assets root") + parser.add_argument("--output-dir", default="output/webgateway") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + output_dir = (repo_root / args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + if args.project: + csproj = (repo_root / args.project).resolve() + else: + csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"]) + if csproj is None or not csproj.exists(): + print("Could not locate web gateway project. Pass --project .") + return 2 + + publish_args = [ + "publish", + str(csproj), + "-c", + args.configuration, + "-r", + args.runtime, + "--self-contained", + "true" if args.self_contained else "false", + "-p:RestoreIgnoreFailedSources=true", + "-p:NuGetAudit=false", + "-o", + str(output_dir), + ] + code = run("dotnet", publish_args, repo_root, env=dotnet_env(repo_root)) + if code != 0: + return code + + if not args.skip_web_assets: + if args.web_build_dir: + web_build_dir = (repo_root / args.web_build_dir).resolve() + else: + web_build_dir = next((p.parent for p in repo_root.rglob("package.json") if (p.parent / "build").exists()), None) + if web_build_dir is not None: + web_build_dir = web_build_dir / "build" + + if web_build_dir is None or not web_build_dir.exists(): + print("Web assets not found. Skip with --skip-web-assets or pass --web-build-dir.") + else: + web_out = output_dir / "wwwroot" + web_out.mkdir(parents=True, exist_ok=True) + for item in web_build_dir.iterdir(): + dst = web_out / item.name + if item.is_dir(): + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(item, dst) + else: + shutil.copy2(item, dst) + print(f"Copied web assets: {web_out}") + + print(f"Publish completed: {output_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/run-webgateway.py b/Journal.DevTool/scripts/run-webgateway.py new file mode 100644 index 0000000..3f35dbc --- /dev/null +++ b/Journal.DevTool/scripts/run-webgateway.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +import argparse +import os +from pathlib import Path + +from script_common import dotnet_env, find_csproj_by_keyword, resolve_repo_root, run + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run gateway in dev or output mode") + parser.add_argument("--configuration", choices=["Release", "Debug"], default="Release") + parser.add_argument("--urls", default="http://0.0.0.0:5180") + parser.add_argument("--project-root", default=None, help="Runtime project root exposed via SDT_PROJECT_ROOT") + parser.add_argument("--mode", choices=["Dev", "Output"], default="Dev") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--project", default=None, help="Gateway csproj path") + parser.add_argument("--output-exe", default=None, help="Published gateway executable path") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + effective_project_root = Path(args.project_root).resolve() if args.project_root else repo_root + if not effective_project_root.exists(): + print(f"Project root does not exist: {effective_project_root}") + return 2 + + env = dotnet_env(repo_root) + env["SDT_PROJECT_ROOT"] = str(effective_project_root) + + if args.mode == "Output": + exe_path = Path(args.output_exe).resolve() if args.output_exe else (repo_root / "output" / "webgateway" / ("webgateway.exe" if os.name == "nt" else "webgateway")) + if not exe_path.exists(): + print(f"Output executable not found: {exe_path}") + return 2 + return run(str(exe_path), ["--urls", args.urls], repo_root, env=env) + + if args.project: + csproj = (repo_root / args.project).resolve() + else: + csproj = find_csproj_by_keyword(repo_root, ["webgateway", "gateway"]) + if csproj is None or not csproj.exists(): + print("Could not locate gateway project. Pass --project .") + return 2 + + run_args = [ + "run", + "--project", + str(csproj), + "-c", + args.configuration, + "--no-launch-profile", + "--urls", + args.urls, + "-p:RestoreIgnoreFailedSources=true", + "-p:NuGetAudit=false", + ] + return run("dotnet", run_args, repo_root, env=env) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/script_common.py b/Journal.DevTool/scripts/script_common.py new file mode 100644 index 0000000..03faa87 --- /dev/null +++ b/Journal.DevTool/scripts/script_common.py @@ -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] diff --git a/Journal.DevTool/scripts/sync-output.py b/Journal.DevTool/scripts/sync-output.py new file mode 100644 index 0000000..8a28a2b --- /dev/null +++ b/Journal.DevTool/scripts/sync-output.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import argparse +import os +import shutil +from pathlib import Path + +from script_common import newest_file, resolve_repo_root + + +def copy_tree_contents(src: Path, dst: Path) -> None: + dst.mkdir(parents=True, exist_ok=True) + for item in src.iterdir(): + target = dst / item.name + if item.is_dir(): + if target.exists(): + shutil.rmtree(target) + shutil.copytree(item, target) + else: + shutil.copy2(item, target) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Sync newest built assets into output folder") + parser.add_argument("--repo-root", default=None) + parser.add_argument("--output-dir", default="output") + parser.add_argument("--web-build-dir", default=None, help="Path to web build output") + parser.add_argument("--sidecar-bin-dir", default=None, help="Path to sidecar bin root") + parser.add_argument("--gateway-bin-dir", default=None, help="Path to gateway bin root") + parser.add_argument("--tauri-target-dir", default=None, help="Path to tauri target root") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + output_dir = (repo_root / args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + web_build = (repo_root / args.web_build_dir).resolve() if args.web_build_dir else None + if web_build is None: + web_build = next((p for p in repo_root.rglob("build") if (p.parent / "package.json").exists()), None) + if web_build is not None and web_build.exists(): + web_out = output_dir / "webgateway" / "wwwroot" + copy_tree_contents(web_build, web_out) + print(f"Synced web assets -> {web_out}") + + sidecar_bin = (repo_root / args.sidecar_bin_dir).resolve() if args.sidecar_bin_dir else None + if sidecar_bin is None: + sidecar_proj = next((p.parent for p in repo_root.rglob("*.csproj") if "sidecar" in str(p).lower()), None) + sidecar_bin = sidecar_proj / "bin" if sidecar_proj else None + if sidecar_bin is not None: + sidecar_pattern = "*.exe" if os.name == "nt" else "*" + sidecar_exe = newest_file(sidecar_bin, sidecar_pattern) + if sidecar_exe is not None: + copy_tree_contents(sidecar_exe.parent, output_dir) + print(f"Synced sidecar -> {output_dir}") + + gateway_bin = (repo_root / args.gateway_bin_dir).resolve() if args.gateway_bin_dir else None + if gateway_bin is None: + gateway_proj = next((p.parent for p in repo_root.rglob("*.csproj") if "gateway" in str(p).lower()), None) + gateway_bin = gateway_proj / "bin" if gateway_proj else None + if gateway_bin is not None: + gateway_pattern = "*.exe" if os.name == "nt" else "*" + gw_exe = newest_file(gateway_bin, gateway_pattern) + if gw_exe is not None: + gw_out = output_dir / "webgateway" + copy_tree_contents(gw_exe.parent, gw_out) + print(f"Synced gateway -> {gw_out}") + + tauri_target = (repo_root / args.tauri_target_dir).resolve() if args.tauri_target_dir else None + if tauri_target is None: + tauri_target = next((p for p in repo_root.rglob("src-tauri") if (p / "target").exists()), None) + tauri_target = tauri_target / "target" if tauri_target else None + if tauri_target is not None: + app_exe = newest_file(tauri_target, "*.exe") + if app_exe is not None: + shutil.copy2(app_exe, output_dir / app_exe.name) + print(f"Synced desktop app ({app_exe.name}) -> {output_dir}") + + print("Sync complete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Journal.DevTool/scripts/verify-workflow-routes.py b/Journal.DevTool/scripts/verify-workflow-routes.py new file mode 100644 index 0000000..c03cbc5 --- /dev/null +++ b/Journal.DevTool/scripts/verify-workflow-routes.py @@ -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", "") + 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()) diff --git a/Journal.DevTool/sdt.deps.json b/Journal.DevTool/sdt.deps.json new file mode 100644 index 0000000..e07b2c6 --- /dev/null +++ b/Journal.DevTool/sdt.deps.json @@ -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": "" + } + } +} \ No newline at end of file diff --git a/Journal.DevTool/sdt.dll b/Journal.DevTool/sdt.dll new file mode 100644 index 0000000..faa199f Binary files /dev/null and b/Journal.DevTool/sdt.dll differ diff --git a/Journal.DevTool/sdt.exe b/Journal.DevTool/sdt.exe new file mode 100644 index 0000000..a8a4842 Binary files /dev/null and b/Journal.DevTool/sdt.exe differ diff --git a/Journal.DevTool/sdt.pdb b/Journal.DevTool/sdt.pdb new file mode 100644 index 0000000..dca518d Binary files /dev/null and b/Journal.DevTool/sdt.pdb differ diff --git a/Journal.DevTool/sdt.runtimeconfig.json b/Journal.DevTool/sdt.runtimeconfig.json new file mode 100644 index 0000000..f730443 --- /dev/null +++ b/Journal.DevTool/sdt.runtimeconfig.json @@ -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 + } + } +} \ No newline at end of file diff --git a/devtool.json b/devtool.json index e840047..a3aa0a8 100644 --- a/devtool.json +++ b/devtool.json @@ -676,7 +676,7 @@ ], "values": { "SDT_ENV_PROFILE": "ci", - "CI": "true", + "CI": "false", "SDT_LOG_LEVEL": "warning" } }, @@ -783,4 +783,4 @@ "bundleOnFailure": true } } -} +} \ No newline at end of file