diff --git a/.gitignore b/.gitignore index 39886a3..3f68c96 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ project.fragment.lock.json .nuget/ .dotnet_home/ .journal-sidecar/ +.tmp +.npm +output/ # Publish output publish/ diff --git a/Journal.App/src/lib/backend/auth.ts b/Journal.App/src/lib/backend/auth.ts index bf386d3..b3f8697 100644 --- a/Journal.App/src/lib/backend/auth.ts +++ b/Journal.App/src/lib/backend/auth.ts @@ -20,10 +20,17 @@ type RuntimeConfig = { vaultDirectory: string; }; -async function getRuntimeConfig(): Promise { - const data = await sendCommand({ - action: "config.get" - }); +type PersistOptions = { + keepalive?: boolean; +}; + +async function getRuntimeConfig(options: PersistOptions = {}): Promise { + const data = await sendCommand( + { + action: "config.get" + }, + options + ); return { dataDirectory: pickCase(data, "dataDirectory", "DataDirectory", ""), @@ -55,22 +62,28 @@ export async function unlockVaultWorkspace(password: string): Promise { }); } -export async function persistAndClearVault(password: string): Promise { - const config = await getRuntimeConfig(); +export async function persistAndClearVault(password: string, options: PersistOptions = {}): Promise { + const config = await getRuntimeConfig(options); - await sendCommand({ - action: "vault.rebuild_all", - payload: { - password, - vaultDirectory: config.vaultDirectory, - dataDirectory: config.dataDirectory - } - }); + await sendCommand( + { + action: "vault.rebuild_all", + payload: { + password, + vaultDirectory: config.vaultDirectory, + dataDirectory: config.dataDirectory + } + }, + options + ); - await sendCommand({ - action: "vault.clear_data_directory", - payload: { - dataDirectory: config.dataDirectory - } - }); + await sendCommand( + { + action: "vault.clear_data_directory", + payload: { + dataDirectory: config.dataDirectory + } + }, + options + ); } diff --git a/Journal.App/src/lib/backend/client.ts b/Journal.App/src/lib/backend/client.ts index 251886b..1ab887b 100644 --- a/Journal.App/src/lib/backend/client.ts +++ b/Journal.App/src/lib/backend/client.ts @@ -1,16 +1,23 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "$lib/runtime/invoke"; import type { BackendCommand, BackendResponse } from "./types"; function newCorrelationId(): string { return `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`; } -export async function sendCommand(command: BackendCommand): Promise { +type SendCommandOptions = { + keepalive?: boolean; +}; + +export async function sendCommand(command: BackendCommand, options: SendCommandOptions = {}): Promise { const envelope: BackendCommand = { ...command, correlationId: command.correlationId ?? newCorrelationId() }; - const response = await invoke>("sidecar_command", { command: envelope }); + const response = await invoke>("sidecar_command", { + command: envelope, + keepalive: options.keepalive === true + }); if (!response.ok) { throw new Error(response.error || "Backend command failed"); diff --git a/Journal.App/src/lib/runtime/invoke.ts b/Journal.App/src/lib/runtime/invoke.ts new file mode 100644 index 0000000..b665d7a --- /dev/null +++ b/Journal.App/src/lib/runtime/invoke.ts @@ -0,0 +1,143 @@ +import type { BackendCommand } from "$lib/backend/types"; + +type InvokeArgs = Record | undefined; + +type WindowWithTauri = Window & { + __TAURI_INTERNALS__?: unknown; +}; + +type UiSettingsPayload = { + tags?: string[]; + fragmentTypes?: string[]; +}; + +type FetchJsonOptions = { + keepalive?: boolean; +}; + +const UI_SETTINGS_KEY = "journal.ui.settings"; + +function normalizedApiBase(): string { + const configured = import.meta.env.VITE_JOURNAL_API_BASE?.trim(); + if (!configured) { + return "/api"; + } + + return configured.endsWith("/") ? configured.slice(0, -1) : configured; +} + +export function isTauriRuntime(): boolean { + if (typeof window === "undefined") { + return false; + } + + return Object.prototype.hasOwnProperty.call(window as WindowWithTauri, "__TAURI_INTERNALS__"); +} + +function readUiSettingsFromLocalStorage(): UiSettingsPayload { + if (typeof window === "undefined") { + return {}; + } + + const raw = window.localStorage.getItem(UI_SETTINGS_KEY); + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(raw) as UiSettingsPayload; + return { + tags: Array.isArray(parsed.tags) ? parsed.tags : undefined, + fragmentTypes: Array.isArray(parsed.fragmentTypes) ? parsed.fragmentTypes : undefined + }; + } catch { + return {}; + } +} + +function writeUiSettingsToLocalStorage(payload: UiSettingsPayload): void { + if (typeof window === "undefined") { + return; + } + + const safePayload: UiSettingsPayload = { + tags: Array.isArray(payload.tags) ? payload.tags : undefined, + fragmentTypes: Array.isArray(payload.fragmentTypes) ? payload.fragmentTypes : undefined + }; + + window.localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(safePayload)); +} + +async function fetchJson(path: string, init: RequestInit = {}, options: FetchJsonOptions = {}): Promise { + const response = await fetch(`${normalizedApiBase()}${path}`, { + ...init, + keepalive: options.keepalive === true, + headers: { + "Content-Type": "application/json", + ...(init.headers ?? {}) + } + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `Request failed (${response.status})`); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; +} + +export async function invoke(command: string, args?: InvokeArgs): Promise { + if (isTauriRuntime()) { + const tauriCore = await import("@tauri-apps/api/core"); + return tauriCore.invoke(command, args); + } + + switch (command) { + case "sidecar_command": { + const envelope = args?.command; + if (!envelope || typeof envelope !== "object") { + throw new Error("Missing command payload."); + } + + const keepalive = args?.keepalive === true; + + return fetchJson( + "/command", + { + method: "POST", + body: JSON.stringify(envelope as BackendCommand) + }, + { keepalive } + ); + } + case "get_sidecar_root": + return fetchJson("/sidecar/root"); + case "set_sidecar_root": { + const path = typeof args?.path === "string" ? args.path : ""; + return fetchJson("/sidecar/root", { + method: "POST", + body: JSON.stringify({ path }) + }); + } + case "get_ui_settings": + return readUiSettingsFromLocalStorage() as T; + case "set_ui_settings": { + const tags = Array.isArray(args?.tags) ? (args?.tags as string[]) : undefined; + const fragmentTypes = + Array.isArray(args?.fragmentTypes) ? (args?.fragmentTypes as string[]) : + Array.isArray(args?.fragment_types) ? (args?.fragment_types as string[]) : + undefined; + + writeUiSettingsToLocalStorage({ tags, fragmentTypes }); + return undefined as T; + } + case "shutdown": + return undefined as T; + default: + throw new Error(`Unsupported command in web runtime: ${command}`); + } +} diff --git a/Journal.App/src/lib/stores/settings.ts b/Journal.App/src/lib/stores/settings.ts index 80e4e80..81bb23f 100644 --- a/Journal.App/src/lib/stores/settings.ts +++ b/Journal.App/src/lib/stores/settings.ts @@ -1,6 +1,6 @@ import { writable } from "svelte/store"; import { get } from "svelte/store"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "$lib/runtime/invoke"; const defaultTags = ["Personal", "Work", "Ideas", "Journal"]; const defaultFragmentTypes = ["Quote", "Snippet", "Reference"]; @@ -131,3 +131,4 @@ export function removeFragmentType(index: number): boolean { queuePersist(); return true; } + diff --git a/Journal.App/src/routes/+layout.svelte b/Journal.App/src/routes/+layout.svelte index cd0bc0a..8e2ee39 100644 --- a/Journal.App/src/routes/+layout.svelte +++ b/Journal.App/src/routes/+layout.svelte @@ -1,39 +1,70 @@ diff --git a/Journal.App/src/routes/settings/+page.svelte b/Journal.App/src/routes/settings/+page.svelte index 8c387b1..b52f378 100644 --- a/Journal.App/src/routes/settings/+page.svelte +++ b/Journal.App/src/routes/settings/+page.svelte @@ -12,7 +12,7 @@ updateFragmentType, updateSettingsTag } from "$lib/stores/settings"; - import { invoke } from "@tauri-apps/api/core"; + import { invoke } from "$lib/runtime/invoke"; import { onMount } from "svelte"; const activeSection = "settings"; @@ -483,3 +483,4 @@ font-size: 0.82rem; } + diff --git a/Journal.Core/Services/Config/JournalConfigService.cs b/Journal.Core/Services/Config/JournalConfigService.cs index 826ec43..f33a9c1 100644 --- a/Journal.Core/Services/Config/JournalConfigService.cs +++ b/Journal.Core/Services/Config/JournalConfigService.cs @@ -4,7 +4,7 @@ namespace Journal.Core.Services.Config; public sealed class JournalConfigService : IJournalConfigService { - public JournalConfig Current { get; } = BuildConfig(); + public JournalConfig Current => BuildConfig(); private static JournalConfig BuildConfig() { @@ -105,3 +105,4 @@ public sealed class JournalConfigService : IJournalConfigService return int.TryParse(value, out var parsed) ? parsed : null; } } + diff --git a/Journal.WebGateway/Journal.WebGateway.csproj b/Journal.WebGateway/Journal.WebGateway.csproj new file mode 100644 index 0000000..511c2f0 --- /dev/null +++ b/Journal.WebGateway/Journal.WebGateway.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/Journal.WebGateway/Program.cs b/Journal.WebGateway/Program.cs new file mode 100644 index 0000000..743c813 --- /dev/null +++ b/Journal.WebGateway/Program.cs @@ -0,0 +1,276 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Journal.Core; +using Microsoft.Extensions.FileProviders; + +var gatewayJsonOptions = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +}; + +var repoRoot = ResolveRepoRoot(); +Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", repoRoot); +var webDistPath = ResolveWebDist(repoRoot); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddFragmentServices(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new SidecarRootState(repoRoot)); +builder.Services.AddSingleton(new WebUiState(webDistPath)); + +builder.Services.AddCors(options => +{ + options.AddPolicy("GatewayCors", policy => + { + policy.AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.SerializerOptions.PropertyNameCaseInsensitive = true; +}); + +var app = builder.Build(); + +app.UseCors("GatewayCors"); + +app.MapGet("/api/health", () => Results.Ok(new +{ + ok = true, + service = "Journal.WebGateway" +})); + +app.MapGet("/api/web/status", (WebUiState webUiState) => Results.Ok(new +{ + distPath = webUiState.DistPath, + exists = webUiState.Exists +})); + +app.MapPost("/api/command", async (CommandEnvelope? command, Entry entry) => +{ + if (command is null || string.IsNullOrWhiteSpace(command.Action)) + { + return Results.Content(ErrorResponse("Missing action"), "application/json"); + } + + var inputJson = JsonSerializer.Serialize(command, gatewayJsonOptions); + var responseJson = await entry.HandleCommandAsync(inputJson); + return Results.Content(responseJson, "application/json"); +}); + +app.MapGet("/api/sidecar/root", (SidecarRootState rootState) => +{ + var snapshot = rootState.Get(); + return Results.Ok(new + { + root = snapshot.Root, + isCustom = snapshot.IsCustom + }); +}); + +app.MapPost("/api/sidecar/root", (SetSidecarRootRequest? request, SidecarRootState rootState) => +{ + var path = request?.Path ?? ""; + if (!string.IsNullOrWhiteSpace(path) && !Directory.Exists(path)) + { + return Results.BadRequest($"Directory '{path}' does not exist."); + } + + rootState.Set(path); + var snapshot = rootState.Get(); + Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", snapshot.Root); + return Results.Ok(new + { + root = snapshot.Root, + isCustom = snapshot.IsCustom + }); +}); + +if (Directory.Exists(webDistPath) && File.Exists(Path.Combine(webDistPath, "index.html"))) +{ + var fileProvider = new PhysicalFileProvider(webDistPath); + var indexPath = Path.Combine(webDistPath, "index.html"); + + app.UseDefaultFiles(new DefaultFilesOptions + { + FileProvider = fileProvider, + RequestPath = "" + }); + + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = fileProvider, + RequestPath = "" + }); + + app.MapGet("/", async context => + { + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.SendFileAsync(indexPath); + }); + + app.MapFallback(async context => + { + if (context.Request.Path.StartsWithSegments("/api")) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.SendFileAsync(indexPath); + }); +} +else +{ + app.MapGet("/", () => Results.Ok(new + { + name = "Journal.WebGateway", + status = "ok", + uiAvailable = false, + message = "No built web UI found. Build Journal.App with ./scripts/publish-app.ps1 -Target web.", + expectedDist = webDistPath + })); +} + +app.Run(); + +string ResolveRepoRoot() +{ + var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + { + return Path.GetFullPath(fromEnv); + } + + foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) + { + var resolved = FindRepoRoot(start); + if (resolved is not null) + { + return resolved; + } + } + + return Path.GetFullPath(Directory.GetCurrentDirectory()); +} + +string? FindRepoRoot(string start) +{ + var cursor = Path.GetFullPath(start); + + while (!string.IsNullOrWhiteSpace(cursor)) + { + if (File.Exists(Path.Combine(cursor, "Journal.slnx")) || + Directory.Exists(Path.Combine(cursor, "Journal.Sidecar")) || + Directory.Exists(Path.Combine(cursor, "Journal.Core"))) + { + return cursor; + } + + var parent = Directory.GetParent(cursor); + if (parent is null || string.Equals(parent.FullName, cursor, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + cursor = parent.FullName; + } + + return null; +} + +string ResolveWebDist(string repoRootPath) +{ + var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_WEB_DIST"); + if (!string.IsNullOrWhiteSpace(fromEnv)) + { + return Path.GetFullPath(fromEnv); + } + + var packagedWwwRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + if (Directory.Exists(packagedWwwRoot)) + { + return packagedWwwRoot; + } + + return Path.Combine(repoRootPath, "Journal.App", "build"); +} + +string ErrorResponse(string message) + => JsonSerializer.Serialize(new { ok = false, error = message }, gatewayJsonOptions); + +sealed class WebUiState +{ + public WebUiState(string distPath) + { + DistPath = distPath; + } + + public string DistPath { get; } + + public bool Exists => Directory.Exists(DistPath) && File.Exists(Path.Combine(DistPath, "index.html")); +} + +sealed class SidecarRootState +{ + private readonly object _sync = new(); + private readonly string _autoRoot; + private string _currentRoot; + private bool _isCustom; + + public SidecarRootState(string autoRoot) + { + _autoRoot = autoRoot; + _currentRoot = autoRoot; + _isCustom = false; + } + + public (string Root, bool IsCustom) Get() + { + lock (_sync) + { + return (_currentRoot, _isCustom); + } + } + + public void Set(string? path) + { + lock (_sync) + { + if (string.IsNullOrWhiteSpace(path)) + { + _currentRoot = _autoRoot; + _isCustom = false; + return; + } + + _currentRoot = Path.GetFullPath(path.Trim()); + _isCustom = true; + } + } +} + +sealed class SetSidecarRootRequest +{ + public string? Path { get; set; } +} + +sealed class CommandEnvelope +{ + public string Action { get; set; } = ""; + public string? CorrelationId { get; set; } + public string? Id { get; set; } + public string? Type { get; set; } + public string? Tag { get; set; } + public JsonElement? Payload { get; set; } +} + + diff --git a/scripts/dev-shell.ps1 b/scripts/dev-shell.ps1 new file mode 100644 index 0000000..eb262d0 --- /dev/null +++ b/scripts/dev-shell.ps1 @@ -0,0 +1,24 @@ +# Run this in PowerShell before development commands: +# . ./scripts/dev-shell.ps1 + +$commonScript = Join-Path $PSScriptRoot "script-common.ps1" +if (-not (Test-Path $commonScript)) { + throw "Missing helper script: $commonScript" +} +. $commonScript + +$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot + +# Clear dead proxy overrides and offline-only pip mode in current shell. +Clear-JournalProxyEnv + +# Keep .NET artifacts local to repo to avoid restricted user-profile paths. +Initialize-JournalDotnetEnv -RepoRoot $repoRoot + +# Keep pip artifacts local to repo. +Initialize-JournalPipEnv -RepoRoot $repoRoot + +# Keep Hugging Face cache local and silence symlink-only warning on Windows. +Initialize-JournalHuggingFaceEnv -RepoRoot $repoRoot + +Write-Host "Development shell initialized for repo-local dotnet/pip paths at: $repoRoot" diff --git a/scripts/migration-gate.ps1 b/scripts/migration-gate.ps1 new file mode 100644 index 0000000..e621cbd --- /dev/null +++ b/scripts/migration-gate.ps1 @@ -0,0 +1,46 @@ +param( + [switch]$SkipSmoke, + [switch]$SkipApi +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$parityReport = Join-Path $repoRoot "logs\parity_harness_results.json" + +Write-Host "migration-gate: repo root = $repoRoot" + +Push-Location $repoRoot +try { + Write-Host "migration-gate: building sidecar binary..." + & "$repoRoot\scripts\dotnet-min.ps1" build Journal.Sidecar/Journal.Sidecar.csproj + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + if (-not $SkipSmoke) { + Write-Host "migration-gate: running csharp smoke tests..." + & "$repoRoot\scripts\dotnet-min.ps1" run --project Journal.SmokeTests/Journal.SmokeTests.csproj + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + else { + Write-Host "migration-gate: skipping smoke tests (--SkipSmoke)." + } + + Write-Host "migration-gate: running parity harness + fixture matrix..." + $env:PARITY_HARNESS_REPORT = $parityReport + & python -m unittest discover -s tests -p "test_parity_harness.py" -v + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + if (-not $SkipApi) { + Write-Host "migration-gate: running API contract tests..." + & python -m unittest discover -s tests -p "test_api_contract.py" -v + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + else { + Write-Host "migration-gate: skipping API contract tests (--SkipApi)." + } + + Write-Host "migration-gate: PASS" + Write-Host "migration-gate: parity report => $parityReport" +} +finally { + Pop-Location +} diff --git a/scripts/pip-min.ps1 b/scripts/pip-min.ps1 new file mode 100644 index 0000000..6bf7f2c --- /dev/null +++ b/scripts/pip-min.ps1 @@ -0,0 +1,56 @@ +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$PipArgs +) + +$commonScript = Join-Path $PSScriptRoot "script-common.ps1" +if (-not (Test-Path $commonScript)) { + throw "Missing helper script: $commonScript" +} +. $commonScript + +$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot + +Initialize-JournalPipEnv -RepoRoot $repoRoot +Clear-JournalProxyEnv + +if (-not $PipArgs -or $PipArgs.Count -eq 0) { + Write-Host "Usage: ./scripts/pip-min.ps1 " + Write-Host "Example: ./scripts/pip-min.ps1 install --index-url https://pypi.org/simple faster-whisper" + exit 2 +} + +# Default install target to a repo-local directory so installs do not require +# user/site-packages write access on constrained hosts. +$effectiveArgs = @($PipArgs) +$firstArg = $effectiveArgs[0].ToLowerInvariant() +if ($firstArg -eq "install") { + # On Windows, map PyAudio to pyaudiowpatch (wheel available for newer CPython), + # avoiding source builds that require PortAudio headers/toolchain wiring. + for ($i = 0; $i -lt $effectiveArgs.Count; $i++) { + $arg = $effectiveArgs[$i] + if ($arg -match '^(?i)pyaudio($|[<>=!~].*)') { + $suffix = $arg.Substring(7) + $effectiveArgs[$i] = "pyaudiowpatch$suffix" + Write-Host "pip-min: mapped '$arg' -> '$($effectiveArgs[$i])' on Windows." + } + } + + $hasTarget = $effectiveArgs -contains "--target" -or $effectiveArgs -contains "-t" -or $effectiveArgs -contains "--prefix" + if (-not $hasTarget) { + $effectiveArgs = $effectiveArgs | Where-Object { $_ -ne "--user" } + $localTarget = Join-Path $repoRoot ".pydeps\py314" + New-Item -ItemType Directory -Force -Path $localTarget | Out-Null + $effectiveArgs += @("--target", $localTarget) + Write-Host "pip-min: using local target $localTarget" + } +} + +$pipWrapper = Join-Path $PSScriptRoot "pip_safe.py" +if (Test-Path $pipWrapper) { + & python $pipWrapper @effectiveArgs +} +else { + & python -m pip @effectiveArgs +} +exit $LASTEXITCODE diff --git a/scripts/pip_safe.py b/scripts/pip_safe.py new file mode 100644 index 0000000..a02a307 --- /dev/null +++ b/scripts/pip_safe.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import os +import tempfile +from typing import Callable + + +def _mkdtemp_compat( + suffix: str | None = None, + prefix: str | None = None, + dir: str | None = None, +) -> str: + # Python 3.14 on some Windows hosts creates mkdtemp dirs that are + # immediately non-writable by the same process when mode=0o700 is used. + # pip relies heavily on tempfile; force 0o777 for compatibility. + if dir is None: + dir = tempfile.gettempdir() + if prefix is None: + prefix = tempfile.template + if suffix is None: + suffix = "" + + names = tempfile._get_candidate_names() + for _ in range(tempfile.TMP_MAX): + name = next(names) + path = os.path.join(dir, f"{prefix}{name}{suffix}") + try: + os.mkdir(path, 0o777) + return path + except FileExistsError: + continue + + raise FileExistsError("No usable temporary directory name found.") + + +def main(argv: list[str]) -> int: + tempfile.mkdtemp = _mkdtemp_compat # type: ignore[assignment] + + from pip._internal.cli.main import main as pip_main + + return int(pip_main(argv)) + + +if __name__ == "__main__": + raise SystemExit(main(__import__("sys").argv[1:])) + diff --git a/scripts/publish-app.ps1 b/scripts/publish-app.ps1 new file mode 100644 index 0000000..0fbd131 --- /dev/null +++ b/scripts/publish-app.ps1 @@ -0,0 +1,157 @@ +param( + [ValidateSet("web", "tauri")] + [string]$Target = "web", + [ValidateSet("Release", "Debug")] + [string]$Configuration = "Release", + [ValidateSet("none", "nsis", "msi")] + [string]$TauriBundles = "none", + [switch]$InstallDeps, + [switch]$SkipInstall, + [switch]$DryRun +) + +$commonScript = Join-Path $PSScriptRoot "script-common.ps1" +if (-not (Test-Path $commonScript)) { + throw "Missing helper script: $commonScript" +} +. $commonScript + +$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot +$appRoot = Resolve-JournalAppRoot -RepoRoot $repoRoot + +Clear-JournalProxyEnv + +# Keep npm cache and temp local to the repo. +$npmCacheDir = Join-Path $repoRoot ".npm\cache" +$npmTempDir = Join-Path $repoRoot ".tmp\npm-temp" +New-Item -ItemType Directory -Force -Path $npmCacheDir, $npmTempDir | Out-Null +$env:npm_config_cache = $npmCacheDir +$env:npm_config_update_notifier = "false" +$env:npm_config_fund = "false" +$env:npm_config_audit = "false" +$env:npm_config_offline = "false" +$env:npm_config_prefer_offline = "false" +$env:npm_config_prefer_online = "true" +$env:TEMP = $npmTempDir +$env:TMP = $npmTempDir + +if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + throw "npm is required but was not found in PATH." +} + +$nodeModulesPath = Join-Path $appRoot "node_modules" +$packageLockPath = Join-Path $appRoot "package-lock.json" +$shouldInstall = $InstallDeps -or (-not (Test-Path $nodeModulesPath)) +if ($SkipInstall) { + $shouldInstall = $false +} + +Write-Host "Building Journal.App target '$Target' ($Configuration)..." -ForegroundColor Cyan +Write-Host "Using app root: $appRoot" -ForegroundColor DarkGray + +Push-Location $appRoot +try { + if ($shouldInstall) { + $installArgs = if (Test-Path $packageLockPath) { + @("ci", "--no-audit", "--fund=false") + } + else { + @("install", "--no-audit", "--fund=false") + } + + Write-Host "> npm $($installArgs -join ' ')" -ForegroundColor DarkGray + if (-not $DryRun) { + & npm @installArgs + if ($LASTEXITCODE -ne 0) { + throw "Dependency install failed with exit code $LASTEXITCODE." + } + } + } + else { + Write-Host "Skipping dependency install (node_modules already present)." -ForegroundColor DarkGray + } + + if ($Target -eq "web") { + $buildArgs = @("run", "build") + Write-Host "> npm $($buildArgs -join ' ')" -ForegroundColor DarkGray + if (-not $DryRun) { + & npm @buildArgs + if ($LASTEXITCODE -ne 0) { + throw "Frontend build failed with exit code $LASTEXITCODE." + } + } + + $outputPath = Join-Path $appRoot "build" + if ($DryRun) { + Write-Host "`nDry run complete (no commands executed)." -ForegroundColor Yellow + Write-Host "Expected output: $outputPath" -ForegroundColor Gray + } + else { + Write-Host "`nFrontend build successful." -ForegroundColor Green + Write-Host "Output: $outputPath" -ForegroundColor Gray + } + } + else { + $tauriArgs = @("run", "tauri", "build") + $tauriCliArgs = @() + if ($TauriBundles -eq "none") { + $tauriCliArgs += "--no-bundle" + } + else { + $tauriCliArgs += @("--bundles", $TauriBundles) + } + if ($Configuration -eq "Debug") { + $tauriCliArgs += "--debug" + } + if ($tauriCliArgs.Count -gt 0) { + $tauriArgs += "--" + $tauriArgs += $tauriCliArgs + } + + Write-Host "> npm $($tauriArgs -join ' ')" -ForegroundColor DarkGray + if (-not $DryRun) { + & npm @tauriArgs + if ($LASTEXITCODE -ne 0) { + throw "Tauri build failed with exit code $LASTEXITCODE." + } + } + + $targetConfigDir = if ($Configuration -eq "Debug") { "debug" } else { "release" } + $tauriTargetPath = Join-Path $appRoot "src-tauri\target" + $rawExePath = Join-Path $tauriTargetPath "$targetConfigDir\journalapp.exe" + if ($DryRun) { + Write-Host "`nDry run complete (no commands executed)." -ForegroundColor Yellow + if ($TauriBundles -eq "none") { + Write-Host "Expected executable: $rawExePath" -ForegroundColor Gray + } + else { + Write-Host "Expected output root: $tauriTargetPath" -ForegroundColor Gray + } + } + else { + Write-Host "`nTauri build successful." -ForegroundColor Green + if ($TauriBundles -eq "none") { + if (Test-Path $rawExePath) { + Write-Host "Executable location: $rawExePath" -ForegroundColor Gray + } + else { + $exeCandidates = Get-ChildItem -Path (Join-Path $tauriTargetPath $targetConfigDir) -File -Filter *.exe -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending + if ($exeCandidates -and $exeCandidates.Count -gt 0) { + Write-Host "Executable location: $($exeCandidates[0].FullName)" -ForegroundColor Gray + } + else { + Write-Host "Output root: $tauriTargetPath" -ForegroundColor Gray + } + } + } + else { + Write-Host "Output root: $tauriTargetPath" -ForegroundColor Gray + } + } + } +} +finally { + Pop-Location +} + diff --git a/scripts/publish-sidecar.ps1 b/scripts/publish-sidecar.ps1 new file mode 100644 index 0000000..ab2a855 --- /dev/null +++ b/scripts/publish-sidecar.ps1 @@ -0,0 +1,54 @@ +param( + [string]$Configuration = "Release", + [string]$Runtime = "win-x64" +) + +$commonScript = Join-Path $PSScriptRoot "script-common.ps1" +if (-not (Test-Path $commonScript)) { + throw "Missing helper script: $commonScript" +} +. $commonScript + +$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot +$csproj = Resolve-JournalSidecarProjectPath -RepoRoot $repoRoot +$outputDir = Join-Path $repoRoot "output" + +# Setup local dotnet environment (matches dotnet-min.ps1 logic) +Clear-JournalProxyEnv +Initialize-JournalDotnetEnv -RepoRoot $repoRoot + +Write-Host "Publishing Journal.Sidecar ($Configuration, $Runtime)..." -ForegroundColor Cyan +Write-Host "Using project: $csproj" -ForegroundColor DarkGray + +$publishArgs = @( + "publish", $csproj, + "-c", $Configuration, + "-r", $Runtime, + "--self-contained", + "-p:PublishSingleFile=true", + "-p:IncludeNativeLibrariesForSelfExtract=true", + "-p:RestoreIgnoreFailedSources=true", + "-p:NuGetAudit=false", + "-o", $outputDir +) + +& dotnet @publishArgs + +if ($LASTEXITCODE -eq 0) { + $binaryName = [System.IO.Path]::GetFileNameWithoutExtension($csproj) + $isWindowsRuntime = $Runtime -like "win-*" + $binaryFile = if ($isWindowsRuntime) { "$binaryName.exe" } else { $binaryName } + $binaryPath = Join-Path $outputDir $binaryFile + + Write-Host "`nPublish successful!" -ForegroundColor Green + if (Test-Path $binaryPath) { + Write-Host "Executable location: $binaryPath" -ForegroundColor Gray + } + else { + Write-Host "Output directory: $outputDir" -ForegroundColor Gray + } +} +else { + Write-Host "`nPublish failed with exit code $LASTEXITCODE" -ForegroundColor Red + exit $LASTEXITCODE +} diff --git a/scripts/publish-webgateway.ps1 b/scripts/publish-webgateway.ps1 new file mode 100644 index 0000000..b3e3322 --- /dev/null +++ b/scripts/publish-webgateway.ps1 @@ -0,0 +1,55 @@ +param( + [ValidateSet("Release", "Debug")] + [string]$Configuration = "Release", + [string]$Runtime = "win-x64", + [switch]$SelfContained, + [switch]$SkipWebAssets +) + +$commonScript = Join-Path $PSScriptRoot "script-common.ps1" +if (-not (Test-Path $commonScript)) { + throw "Missing helper script: $commonScript" +} +. $commonScript + +$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot +$gatewayProject = Resolve-JournalWebGatewayProjectPath -RepoRoot $repoRoot +$outputDir = Join-Path $repoRoot "output\webgateway" +$webBuildDir = Join-Path $repoRoot "Journal.App\build" +$webOutputDir = Join-Path $outputDir "wwwroot" + +Clear-JournalProxyEnv +Initialize-JournalDotnetEnv -RepoRoot $repoRoot + +Write-Host "Publishing Journal.WebGateway ($Configuration, $Runtime)..." -ForegroundColor Cyan +Write-Host "Project: $gatewayProject" -ForegroundColor DarkGray + +$publishArgs = @( + "publish", $gatewayProject, + "-c", $Configuration, + "-r", $Runtime, + "--self-contained", ($SelfContained.IsPresent.ToString().ToLowerInvariant()), + "-p:RestoreIgnoreFailedSources=true", + "-p:NuGetAudit=false", + "-o", $outputDir +) + +& dotnet @publishArgs +if ($LASTEXITCODE -ne 0) { + Write-Host "`nPublish failed with exit code $LASTEXITCODE" -ForegroundColor Red + exit $LASTEXITCODE +} + +if (-not $SkipWebAssets) { + if (Test-Path $webBuildDir) { + New-Item -ItemType Directory -Force -Path $webOutputDir | Out-Null + Copy-Item -Path (Join-Path $webBuildDir "*") -Destination $webOutputDir -Recurse -Force + Write-Host "Copied web assets to: $webOutputDir" -ForegroundColor Gray + } + else { + Write-Warning "Journal.App build output not found at $webBuildDir. Run ./scripts/publish-app.ps1 -Target web first." + } +} + +Write-Host "`nPublish successful." -ForegroundColor Green +Write-Host "Output directory: $outputDir" -ForegroundColor Gray diff --git a/scripts/run-webgateway.ps1 b/scripts/run-webgateway.ps1 new file mode 100644 index 0000000..eb2c6d6 --- /dev/null +++ b/scripts/run-webgateway.ps1 @@ -0,0 +1,48 @@ +param( + [ValidateSet("Release", "Debug")] + [string]$Configuration = "Release", + [string]$Urls = "http://0.0.0.0:5180", + [string]$ProjectRoot +) + +$commonScript = Join-Path $PSScriptRoot "script-common.ps1" +if (-not (Test-Path $commonScript)) { + throw "Missing helper script: $commonScript" +} +. $commonScript + +$repoRoot = Resolve-JournalRepoRoot -StartPath $PSScriptRoot +$gatewayProject = Resolve-JournalWebGatewayProjectPath -RepoRoot $repoRoot + +$effectiveProjectRoot = if ([string]::IsNullOrWhiteSpace($ProjectRoot)) { + $repoRoot +} +else { + [System.IO.Path]::GetFullPath($ProjectRoot) +} + +if (-not (Test-Path $effectiveProjectRoot)) { + throw "ProjectRoot does not exist: $effectiveProjectRoot" +} + +Clear-JournalProxyEnv +Initialize-JournalDotnetEnv -RepoRoot $repoRoot +$env:JOURNAL_PROJECT_ROOT = $effectiveProjectRoot + +Write-Host "Running Journal.WebGateway ($Configuration)..." -ForegroundColor Cyan +Write-Host "Project: $gatewayProject" -ForegroundColor DarkGray +Write-Host "URLs: $Urls" -ForegroundColor DarkGray +Write-Host "JOURNAL_PROJECT_ROOT: $effectiveProjectRoot" -ForegroundColor DarkGray + +$runArgs = @( + "run", + "--project", $gatewayProject, + "-c", $Configuration, + "--no-launch-profile", + "--urls", $Urls, + "-p:RestoreIgnoreFailedSources=true", + "-p:NuGetAudit=false" +) + +& dotnet @runArgs +exit $LASTEXITCODE diff --git a/scripts/script-common.ps1 b/scripts/script-common.ps1 new file mode 100644 index 0000000..eda3a90 --- /dev/null +++ b/scripts/script-common.ps1 @@ -0,0 +1,240 @@ +function Clear-JournalProxyEnv { + # Clear proxy/no-index env vars that commonly break package restore. + 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-JournalRepoRootCandidate { + param( + [string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $false + } + + $markers = @( + "Journal.slnx", + "scripts\dev-shell.ps1", + "Journal.Sidecar\Journal.Sidecar.csproj" + ) + + foreach ($marker in $markers) { + if (Test-Path (Join-Path $Path $marker)) { + return $true + } + } + + return $false +} + +function Resolve-JournalRepoRoot { + param( + [string]$StartPath + ) + + $candidateStarts = @() + if (-not [string]::IsNullOrWhiteSpace($StartPath)) { + $candidateStarts += $StartPath + } + + $currentPath = (Get-Location).Path + if (-not [string]::IsNullOrWhiteSpace($currentPath) -and ($candidateStarts -notcontains $currentPath)) { + $candidateStarts += $currentPath + } + + $override = $env:JOURNAL_REPO_ROOT + if (-not [string]::IsNullOrWhiteSpace($override)) { + $overridePath = [System.IO.Path]::GetFullPath($override) + if (Test-JournalRepoRootCandidate -Path $overridePath) { + return $overridePath + } + Write-Warning "JOURNAL_REPO_ROOT is set but does not look like this repo: $overridePath" + } + + 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)) { + $gitRootPath = [System.IO.Path]::GetFullPath($gitRoot.Trim()) + if (Test-JournalRepoRootCandidate -Path $gitRootPath) { + return $gitRootPath + } + } + } + catch { + } + } + } + + foreach ($start in $candidateStarts) { + $cursor = [System.IO.Path]::GetFullPath($start) + while (-not [string]::IsNullOrWhiteSpace($cursor)) { + if (Test-JournalRepoRootCandidate -Path $cursor) { + return $cursor + } + + $parent = [System.IO.Directory]::GetParent($cursor) + if ($null -eq $parent) { + break + } + + if ($parent.FullName -eq $cursor) { + break + } + + $cursor = $parent.FullName + } + } + + throw "Could not locate repository root. Set JOURNAL_REPO_ROOT to the repo path." +} + +function Initialize-JournalDotnetEnv { + 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-JournalPipEnv { + 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-JournalHuggingFaceEnv { + 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 +} + +function Resolve-JournalSidecarProjectPath { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot + ) + + $defaultPath = Join-Path $RepoRoot "Journal.Sidecar\Journal.Sidecar.csproj" + if (Test-Path $defaultPath) { + return (Resolve-Path $defaultPath).Path + } + + $exactMatches = @(Get-ChildItem -Path $RepoRoot -Recurse -File -Filter "Journal.Sidecar.csproj" -ErrorAction SilentlyContinue) + if ($exactMatches.Count -eq 1) { + return $exactMatches[0].FullName + } + if ($exactMatches.Count -gt 1) { + $matchList = ($exactMatches | ForEach-Object { $_.FullName }) -join "; " + throw "Found multiple Journal.Sidecar.csproj files: $matchList" + } + + $fallbackMatches = @(Get-ChildItem -Path $RepoRoot -Recurse -File -Filter "*.csproj" -ErrorAction SilentlyContinue | Where-Object { + $_.BaseName -match "(?i)sidecar" -or $_.DirectoryName -match "(?i)sidecar" + }) + if ($fallbackMatches.Count -eq 1) { + return $fallbackMatches[0].FullName + } + if ($fallbackMatches.Count -gt 1) { + $matchList = ($fallbackMatches | ForEach-Object { $_.FullName }) -join "; " + throw "Found multiple sidecar-like csproj files: $matchList" + } + + throw "Could not locate a sidecar project file under: $RepoRoot" +} + +function Resolve-JournalAppRoot { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot + ) + + $defaultPath = Join-Path $RepoRoot "Journal.App" + $defaultPackageJson = Join-Path $defaultPath "package.json" + $defaultTauriConfig = Join-Path $defaultPath "src-tauri\tauri.conf.json" + if ((Test-Path $defaultPackageJson) -and (Test-Path $defaultTauriConfig)) { + return (Resolve-Path $defaultPath).Path + } + + $packageJsonMatches = @(Get-ChildItem -Path $RepoRoot -Recurse -File -Filter "package.json" -ErrorAction SilentlyContinue | Where-Object { + Test-Path (Join-Path $_.DirectoryName "src-tauri\tauri.conf.json") + }) + if ($packageJsonMatches.Count -eq 1) { + return $packageJsonMatches[0].DirectoryName + } + if ($packageJsonMatches.Count -gt 1) { + $matchList = ($packageJsonMatches | ForEach-Object { $_.DirectoryName }) -join "; " + throw "Found multiple Tauri app roots under repo: $matchList" + } + + throw "Could not locate Journal.App root under: $RepoRoot" +} + +function Resolve-JournalWebGatewayProjectPath { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot + ) + + $defaultPath = Join-Path $RepoRoot "Journal.WebGateway\Journal.WebGateway.csproj" + if (Test-Path $defaultPath) { + return (Resolve-Path $defaultPath).Path + } + + $exactMatches = @(Get-ChildItem -Path $RepoRoot -Recurse -File -Filter "Journal.WebGateway.csproj" -ErrorAction SilentlyContinue) + if ($exactMatches.Count -eq 1) { + return $exactMatches[0].FullName + } + if ($exactMatches.Count -gt 1) { + $matchList = ($exactMatches | ForEach-Object { $_.FullName }) -join "; " + throw "Found multiple Journal.WebGateway.csproj files: $matchList" + } + + throw "Could not locate Journal.WebGateway.csproj under: $RepoRoot" +}