Added Web WebGateway

Added connector so gateway works.
scripts are much more polished and functional.
This commit is contained in:
stan44 2026-02-27 11:03:53 -06:00
parent a72ddf6aec
commit 069b38071c
19 changed files with 1261 additions and 46 deletions

3
.gitignore vendored
View File

@ -24,6 +24,9 @@ project.fragment.lock.json
.nuget/
.dotnet_home/
.journal-sidecar/
.tmp
.npm
output/
# Publish output
publish/

View File

@ -20,10 +20,17 @@ type RuntimeConfig = {
vaultDirectory: string;
};
async function getRuntimeConfig(): Promise<RuntimeConfig> {
const data = await sendCommand<RuntimeConfigRaw>({
type PersistOptions = {
keepalive?: boolean;
};
async function getRuntimeConfig(options: PersistOptions = {}): Promise<RuntimeConfig> {
const data = await sendCommand<RuntimeConfigRaw>(
{
action: "config.get"
});
},
options
);
return {
dataDirectory: pickCase(data, "dataDirectory", "DataDirectory", ""),
@ -55,22 +62,28 @@ export async function unlockVaultWorkspace(password: string): Promise<void> {
});
}
export async function persistAndClearVault(password: string): Promise<void> {
const config = await getRuntimeConfig();
export async function persistAndClearVault(password: string, options: PersistOptions = {}): Promise<void> {
const config = await getRuntimeConfig(options);
await sendCommand<boolean>({
await sendCommand<boolean>(
{
action: "vault.rebuild_all",
payload: {
password,
vaultDirectory: config.vaultDirectory,
dataDirectory: config.dataDirectory
}
});
},
options
);
await sendCommand<boolean>({
await sendCommand<boolean>(
{
action: "vault.clear_data_directory",
payload: {
dataDirectory: config.dataDirectory
}
});
},
options
);
}

View File

@ -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<T>(command: BackendCommand): Promise<T> {
type SendCommandOptions = {
keepalive?: boolean;
};
export async function sendCommand<T>(command: BackendCommand, options: SendCommandOptions = {}): Promise<T> {
const envelope: BackendCommand = {
...command,
correlationId: command.correlationId ?? newCorrelationId()
};
const response = await invoke<BackendResponse<T>>("sidecar_command", { command: envelope });
const response = await invoke<BackendResponse<T>>("sidecar_command", {
command: envelope,
keepalive: options.keepalive === true
});
if (!response.ok) {
throw new Error(response.error || "Backend command failed");

View File

@ -0,0 +1,143 @@
import type { BackendCommand } from "$lib/backend/types";
type InvokeArgs = Record<string, unknown> | 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<T>(path: string, init: RequestInit = {}, options: FetchJsonOptions = {}): Promise<T> {
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<T>(command: string, args?: InvokeArgs): Promise<T> {
if (isTauriRuntime()) {
const tauriCore = await import("@tauri-apps/api/core");
return tauriCore.invoke<T>(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<T>(
"/command",
{
method: "POST",
body: JSON.stringify(envelope as BackendCommand)
},
{ keepalive }
);
}
case "get_sidecar_root":
return fetchJson<T>("/sidecar/root");
case "set_sidecar_root": {
const path = typeof args?.path === "string" ? args.path : "";
return fetchJson<T>("/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}`);
}
}

View File

@ -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;
}

View File

@ -1,40 +1,71 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
import { getSessionPassword, clearVaultSession, flushBeforeClose } from "$lib/stores/session";
import { persistAndClearVault } from "$lib/backend/auth";
import { hydrateUiSettings } from "$lib/stores/settings";
import { invoke, isTauriRuntime } from "$lib/runtime/invoke";
let closeInProgress = false;
onMount(() => {
void hydrateUiSettings();
const appWindow = getCurrentWindow();
const unlistenPromise = appWindow.onCloseRequested(async (event) => {
async function persistSession(keepalive = false): Promise<void> {
if (closeInProgress) return;
event.preventDefault();
closeInProgress = true;
try { await flushBeforeClose(); } catch {}
try {
await flushBeforeClose();
} catch {
// best effort
}
const password = getSessionPassword();
if (password) {
if (!password) {
return;
}
try {
await persistAndClearVault(password);
await persistAndClearVault(password, { keepalive });
clearVaultSession();
} catch (error) {
console.error("Vault persistence on exit failed:", error);
}
}
onMount(() => {
void hydrateUiSettings();
if (isTauriRuntime()) {
const unlistenPromise = (async () => {
const tauriWindow = await import("@tauri-apps/api/window");
const appWindow = tauriWindow.getCurrentWindow();
return appWindow.onCloseRequested(async (event) => {
if (closeInProgress) return;
event.preventDefault();
await persistSession();
await invoke("shutdown");
});
})();
return () => {
void unlistenPromise.then((unlisten) => unlisten());
};
}
const handlePageHide = () => {
void persistSession(true);
};
const handleBeforeUnload = () => {
void persistSession(true);
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
});
</script>

View File

@ -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;
}
</style>

View File

@ -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;
}
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Journal.Core\Journal.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -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<Entry>();
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; }
}

24
scripts/dev-shell.ps1 Normal file
View File

@ -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"

View File

@ -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
}

56
scripts/pip-min.ps1 Normal file
View File

@ -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 <pip args>"
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

46
scripts/pip_safe.py Normal file
View File

@ -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:]))

157
scripts/publish-app.ps1 Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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

240
scripts/script-common.ps1 Normal file
View File

@ -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"
}