Compare commits

...

3 Commits

Author SHA1 Message Date
06c0d30aaa SDT update 2026-03-04 16:42:04 -06:00
Jacob Schmidt
27cc379eb8 feat: add Vulkan GPU backend and fix GpuLayerCount config
- Downgrade LLamaSharp packages to 0.25.0 to match Vulkan backend availability
- Add LLamaSharp.Backend.Vulkan for AMD/Intel/NVIDIA GPU acceleration
- Fix _gpuLayers bug: was reading LlamaCppTimeout instead of a dedicated field
- Add GpuLayerCount to JournalConfig, sourced from JOURNAL_GPU_LAYERS env var
- Document AI/LLM notes in README (version pinning, known vulkaninfo issue)

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-03-02 22:44:01 -06:00
b4fa65c881 gitignore. 2026-03-02 22:44:01 -06:00
33 changed files with 1453 additions and 2192 deletions

4
.gitignore vendored
View File

@ -55,4 +55,6 @@ journalapp(1).exe
.cache/ .cache/
Journal.DevTool/scripts/__pycache__/ Journal.DevTool/scripts/__pycache__/
.sdt/ .sdt/
devtool.backup.json devtool.backup.json
Journal.App/node_modules.old/
scripts/__pycache__/

View File

@ -10,7 +10,8 @@
<PackageVersion Include="NAudio" Version="2.2.1" /> <PackageVersion Include="NAudio" Version="2.2.1" />
<PackageVersion Include="Whisper.net" Version="1.9.0" /> <PackageVersion Include="Whisper.net" Version="1.9.0" />
<PackageVersion Include="Whisper.net.Runtime" Version="1.9.0" /> <PackageVersion Include="Whisper.net.Runtime" Version="1.9.0" />
<PackageVersion Include="LLamaSharp" Version="0.26.0" /> <PackageVersion Include="LLamaSharp" Version="0.25.0" />
<PackageVersion Include="LLamaSharp.Backend.Cpu" Version="0.26.0" /> <PackageVersion Include="LLamaSharp.Backend.Cpu" Version="0.25.0" />
<PackageVersion Include="LLamaSharp.Backend.Vulkan" Version="0.25.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -3,6 +3,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="LLamaSharp" /> <PackageReference Include="LLamaSharp" />
<PackageReference Include="LLamaSharp.Backend.Cpu" /> <PackageReference Include="LLamaSharp.Backend.Cpu" />
<PackageReference Include="LLamaSharp.Backend.Vulkan" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -18,7 +18,7 @@ public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiServi
private readonly string _configuredModelPath = config.GgufModelPath; private readonly string _configuredModelPath = config.GgufModelPath;
private readonly uint _contextSize = (uint)Math.Clamp(config.ModelContextTokens, 512, 4096); private readonly uint _contextSize = (uint)Math.Clamp(config.ModelContextTokens, 512, 4096);
private readonly int _gpuLayers = config.LlamaCppTimeout; private readonly int _gpuLayers = config.GpuLayerCount;
private readonly Lock _sync = new(); private readonly Lock _sync = new();
private string? _resolvedModelPath; private string? _resolvedModelPath;

View File

@ -13,6 +13,7 @@ public sealed record JournalConfig(
string LlamaCppUrl, string LlamaCppUrl,
string LlamaCppModel, string LlamaCppModel,
int LlamaCppTimeout, int LlamaCppTimeout,
int GpuLayerCount,
string EmbeddingApiUrl, string EmbeddingApiUrl,
string EmbeddingModelName, string EmbeddingModelName,
int ModelContextTokens, int ModelContextTokens,

View File

@ -38,6 +38,7 @@ public sealed class JournalConfigService : IJournalConfigService
LlamaCppUrl: Environment.GetEnvironmentVariable("LLAMA_CPP_URL") ?? "http://127.0.0.1:8085/v1/completions", LlamaCppUrl: Environment.GetEnvironmentVariable("LLAMA_CPP_URL") ?? "http://127.0.0.1:8085/v1/completions",
LlamaCppModel: Environment.GetEnvironmentVariable("LLAMA_CPP_MODEL") ?? "qwen/qwen3-4b", LlamaCppModel: Environment.GetEnvironmentVariable("LLAMA_CPP_MODEL") ?? "qwen/qwen3-4b",
LlamaCppTimeout: ParseInt("LLAMA_CPP_TIMEOUT", 6000), LlamaCppTimeout: ParseInt("LLAMA_CPP_TIMEOUT", 6000),
GpuLayerCount: ParseInt("JOURNAL_GPU_LAYERS", -1),
EmbeddingApiUrl: Environment.GetEnvironmentVariable("EMBEDDING_API_URL") ?? "http://127.0.0.1:8086/v1/embeddings", EmbeddingApiUrl: Environment.GetEnvironmentVariable("EMBEDDING_API_URL") ?? "http://127.0.0.1:8086/v1/embeddings",
EmbeddingModelName: Environment.GetEnvironmentVariable("EMBEDDING_MODEL_NAME") ?? "text-embedding-nomic-embed-text-v2-moe", EmbeddingModelName: Environment.GetEnvironmentVariable("EMBEDDING_MODEL_NAME") ?? "text-embedding-nomic-embed-text-v2-moe",
ModelContextTokens: ParseInt("MODEL_CONTEXT_TOKENS", 131072), ModelContextTokens: ParseInt("MODEL_CONTEXT_TOKENS", 131072),

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,63 @@
# Scripts (Python-first, cross-platform)
This folder now uses Python as the default runtime for orchestration and diagnostics.
## Preferred scripts
- `diag.py`: tool probing and install-plan generation (`dotnet`, `python`, `node`, `npm`, `cargo`, `tauri`)
- `build.py`: normalized build actions used by SDT workflows
- `dev_shell.py`: cross-platform shell bootstrap export/doctor helper
- `dotnet-min.py`: resilient `dotnet` wrapper with local cache env
- `pip-min.py`: resilient `pip` wrapper with local cache env and repo-local target default
- `npm-clean.py`: remove `node_modules` cross-platform
- `migration-gate.py`: build/test quality gate
- `nuget-export-cache.py`: archive `.nuget` cache
- `nuget-import-cache.py`: restore `.nuget` cache from archive
- `publish-app.py`: build web or tauri app (cross-platform)
- `publish-sidecar.py`: publish sidecar .NET service
- `publish-webgateway.py`: publish gateway .NET service and optional web assets
- `run-webgateway.py`: run gateway in dev or published-output mode
- `publish-output.py`: orchestrate sidecar/web/gateway/desktop publish steps
- `sync-output.py`: sweep newest build artifacts into `output/`
- `script_common.py`: shared helpers (repo root resolution, env shaping, command runner)
- `project.rootHints` supports glob markers (for example `*.sln`) and directory/file markers (`.git`, `package.json`)
- Windows PATH token expansion (`%NVM_HOME%`, `%NVM_SYMLINK%`, etc.) is applied during command resolution
## Shell bootstrap wrappers
- `dev-shell.ps1`: PowerShell wrapper over `dev_shell.py`
- `dev-shell.sh`: bash/zsh wrapper over `dev_shell.py`
- `dev-shell.cmd`: cmd wrapper over `dev_shell.py`
## Legacy scripts
Existing `.ps1` entrypoints are now compatibility wrappers that forward to Python scripts.
`script-common.ps1` is legacy-only compatibility and not used by active SDT workflows.
Original PowerShell implementations are archived under `scripts/legacy/` as `*.legacy.ps1` for reference during transition.
## Root Hint Semantics
`project.rootHints` is evaluated in this order:
1. Exact marker exists at candidate root (file or directory)
2. Root-level glob match (`glob`)
3. Recursive glob match (`rglob`)
Examples:
- `"*.sln"`
- `".git"`
- `"package.json"`
- `"src-tauri/tauri.conf.json"`
## Quick usage
```powershell
python scripts/diag.py probe --tool dotnet --json
python scripts/dotnet-min.py build
python scripts/migration-gate.py
python scripts/nuget-export-cache.py --output-zip nuget-cache-export.zip
python scripts/nuget-import-cache.py --input-zip nuget-cache-export.zip
python scripts/npm-clean.py --working-dir .
python scripts/dev_shell.py export --shell pwsh --json
python scripts/dev_shell.py doctor
```

View File

@ -0,0 +1,67 @@
# Cross-Platform Script Workflows
## 1) Probe toolchain availability
```powershell
python scripts/diag.py probe --tool dotnet --json
python scripts/diag.py probe --tool python --json
python scripts/diag.py probe --tool node --json
python scripts/diag.py probe --tool npm --json
python scripts/diag.py probe --tool cargo --json
python scripts/diag.py probe --tool tauri --json
python scripts/diag.py probe --tool git --json
python scripts/diag.py probe --tool docker --json
```
## Shell bootstrap (cross-platform)
```powershell
python scripts/dev_shell.py export --shell pwsh --json
python scripts/dev_shell.py doctor
```
## 2) Build and run SDT
```powershell
python scripts/dotnet-min.py build
dotnet run --project DevTool.csproj
```
## 3) Run migration gate
```powershell
python scripts/migration-gate.py
```
## 3.1) Verify workflow route resolution (path + optional execution)
```powershell
# Static route checks only
python scripts/verify-workflow-routes.py --project-root .
# Static + headless execution checks for selected workflows
python scripts/verify-workflow-routes.py --project-root . --workflow build --workflow tauri --execute --env-profile dev
```
## 4) Manage NuGet cache
```powershell
python scripts/nuget-export-cache.py --output-zip nuget-cache-export.zip
python scripts/nuget-import-cache.py --input-zip nuget-cache-export.zip
```
## 5) Clean Node modules
```powershell
python scripts/npm-clean.py --working-dir .
```
## 6) Build app/gateway bundles
```powershell
python scripts/publish-app.py --target web
python scripts/publish-sidecar.py --project path/to/sidecar.csproj
python scripts/publish-webgateway.py --project path/to/gateway.csproj --skip-web-assets
python scripts/publish-output.py --dry-run
python scripts/sync-output.py
```

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
param(
[Parameter(ValueFromRemainingArguments = $($true))]
[string[]]$ForwardArgs
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot '_pwsh-python-shim.ps1')
Invoke-SdtPythonScript -ScriptName 'publish-sidecar.py' -ForwardArgs $ForwardArgs

View File

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

View File

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

View File

@ -0,0 +1,133 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Legacy compatibility helper only.
# Active SDT workflows and shell bootstrap now use Python scripts.
function Clear-SdtProxyEnv {
Remove-Item Env:HTTP_PROXY -ErrorAction SilentlyContinue
Remove-Item Env:HTTPS_PROXY -ErrorAction SilentlyContinue
Remove-Item Env:ALL_PROXY -ErrorAction SilentlyContinue
Remove-Item Env:http_proxy -ErrorAction SilentlyContinue
Remove-Item Env:https_proxy -ErrorAction SilentlyContinue
Remove-Item Env:all_proxy -ErrorAction SilentlyContinue
Remove-Item Env:GIT_HTTP_PROXY -ErrorAction SilentlyContinue
Remove-Item Env:GIT_HTTPS_PROXY -ErrorAction SilentlyContinue
Remove-Item Env:PIP_NO_INDEX -ErrorAction SilentlyContinue
}
function Test-SdtConfigExists {
param([string]$Path)
if (Test-Path (Join-Path $Path "devtool.json")) {
return $true
}
$sdtConfigs = Get-ChildItem -Path $Path -Filter "sdtconfig-*.json" -File -ErrorAction SilentlyContinue
return ($null -ne $sdtConfigs) -and ($sdtConfigs.Count -gt 0)
}
function Resolve-SdtRepoRoot {
param([string]$StartPath)
$candidateStarts = @()
if (-not [string]::IsNullOrWhiteSpace($StartPath)) {
$candidateStarts += $StartPath
}
$cwd = (Get-Location).Path
if (-not [string]::IsNullOrWhiteSpace($cwd) -and ($candidateStarts -notcontains $cwd)) {
$candidateStarts += $cwd
}
$override = $env:SDT_REPO_ROOT
if ([string]::IsNullOrWhiteSpace($override)) {
$override = $env:JOURNAL_REPO_ROOT # backward compatibility
}
if (-not [string]::IsNullOrWhiteSpace($override)) {
$overridePath = [System.IO.Path]::GetFullPath($override)
if (Test-SdtConfigExists -Path $overridePath) {
return $overridePath
}
}
foreach ($start in $candidateStarts) {
$cursor = [System.IO.Path]::GetFullPath($start)
while (-not [string]::IsNullOrWhiteSpace($cursor)) {
if (Test-SdtConfigExists -Path $cursor) {
return $cursor
}
$parent = [System.IO.Directory]::GetParent($cursor)
if ($null -eq $parent -or $parent.FullName -eq $cursor) {
break
}
$cursor = $parent.FullName
}
}
if (Get-Command git -ErrorAction SilentlyContinue) {
foreach ($start in $candidateStarts) {
try {
$gitRoot = & git -C $start rev-parse --show-toplevel 2>$null
if ($? -and -not [string]::IsNullOrWhiteSpace($gitRoot)) {
return [System.IO.Path]::GetFullPath($gitRoot.Trim())
}
}
catch {}
}
}
throw "Could not locate repository root. Ensure a project config (sdtconfig-*.json or devtool.json) exists in the project root."
}
function Initialize-SdtDotnetEnv {
param([Parameter(Mandatory = $true)][string]$RepoRoot)
$dotnetCliHome = Join-Path $RepoRoot ".dotnet_home"
$nugetPackages = Join-Path $RepoRoot ".nuget\packages"
$nugetHttpCachePath = Join-Path $RepoRoot ".nuget\http-cache"
$env:DOTNET_CLI_HOME = $dotnetCliHome
$env:NUGET_PACKAGES = $nugetPackages
$env:NUGET_HTTP_CACHE_PATH = $nugetHttpCachePath
$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"
New-Item -ItemType Directory -Force -Path $dotnetCliHome, $nugetPackages, $nugetHttpCachePath | Out-Null
}
function Initialize-SdtPipEnv {
param([Parameter(Mandatory = $true)][string]$RepoRoot)
$pipCacheDir = Join-Path $RepoRoot ".pip\cache"
$pipTempDir = Join-Path $RepoRoot ".tmp\pip-temp"
$env:PIP_CACHE_DIR = $pipCacheDir
$env:TEMP = $pipTempDir
$env:TMP = $pipTempDir
$env:PIP_DISABLE_PIP_VERSION_CHECK = "1"
$env:PIP_DEFAULT_TIMEOUT = "30"
$env:PIP_RETRIES = "2"
New-Item -ItemType Directory -Force -Path $pipCacheDir, $pipTempDir | Out-Null
}
function Initialize-SdtHuggingFaceEnv {
param([Parameter(Mandatory = $true)][string]$RepoRoot)
$hfHome = Join-Path $RepoRoot ".cache\huggingface"
$hfHubCache = Join-Path $hfHome "hub"
$env:HF_HOME = $hfHome
$env:HUGGINGFACE_HUB_CACHE = $hfHubCache
$env:HF_HUB_DISABLE_SYMLINKS_WARNING = "1"
New-Item -ItemType Directory -Force -Path $hfHubCache | Out-Null
}
# Backward-compatible aliases (legacy script calls)
Set-Alias -Name Clear-JournalProxyEnv -Value Clear-SdtProxyEnv -Scope Script
Set-Alias -Name Resolve-JournalRepoRoot -Value Resolve-SdtRepoRoot -Scope Script
Set-Alias -Name Initialize-JournalDotnetEnv -Value Initialize-SdtDotnetEnv -Scope Script
Set-Alias -Name Initialize-JournalPipEnv -Value Initialize-SdtPipEnv -Scope Script
Set-Alias -Name Initialize-JournalHuggingFaceEnv -Value Initialize-SdtHuggingFaceEnv -Scope Script

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -165,7 +165,7 @@ dotnet run --project Journal.SmokeTests
NuGet package versions are managed centrally in `Directory.Packages.props`. Project-level `.csproj` files reference packages without version numbers. NuGet package versions are managed centrally in `Directory.Packages.props`. Project-level `.csproj` files reference packages without version numbers.
- `Journal.Core``Microsoft.Data.Sqlite.Core`, `SQLitePCLRaw.bundle_e_sqlcipher`, `Microsoft.Extensions.DependencyInjection.Abstractions` - `Journal.Core``Microsoft.Data.Sqlite.Core`, `SQLitePCLRaw.bundle_e_sqlcipher`, `Microsoft.Extensions.DependencyInjection.Abstractions`
- `Journal.AI``LLamaSharp`, `LLamaSharp.Backend.Cpu` + references `Journal.Core` - `Journal.AI``LLamaSharp`, `LLamaSharp.Backend.Cpu`, `LLamaSharp.Backend.Vulkan` + references `Journal.Core`
- `Journal.Sidecar``Microsoft.Extensions.DependencyInjection`, `NAudio`, `Whisper.net` + references `Journal.Core`, `Journal.AI` - `Journal.Sidecar``Microsoft.Extensions.DependencyInjection`, `NAudio`, `Whisper.net` + references `Journal.Core`, `Journal.AI`
- `Journal.WebGateway``Microsoft.NET.Sdk.Web` + references `Journal.Core`, `Journal.AI` - `Journal.WebGateway``Microsoft.NET.Sdk.Web` + references `Journal.Core`, `Journal.AI`
- `Journal.SmokeTests` — references `Journal.Core` - `Journal.SmokeTests` — references `Journal.Core`
@ -187,6 +187,7 @@ NuGet package versions are managed centrally in `Directory.Packages.props`. Proj
| `JOURNAL_VAULT_DIR` | `<root>/journal/vault` | Override vault directory path | | `JOURNAL_VAULT_DIR` | `<root>/journal/vault` | Override vault directory path |
| `JOURNAL_DATA_DIR` | _(empty)_ | Override decrypted data directory path | | `JOURNAL_DATA_DIR` | _(empty)_ | Override decrypted data directory path |
| `JOURNAL_AI_PROVIDER` | `none` | AI provider mode (`none`, `llamasharp`) | | `JOURNAL_AI_PROVIDER` | `none` | AI provider mode (`none`, `llamasharp`) |
| `JOURNAL_GPU_LAYERS` | `-1` (all) | Number of model layers to offload to GPU (`-1` = all, `0` = CPU only) |
| `JOURNAL_LOG_LEVEL` | `warning` | Log verbosity (`trace`, `debug`, `information`, `warning`, `error`, `critical`) | | `JOURNAL_LOG_LEVEL` | `warning` | Log verbosity (`trace`, `debug`, `information`, `warning`, `error`, `critical`) |
| `JOURNAL_WEB_DIST` | auto | Override web UI dist path for WebGateway | | `JOURNAL_WEB_DIST` | auto | Override web UI dist path for WebGateway |
@ -199,6 +200,18 @@ NuGet package versions are managed centrally in `Directory.Packages.props`. Proj
--- ---
## AI / LLM Notes
The `Journal.AI` project uses **LLamaSharp** for local LLM inference.
- **CPU backend** (`LLamaSharp.Backend.Cpu`) is always installed as a fallback.
- **Vulkan backend** (`LLamaSharp.Backend.Vulkan`) provides GPU acceleration for AMD, Intel, and NVIDIA GPUs. LLamaSharp picks the best available backend at runtime.
- All backend packages must share the **same version**. Currently pinned to **0.25.0** because `LLamaSharp.Backend.Vulkan` has not yet published a 0.26.0 release. Watch the [NuGet page](https://www.nuget.org/packages/LLamaSharp.Backend.Vulkan) and upgrade all three packages together when a new version ships.
- **Known issue**: on some machines the Vulkan backend falls back to CPU because the internal `vulkaninfo --summary` detection times out at 1 second. If you see CPU-only inference despite having a Vulkan-capable GPU, this is likely the cause. The LLamaSharp team has acknowledged the issue ([#930](https://github.com/SciSharp/LLamaSharp/issues/930)).
- Set `JOURNAL_GPU_LAYERS=-1` (the default) to offload all model layers to the GPU, or `0` to force CPU-only.
---
## Journal.WebGateway ## Journal.WebGateway
An ASP.NET Core minimal API that wraps `Journal.Core` for browser use. An ASP.NET Core minimal API that wraps `Journal.Core` for browser use.

View File

@ -1,786 +0,0 @@
{
"name": "Project Journal",
"version": "0.1.0",
"targets": [],
"workflows": [
{
"id": "sidecar",
"label": "Publish Sidecar",
"description": "Build Journal.Sidecar as self-contained exe \u2192 output/",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "sidecar:run",
"label": "Publish Sidecar",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-sidecar.ps1",
"-Configuration",
"Release",
"-Runtime",
"win-x64"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "web",
"label": "Build Web UI",
"description": "Build SvelteKit bundle \u2192 Journal.App/build/",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "web:run",
"label": "Build Web UI",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-app.ps1",
"-Target",
"web"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "sync-output",
"label": "Sync Build Assets to Output",
"description": "Sweep repo for newest builds (Web/Sidecar/Gateway/Tauri) and copy them to the output dir",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "sync-output:run",
"label": "Sync Build Assets to Output",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/sync-output.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "webgateway",
"label": "Publish WebGateway",
"description": "Publish ASP.NET host with embedded web UI \u2192 output/webgateway/",
"group": "Build",
"dependsOn": [
"web"
],
"steps": [
{
"id": "webgateway:run",
"label": "Publish WebGateway",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-webgateway.ps1",
"-Configuration",
"Release",
"-Runtime",
"win-x64"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "tauri",
"label": "Build Tauri Desktop App",
"description": "Build desktop exe (no installer) \u2192 Journal.App/src-tauri/target/release/",
"group": "Build",
"dependsOn": [
"sidecar"
],
"steps": [
{
"id": "tauri:run",
"label": "Build Tauri Desktop App",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-app.ps1",
"-Target",
"tauri",
"-TauriBundles",
"none"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
},
{
"tool": "cargo",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "tauri-nsis",
"label": "Build Tauri \u002B NSIS Installer",
"description": "Build desktop exe with NSIS installer package",
"group": "Build",
"dependsOn": [
"sidecar"
],
"steps": [
{
"id": "tauri-nsis:run",
"label": "Build Tauri \u002B NSIS Installer",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-app.ps1",
"-Target",
"tauri",
"-TauriBundles",
"nsis"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
},
{
"tool": "cargo",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "build-dotnet",
"label": "Build .NET Projects",
"description": "dotnet build \u2014 all C# projects in solution",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "build-dotnet:run",
"label": "Build .NET Projects",
"command": "dotnet",
"args": [
"build"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "all",
"label": "Full Release Build \u2726",
"description": "Sidecar \u2192 Web \u2192 WebGateway \u2192 Tauri, in dependency order",
"group": "Build",
"dependsOn": [
"sidecar",
"web",
"webgateway",
"tauri"
],
"steps": []
},
{
"id": "run-gateway-dev",
"label": "Run WebGateway Server (Dev)",
"description": "Start HTTP gateway via \u0027dotnet run\u0027 at http://localhost:5180",
"group": "Dev",
"dependsOn": [],
"steps": [
{
"id": "run-gateway-dev:run",
"label": "Run WebGateway Server (Dev)",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/run-webgateway.ps1",
"-Mode",
"Dev"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "run-gateway-prod",
"label": "Run WebGateway Server (Output)",
"description": "Start compiled gateway from output/webgateway at http://localhost:5180",
"group": "Dev",
"dependsOn": [],
"steps": [
{
"id": "run-gateway-prod:run",
"label": "Run WebGateway Server (Output)",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/run-webgateway.ps1",
"-Mode",
"Output"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "test",
"label": "Run Smoke Tests",
"description": "Run all ~80 integration tests in Journal.SmokeTests",
"group": "Test",
"dependsOn": [],
"steps": [
{
"id": "test:run",
"label": "Run Smoke Tests",
"command": "dotnet",
"args": [
"run",
"--project",
"Journal.SmokeTests/Journal.SmokeTests.csproj"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "gate",
"label": "Run Migration Gate",
"description": "Full build \u002B smoke tests \u002B parity check",
"group": "Test",
"dependsOn": [],
"steps": [
{
"id": "gate:run",
"label": "Run Migration Gate",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/migration-gate.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "nuget-export",
"label": "Export NuGet Cache",
"description": "Prime and export .nuget cache to zip for offline use",
"group": "Cache",
"dependsOn": [],
"steps": [
{
"id": "nuget-export:run",
"label": "Export NuGet Cache",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/nuget-export-cache.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "nuget-import",
"label": "Import NuGet Cache",
"description": "Import cache zip and validate restore",
"group": "Cache",
"dependsOn": [],
"steps": [
{
"id": "nuget-import:run",
"label": "Import NuGet Cache",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/nuget-import-cache.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "npm-clean",
"label": "Clean Node Modules",
"description": "Remove Journal.App node_modules (kills node/tauri first)",
"group": "System",
"dependsOn": [],
"steps": [
{
"id": "npm-clean:run",
"label": "Clean Node Modules",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/npm-clean.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "stage-output",
"label": "Stage Output Bundle",
"description": "Publish sidecar \u002B web \u002B webgateway \u002B tauri, then stage journalapp.exe into output/",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "stage-output:run",
"label": "Stage Output Bundle",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-output.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
},
{
"tool": "cargo",
"installPolicy": "Prompt"
}
]
}
]
}
],
"env": [
{
"key": "JOURNAL_AI_PROVIDER",
"description": "AI provider bridge mode",
"default": "none",
"options": [
"none",
"python-sidecar"
]
},
{
"key": "JOURNAL_LOG_LEVEL",
"description": "Log verbosity for C# backend",
"default": "warning",
"options": [
"trace",
"debug",
"information",
"warning",
"error",
"critical"
]
},
{
"key": "JOURNAL_NLP_BACKEND",
"description": "Python NLP backend selection",
"default": "auto",
"options": [
"auto",
"spacy",
"fallback"
]
},
{
"key": "JOURNAL_PROJECT_ROOT",
"description": "Override project root path (blank = auto-detect)",
"default": "",
"options": []
},
{
"key": "JOURNAL_VAULT_DIR",
"description": "Override vault directory path",
"default": "",
"options": []
},
{
"key": "JOURNAL_DATA_DIR",
"description": "Override decrypted data directory path",
"default": "",
"options": []
},
{
"key": "SDT_ENV_PROFILE",
"description": "Active SDT runtime environment profile",
"default": "dev",
"options": [
"dev",
"ci",
"release"
]
},
{
"key": "SDT_LOG_LEVEL",
"description": "CLI log verbosity",
"default": "information",
"options": [
"trace",
"debug",
"information",
"warning",
"error",
"critical"
]
}
],
"envProfiles": {
"active": "dev",
"profiles": [
{
"id": "dev",
"description": "Local development defaults",
"inherits": [],
"values": {
"SDT_ENV_PROFILE": "dev",
"SDT_LOG_LEVEL": "information"
}
},
{
"id": "ci",
"description": "Continuous integration defaults",
"inherits": [
"dev"
],
"values": {
"SDT_ENV_PROFILE": "ci",
"CI": "false",
"SDT_LOG_LEVEL": "warning"
}
},
{
"id": "release",
"description": "Release build defaults",
"inherits": [
"dev"
],
"values": {
"SDT_ENV_PROFILE": "release",
"SDT_LOG_LEVEL": "warning"
}
}
]
},
"toolchains": {
"python": {
"executable": "python3.14",
"windowsExecutable": "py",
"launcherVersion": "-3.14",
"venvDir": ".venv",
"profiles": [
{
"id": "cpu",
"label": "CPU only (default)",
"requirementsFile": "requirements_cpu_only.txt",
"extraIndexUrl": "https://download.pytorch.org/whl/cpu",
"postInstallCommands": []
},
{
"id": "gpu",
"label": "GPU / CUDA",
"requirementsFile": "requirements_gpu.txt",
"extraIndexUrl": null,
"postInstallCommands": []
},
{
"id": "nlp",
"label": "NLP / spaCy (optional)",
"requirementsFile": "requirements_nlp_optional.txt",
"extraIndexUrl": null,
"postInstallCommands": [
"spacy download en_core_web_sm"
]
}
],
"pipScript": "scripts/pip-min.ps1"
},
"node": {
"packageManager": "npm",
"workingDir": "Journal.App"
}
},
"tooling": {
"tools": [
{
"tool": "cargo",
"preferredInstallCommands": [],
"executables": []
},
{
"tool": "dotnet",
"preferredInstallCommands": [],
"executables": []
},
{
"tool": "node",
"preferredInstallCommands": [],
"executables": []
},
{
"tool": "npm",
"preferredInstallCommands": [],
"executables": []
},
{
"tool": "python",
"preferredInstallCommands": [],
"executables": []
}
]
},
"project": null,
"debug": {
"profiles": [],
"diagnostics": {
"enabled": true,
"outputDir": ".sdt/debug",
"includeAllEnv": false,
"captureEnvKeys": [],
"redactSensitive": true,
"sensitiveKeyPatterns": [
"TOKEN",
"SECRET",
"PASSWORD",
"PWD",
"CREDENTIAL",
"API_KEY",
"ACCESS_KEY",
"PRIVATE_KEY"
],
"redactionAllowKeys": [],
"bundleOnFailure": true
}
}
}

View File

@ -1,677 +0,0 @@
{
"name": "Project Journal",
"version": "0.1.0",
"targets": [],
"workflows": [
{
"id": "sidecar",
"label": "Publish Sidecar",
"description": "Build Journal.Sidecar as self-contained exe \u2192 output/",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "sidecar:run",
"label": "Publish Sidecar",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-sidecar.ps1",
"-Configuration",
"Release",
"-Runtime",
"win-x64"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "web",
"label": "Build Web UI",
"description": "Build SvelteKit bundle \u2192 Journal.App/build/",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "web:run",
"label": "Build Web UI",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-app.ps1",
"-Target",
"web"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "sync-output",
"label": "Sync Build Assets to Output",
"description": "Sweep repo for newest builds (Web/Sidecar/Gateway/Tauri) and copy them to the output dir",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "sync-output:run",
"label": "Sync Build Assets to Output",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/sync-output.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "webgateway",
"label": "Publish WebGateway",
"description": "Publish ASP.NET host with embedded web UI \u2192 output/webgateway/",
"group": "Build",
"dependsOn": [
"web"
],
"steps": [
{
"id": "webgateway:run",
"label": "Publish WebGateway",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-webgateway.ps1",
"-Configuration",
"Release",
"-Runtime",
"win-x64"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "tauri",
"label": "Build Tauri Desktop App",
"description": "Build desktop exe (no installer) \u2192 Journal.App/src-tauri/target/release/",
"group": "Build",
"dependsOn": [
"sidecar"
],
"steps": [
{
"id": "tauri:run",
"label": "Build Tauri Desktop App",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-app.ps1",
"-Target",
"tauri",
"-TauriBundles",
"none"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
},
{
"tool": "cargo",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "tauri-nsis",
"label": "Build Tauri \u002B NSIS Installer",
"description": "Build desktop exe with NSIS installer package",
"group": "Build",
"dependsOn": [
"sidecar"
],
"steps": [
{
"id": "tauri-nsis:run",
"label": "Build Tauri \u002B NSIS Installer",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-app.ps1",
"-Target",
"tauri",
"-TauriBundles",
"nsis"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
},
{
"tool": "cargo",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "build-dotnet",
"label": "Build .NET Projects",
"description": "dotnet build \u2014 all C# projects in solution",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "build-dotnet:run",
"label": "Build .NET Projects",
"command": "dotnet",
"args": [
"build"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "all",
"label": "Full Release Build \u2726",
"description": "Sidecar \u2192 Web \u2192 WebGateway \u2192 Tauri, in dependency order",
"group": "Build",
"dependsOn": [
"sidecar",
"web",
"webgateway",
"tauri"
],
"steps": []
},
{
"id": "run-gateway-dev",
"label": "Run WebGateway Server (Dev)",
"description": "Start HTTP gateway via \u0027dotnet run\u0027 at http://localhost:5180",
"group": "Dev",
"dependsOn": [],
"steps": [
{
"id": "run-gateway-dev:run",
"label": "Run WebGateway Server (Dev)",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/run-webgateway.ps1",
"-Mode",
"Dev"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "run-gateway-prod",
"label": "Run WebGateway Server (Output)",
"description": "Start compiled gateway from output/webgateway at http://localhost:5180",
"group": "Dev",
"dependsOn": [],
"steps": [
{
"id": "run-gateway-prod:run",
"label": "Run WebGateway Server (Output)",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/run-webgateway.ps1",
"-Mode",
"Output"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "test",
"label": "Run Smoke Tests",
"description": "Run all ~80 integration tests in Journal.SmokeTests",
"group": "Test",
"dependsOn": [],
"steps": [
{
"id": "test:run",
"label": "Run Smoke Tests",
"command": "dotnet",
"args": [
"run",
"--project",
"Journal.SmokeTests/Journal.SmokeTests.csproj"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "gate",
"label": "Run Migration Gate",
"description": "Full build \u002B smoke tests \u002B parity check",
"group": "Test",
"dependsOn": [],
"steps": [
{
"id": "gate:run",
"label": "Run Migration Gate",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/migration-gate.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "nuget-export",
"label": "Export NuGet Cache",
"description": "Prime and export .nuget cache to zip for offline use",
"group": "Cache",
"dependsOn": [],
"steps": [
{
"id": "nuget-export:run",
"label": "Export NuGet Cache",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/nuget-export-cache.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "nuget-import",
"label": "Import NuGet Cache",
"description": "Import cache zip and validate restore",
"group": "Cache",
"dependsOn": [],
"steps": [
{
"id": "nuget-import:run",
"label": "Import NuGet Cache",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/nuget-import-cache.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "npm-clean",
"label": "Clean Node Modules",
"description": "Remove Journal.App node_modules (kills node/tauri first)",
"group": "System",
"dependsOn": [],
"steps": [
{
"id": "npm-clean:run",
"label": "Clean Node Modules",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/npm-clean.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "stage-output",
"label": "Stage Output Bundle",
"description": "Publish sidecar \u002B web \u002B webgateway \u002B tauri, then stage journalapp.exe into output/",
"group": "Build",
"dependsOn": [],
"steps": [
{
"id": "stage-output:run",
"label": "Stage Output Bundle",
"command": "pwsh",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"scripts/publish-output.ps1"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
},
{
"tool": "dotnet",
"installPolicy": "Prompt"
},
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
},
{
"tool": "cargo",
"installPolicy": "Prompt"
}
]
}
]
}
],
"env": [
{
"key": "JOURNAL_AI_PROVIDER",
"description": "AI provider bridge mode",
"default": "none",
"options": [
"none",
"python-sidecar"
]
},
{
"key": "JOURNAL_LOG_LEVEL",
"description": "Log verbosity for C# backend",
"default": "warning",
"options": [
"trace",
"debug",
"information",
"warning",
"error",
"critical"
]
},
{
"key": "JOURNAL_NLP_BACKEND",
"description": "Python NLP backend selection",
"default": "auto",
"options": [
"auto",
"spacy",
"fallback"
]
},
{
"key": "JOURNAL_PROJECT_ROOT",
"description": "Override project root path (blank = auto-detect)",
"default": "",
"options": []
},
{
"key": "JOURNAL_VAULT_DIR",
"description": "Override vault directory path",
"default": "",
"options": []
},
{
"key": "JOURNAL_DATA_DIR",
"description": "Override decrypted data directory path",
"default": "",
"options": []
}
],
"toolchains": {
"python": {
"executable": "python3.14",
"windowsExecutable": "py",
"launcherVersion": "-3.14",
"venvDir": ".venv",
"profiles": [
{
"id": "cpu",
"label": "CPU only (default)",
"requirementsFile": "requirements_cpu_only.txt",
"extraIndexUrl": "https://download.pytorch.org/whl/cpu",
"postInstallCommands": []
},
{
"id": "gpu",
"label": "GPU / CUDA",
"requirementsFile": "requirements_gpu.txt",
"extraIndexUrl": null,
"postInstallCommands": []
},
{
"id": "nlp",
"label": "NLP / spaCy (optional)",
"requirementsFile": "requirements_nlp_optional.txt",
"extraIndexUrl": null,
"postInstallCommands": [
"spacy download en_core_web_sm"
]
}
],
"pipScript": "scripts/pip-min.ps1"
},
"node": {
"packageManager": "npm",
"workingDir": "Journal.App"
}
},
"tooling": null,
"project": null,
"debug": null
}

558
sdtconfig-journal.json Normal file
View File

@ -0,0 +1,558 @@
{
"name": "journal",
"version": "0.1.0",
"targets": [],
"workflows": [
{
"id": "sidecar",
"label": "Publish Sidecar",
"description": "Publish sidecar service",
"group": "Build",
"dependsOn": [],
"requireFiles": [
"Journal.DevTool\\scripts\\publish-sidecar.py"
],
"steps": [
{
"id": "sidecar:run",
"label": "python Journal.DevTool\\scripts\\publish-sidecar.py",
"command": "python",
"args": [
"Journal.DevTool\\scripts\\publish-sidecar.py"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "web",
"label": "Build Web UI",
"description": "Build frontend assets",
"group": "Build",
"dependsOn": [],
"requireFiles": [
"Journal.DevTool\\scripts\\publish-app.py"
],
"steps": [
{
"id": "web:run",
"label": "python Journal.DevTool\\scripts\\publish-app.py --target web",
"command": "python",
"args": [
"Journal.DevTool\\scripts\\publish-app.py",
"--target",
"web"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "tauri",
"label": "Build Tauri Desktop App",
"description": "Build desktop binary",
"group": "Build",
"dependsOn": [
"sidecar"
],
"requireFiles": [
"Journal.DevTool\\scripts\\publish-app.py",
"Journal.App/src-tauri/tauri.conf.json"
],
"steps": [
{
"id": "tauri:run",
"label": "python Journal.DevTool\\scripts\\publish-app.py --target tauri --tauri-bundles none",
"command": "python",
"args": [
"Journal.DevTool\\scripts\\publish-app.py",
"--target",
"tauri",
"--tauri-bundles",
"none"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "webgateway",
"label": "Publish WebGateway",
"description": "Publish ASP.NET gateway",
"group": "Build",
"dependsOn": [
"web"
],
"requireFiles": [
"Journal.DevTool\\scripts\\publish-webgateway.py"
],
"steps": [
{
"id": "webgateway:run",
"label": "python Journal.DevTool\\scripts\\publish-webgateway.py",
"command": "python",
"args": [
"Journal.DevTool\\scripts\\publish-webgateway.py"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "sync-output",
"label": "Sync Output",
"description": "Sync newest artifacts to output",
"group": "Build",
"dependsOn": [],
"requireFiles": [
"Journal.DevTool\\scripts\\sync-output.py"
],
"steps": [
{
"id": "sync-output:run",
"label": "python Journal.DevTool\\scripts\\sync-output.py",
"command": "python",
"args": [
"Journal.DevTool\\scripts\\sync-output.py"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "stage-output",
"label": "Stage Output Bundle",
"description": "Publish and stage distributable output",
"group": "Build",
"dependsOn": [],
"requireFiles": [
"Journal.DevTool\\scripts\\publish-output.py"
],
"steps": [
{
"id": "stage-output:run",
"label": "python Journal.DevTool\\scripts\\publish-output.py",
"command": "python",
"args": [
"Journal.DevTool\\scripts\\publish-output.py"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "run-gateway-dev",
"label": "Run WebGateway Server (Dev)",
"description": "Run gateway in development mode",
"group": "Dev",
"dependsOn": [],
"requireFiles": [
"Journal.DevTool\\scripts\\run-webgateway.py"
],
"steps": [
{
"id": "run-gateway-dev:run",
"label": "python Journal.DevTool\\scripts\\run-webgateway.py --mode Dev",
"command": "python",
"args": [
"Journal.DevTool\\scripts\\run-webgateway.py",
"--mode",
"Dev"
],
"workingDir": ".",
"action": null,
"actionArgs": [],
"requires": [
{
"tool": "python",
"installPolicy": "Prompt"
}
]
}
]
},
{
"id": "build",
"label": "Build",
"description": "Build detected project stacks",
"group": "Build",
"dependsOn": [],
"requireFiles": [],
"steps": [
{
"id": "dotnet-build",
"label": "dotnet build",
"command": null,
"args": [],
"workingDir": ".",
"action": "dotnet-build",
"actionArgs": [],
"requires": []
},
{
"id": "npm-build",
"label": "npm run build",
"command": null,
"args": [],
"workingDir": "Journal.App",
"action": "npm-build",
"actionArgs": [],
"requires": []
},
{
"id": "cargo-build",
"label": "cargo build",
"command": null,
"args": [],
"workingDir": ".",
"action": "cargo-build",
"actionArgs": [],
"requires": []
},
{
"id": "tauri-build",
"label": "tauri build",
"command": null,
"args": [],
"workingDir": "Journal.App",
"action": "tauri-build",
"actionArgs": [],
"requires": []
}
]
},
{
"id": "deps-refresh",
"label": "Refresh Dependencies",
"description": "Restore/install dependency stacks",
"group": "Deps",
"dependsOn": [],
"requireFiles": [],
"steps": [
{
"id": "dotnet-restore",
"label": "dotnet restore",
"command": null,
"args": [],
"workingDir": ".",
"action": "dotnet-restore",
"actionArgs": [],
"requires": []
},
{
"id": "npm-ci",
"label": "npm ci",
"command": null,
"args": [],
"workingDir": "Journal.App",
"action": "npm-ci",
"actionArgs": [],
"requires": []
}
]
},
{
"id": "test",
"label": "Run Tests",
"description": "Run detected test stacks",
"group": "Test",
"dependsOn": [],
"requireFiles": [],
"steps": [
{
"id": "dotnet-test",
"label": "dotnet test",
"command": null,
"args": [],
"workingDir": ".",
"action": "dotnet-test",
"actionArgs": [],
"requires": []
},
{
"id": "npm-test",
"label": "npm test",
"command": null,
"args": [],
"workingDir": "Journal.App",
"action": "npm-test",
"actionArgs": [],
"requires": []
},
{
"id": "cargo-test",
"label": "cargo test",
"command": null,
"args": [],
"workingDir": ".",
"action": "cargo-test",
"actionArgs": [],
"requires": []
}
]
},
{
"id": "repo-health",
"label": "Repo Health",
"description": "Check repo status and fetch remotes",
"group": "Repo",
"dependsOn": [],
"requireFiles": [],
"steps": [
{
"id": "git-status",
"label": "git status",
"command": null,
"args": [],
"workingDir": ".",
"action": "git-status",
"actionArgs": [],
"requires": []
},
{
"id": "git-fetch",
"label": "git fetch",
"command": null,
"args": [],
"workingDir": ".",
"action": "git-fetch",
"actionArgs": [],
"requires": []
}
]
}
],
"env": [
{
"key": "SDT_LOG_LEVEL",
"description": "CLI log verbosity",
"default": "information",
"options": [
"trace",
"debug",
"information",
"warning",
"error",
"critical"
]
}
],
"envProfiles": {
"active": "dev",
"profiles": [
{
"id": "dev",
"description": "Local development defaults",
"inherits": [],
"values": {
"SDT_ENV_PROFILE": "dev",
"SDT_LOG_LEVEL": "information"
}
},
{
"id": "ci",
"description": "Continuous integration defaults",
"inherits": [
"dev"
],
"values": {
"SDT_ENV_PROFILE": "ci",
"CI": "true",
"SDT_LOG_LEVEL": "warning"
}
},
{
"id": "release",
"description": "Release build defaults",
"inherits": [
"dev"
],
"values": {
"SDT_ENV_PROFILE": "release",
"SDT_LOG_LEVEL": "warning"
}
}
]
},
"toolchains": {
"python": null,
"node": {
"packageManager": "npm",
"workingDir": "Journal.App"
}
},
"tooling": {
"defaultInstallPolicy": "Prompt",
"tools": [
{
"tool": "cargo",
"preferredInstallCommands": [],
"executables": []
},
{
"tool": "dotnet",
"preferredInstallCommands": [],
"executables": []
},
{
"tool": "git",
"preferredInstallCommands": [],
"executables": []
},
{
"tool": "node",
"preferredInstallCommands": [],
"executables": []
},
{
"tool": "npm",
"preferredInstallCommands": [],
"executables": []
},
{
"tool": "tauri",
"preferredInstallCommands": [],
"executables": []
}
]
},
"project": {
"type": "tauri",
"rootHints": [
"*.sln",
".git",
"Cargo.toml",
"package.json",
"tauri.conf.json"
],
"artifacts": [
"bin",
"obj",
".sdt/debug"
]
},
"debug": {
"profiles": [
{
"id": "dotnet-run",
"label": "Run .NET app",
"type": "dotnet",
"command": "dotnet",
"args": [
"run"
],
"workingDir": ".",
"env": {},
"requires": [
{
"tool": "dotnet",
"installPolicy": "Prompt"
}
],
"attach": {
"kind": "manual",
"port": null,
"processName": null,
"note": "Attach your IDE debugger to the running dotnet process."
}
},
{
"id": "npm-dev",
"label": "Run npm dev server",
"type": "node",
"command": "npm",
"args": [
"run",
"dev"
],
"workingDir": "Journal.App",
"env": {},
"requires": [
{
"tool": "node",
"installPolicy": "Prompt"
},
{
"tool": "npm",
"installPolicy": "Prompt"
}
],
"attach": null
}
],
"diagnostics": {
"enabled": true,
"outputDir": ".sdt/debug",
"includeAllEnv": false,
"captureEnvKeys": [
"SDT_LOG_LEVEL",
"DOTNET_CLI_HOME",
"NUGET_PACKAGES",
"PIP_CACHE_DIR",
"NVM_HOME",
"NVM_SYMLINK"
],
"redactSensitive": true,
"sensitiveKeyPatterns": [
"TOKEN",
"SECRET",
"PASSWORD",
"PWD",
"CREDENTIAL",
"API_KEY",
"ACCESS_KEY",
"PRIVATE_KEY"
],
"redactionAllowKeys": [],
"bundleOnFailure": true
}
}
}