diff --git a/Journal.DevTool/Config/ConfigLoader.cs b/Journal.DevTool/Config/ConfigLoader.cs new file mode 100644 index 0000000..471003d --- /dev/null +++ b/Journal.DevTool/Config/ConfigLoader.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +namespace Sdt.Config; + +public static class ConfigLoader +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; + + /// + /// Walks up from (or CWD) until it finds devtool.json. + /// Returns null if not found. + /// + public static (DevToolConfig Config, string ProjectRoot)? FindAndLoad(string? startDir = null) + { + var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory()); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, "devtool.json"); + if (File.Exists(candidate)) + { + try + { + var json = File.ReadAllText(candidate); + var config = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException("devtool.json deserialized to null."); + return (config, dir.FullName); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to parse devtool.json at {candidate}: {ex.Message}", ex); + } + } + dir = dir.Parent!; + } + return null; + } +} diff --git a/Journal.DevTool/Config/DevToolConfig.cs b/Journal.DevTool/Config/DevToolConfig.cs new file mode 100644 index 0000000..fbb91a5 --- /dev/null +++ b/Journal.DevTool/Config/DevToolConfig.cs @@ -0,0 +1,86 @@ +namespace Sdt.Config; + +public sealed class DevToolConfig +{ + public string Name { get; init; } = "SDT Project"; + public string Version { get; init; } = "0.1.0"; + public List Targets { get; init; } = []; + public List Env { get; init; } = []; + public ToolchainConfig? Toolchains { get; init; } +} + +public sealed class BuildTarget +{ + public string Id { get; init; } = ""; + public string Label { get; init; } = ""; + public string Description { get; init; } = ""; + public string Group { get; init; } = "General"; + + /// Executable name. Null = virtual aggregator (runs DependsOn only). + public string? Command { get; init; } + + public List Args { get; init; } = []; + + /// Working directory relative to project root. + public string WorkingDir { get; init; } = "."; + + public List DependsOn { get; init; } = []; +} + +public sealed class EnvVarDef +{ + public string Key { get; init; } = ""; + public string Description { get; init; } = ""; + + [System.Text.Json.Serialization.JsonPropertyName("default")] + public string DefaultValue { get; init; } = ""; + + /// If non-empty, shown as a dropdown. Otherwise free-text input. + public List Options { get; init; } = []; +} + +// ── Toolchain config ────────────────────────────────────────────────────────── + +public sealed class ToolchainConfig +{ + public PythonToolchain? Python { get; init; } + public NodeToolchain? Node { get; init; } +} + +public sealed class PythonToolchain +{ + /// Python executable (e.g. "python3.14", "python"). + public string Executable { get; init; } = "python"; + + /// Windows-specific override (e.g. "py" when using the launcher). + public string? WindowsExecutable { get; init; } + + /// Optional version flag to pass (e.g. "-3.14" for py launcher). + public string? LauncherVersion { get; init; } + + /// Venv directory relative to project root. + public string VenvDir { get; init; } = ".venv"; + + public List Profiles { get; init; } = []; + + /// Optional path to a pip wrapper script (relative to project root). + public string? PipScript { get; init; } +} + +public sealed class PythonProfile +{ + public string Id { get; init; } = ""; + public string Label { get; init; } = ""; + public string RequirementsFile { get; init; } = ""; + public string? ExtraIndexUrl { get; init; } + public List PostInstallCommands { get; init; } = []; +} + +public sealed class NodeToolchain +{ + /// Package manager: "npm", "pnpm", or "yarn". + public string PackageManager { get; init; } = "npm"; + + /// Working directory for the frontend (relative to project root). + public string WorkingDir { get; init; } = "."; +} diff --git a/Journal.DevTool/Config/WorkspaceConfig.cs b/Journal.DevTool/Config/WorkspaceConfig.cs new file mode 100644 index 0000000..fce9cb7 --- /dev/null +++ b/Journal.DevTool/Config/WorkspaceConfig.cs @@ -0,0 +1,19 @@ +namespace Sdt.Config; + +public sealed class WorkspaceConfig +{ + public string Name { get; init; } = "SDT Workspace"; + public List Projects { get; init; } = []; +} + +public sealed class WorkspaceProject +{ + public string Name { get; init; } = ""; + public string Description { get; init; } = ""; + + /// + /// Relative path from the sdt-workspace.json directory to the project root + /// (the directory containing devtool.json). + /// + public string Path { get; init; } = ""; +} diff --git a/Journal.DevTool/Config/WorkspaceLoader.cs b/Journal.DevTool/Config/WorkspaceLoader.cs new file mode 100644 index 0000000..a01f324 --- /dev/null +++ b/Journal.DevTool/Config/WorkspaceLoader.cs @@ -0,0 +1,52 @@ +using System.Text.Json; + +namespace Sdt.Config; + +public static class WorkspaceLoader +{ + private const string FileName = "sdt-workspace.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; + + /// + /// Walks up from (or CWD) to find sdt-workspace.json. + /// Returns null if not found. + /// + public static (WorkspaceConfig Config, string WorkspaceRoot)? FindAndLoad(string? startDir = null) + { + var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory()); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, FileName); + if (File.Exists(candidate)) + { + try + { + var json = File.ReadAllText(candidate); + var config = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException($"{FileName} deserialized to null."); + return (config, dir.FullName); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to parse {FileName} at {candidate}: {ex.Message}", ex); + } + } + dir = dir.Parent!; + } + return null; + } + + /// + /// Resolves the absolute project root for a workspace project entry. + /// + public static string ResolveProjectRoot(string workspaceRoot, WorkspaceProject project) + => Path.GetFullPath(Path.Combine(workspaceRoot, project.Path)); +} diff --git a/Journal.DevTool/Journal.DevTool.csproj b/Journal.DevTool/Journal.DevTool.csproj new file mode 100644 index 0000000..5d8f248 --- /dev/null +++ b/Journal.DevTool/Journal.DevTool.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + sdt + Sdt + false + + + + + + + diff --git a/Journal.DevTool/Program.cs b/Journal.DevTool/Program.cs new file mode 100644 index 0000000..a132fc3 --- /dev/null +++ b/Journal.DevTool/Program.cs @@ -0,0 +1,57 @@ +using Sdt.Config; +using Sdt.Tui; +using Spectre.Console; + +// ── Workspace + project discovery ──────────────────────────────────────────── + +var workspaceResult = WorkspaceLoader.FindAndLoad(); +var projectResult = ConfigLoader.FindAndLoad(); + +if (projectResult is null) +{ + AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No devtool.json found[/] in current directory or any parent."); + AnsiConsole.MarkupLine(Theme.Faint("Create a devtool.json in your project root to get started.")); + return 1; +} + +// ── Main run loop (handles workspace project switching) ─────────────────────── + +var (currentConfig, currentRoot) = projectResult.Value; +var (workspace, workspaceRoot) = workspaceResult.HasValue + ? (workspaceResult.Value.Config, workspaceResult.Value.WorkspaceRoot) + : ((WorkspaceConfig?)null, (string?)null); + +try +{ + while (true) + { + var app = new App(currentConfig, currentRoot, workspace, workspaceRoot); + var result = await app.RunAsync(); + + if (result.Reason == AppExitReason.Quit) + break; + + // User switched projects — reload config from new root + if (result.Reason == AppExitReason.SwitchProject && result.NewProjectRoot is not null) + { + var loaded = ConfigLoader.FindAndLoad(result.NewProjectRoot); + if (loaded is null) + { + AnsiConsole.MarkupLine(Theme.Fail($"No devtool.json found at: {result.NewProjectRoot}")); + AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project...")); + Console.ReadKey(intercept: true); + continue; // go back to current app + } + + (currentConfig, currentRoot) = loaded.Value; + } + } + + return 0; +} +catch (Exception ex) +{ + AnsiConsole.MarkupLine(Theme.Fail($"Fatal: {ex.Message}")); + AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything); + return 1; +} diff --git a/Journal.DevTool/Runner/ProcessRunner.cs b/Journal.DevTool/Runner/ProcessRunner.cs new file mode 100644 index 0000000..33516cc --- /dev/null +++ b/Journal.DevTool/Runner/ProcessRunner.cs @@ -0,0 +1,60 @@ +using System.Diagnostics; + +namespace Sdt.Runner; + +public sealed record RunResult(int ExitCode, TimeSpan Elapsed) +{ + public bool Success => ExitCode == 0; +} + +public static class ProcessRunner +{ + /// + /// Runs a command with the given args, streaming stdout/stderr via . + /// onOutput receives (line, isStderr). + /// + public static async Task RunAsync( + string command, + IEnumerable args, + string workingDir, + Action onOutput, + CancellationToken cancellationToken = default) + { + var psi = new ProcessStartInfo + { + FileName = command, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDir, + }; + + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + var sw = Stopwatch.StartNew(); + + using var process = new Process { StartInfo = psi }; + process.Start(); + + var stdoutTask = DrainAsync(process.StandardOutput, line => onOutput(line, false), cancellationToken); + var stderrTask = DrainAsync(process.StandardError, line => onOutput(line, true), cancellationToken); + + await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + sw.Stop(); + return new RunResult(process.ExitCode, sw.Elapsed); + } + + private static async Task DrainAsync(StreamReader reader, Action emit, CancellationToken ct) + { + string? line; + while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) is not null + && !ct.IsCancellationRequested) + { + emit(line); + } + } +} diff --git a/Journal.DevTool/Runner/TargetRunner.cs b/Journal.DevTool/Runner/TargetRunner.cs new file mode 100644 index 0000000..b4c670e --- /dev/null +++ b/Journal.DevTool/Runner/TargetRunner.cs @@ -0,0 +1,41 @@ +using Sdt.Config; + +namespace Sdt.Runner; + +public static class TargetRunner +{ + /// + /// Returns the ordered list of real (non-virtual) steps needed to execute , + /// respecting DependsOn chains. Each step appears at most once. + /// + public static List ResolvePlan( + BuildTarget target, + IReadOnlyDictionary allTargets) + { + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var plan = new List(); + Visit(target, allTargets, visited, plan); + return plan; + } + + private static void Visit( + BuildTarget target, + IReadOnlyDictionary allTargets, + HashSet visited, + List plan) + { + if (!visited.Add(target.Id)) + return; + + // Recurse into dependencies first (topological order) + foreach (var depId in target.DependsOn) + { + if (allTargets.TryGetValue(depId, out var dep)) + Visit(dep, allTargets, visited, plan); + } + + // Virtual aggregator targets (null Command) are just dependency collectors + if (target.Command is not null) + plan.Add(target); + } +} diff --git a/Journal.DevTool/Tui/App.cs b/Journal.DevTool/Tui/App.cs new file mode 100644 index 0000000..3ad0118 --- /dev/null +++ b/Journal.DevTool/Tui/App.cs @@ -0,0 +1,311 @@ +using Sdt.Config; +using Sdt.Runner; +using Spectre.Console; + +namespace Sdt.Tui; + +/// Thin wrapper used in Spectre.Console selection prompts. +internal sealed record MenuItem(string Display, string Value); + +public enum AppExitReason { Quit, SwitchProject } +public sealed record AppResult(AppExitReason Reason, string? NewProjectRoot = null); + +public sealed class App +{ + private DevToolConfig _config; + private string _projectRoot; + private readonly WorkspaceConfig? _workspace; + private readonly string? _workspaceRoot; + + private IReadOnlyDictionary TargetMap => + _config.Targets.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase); + + public App( + DevToolConfig config, + string projectRoot, + WorkspaceConfig? workspace = null, + string? workspaceRoot = null) + { + _config = config; + _projectRoot = projectRoot; + _workspace = workspace; + _workspaceRoot = workspaceRoot; + } + + public async Task RunAsync() + { + while (true) + { + AnsiConsole.Clear(); + RenderBanner(); + + var choice = ShowMainMenu(); + + switch (choice) + { + case "__env__": + EditEnvironment(); + break; + + case "__toolchains__": + await new ToolchainScreen(_config, _projectRoot).RunAsync(); + break; + + case "__workspace__": + if (_workspace is not null && _workspaceRoot is not null) + { + var switcher = new WorkspaceScreen(_workspace, _workspaceRoot, _projectRoot); + var newRoot = switcher.SelectProject(); + if (newRoot is not null) + return new AppResult(AppExitReason.SwitchProject, newRoot); + } + break; + + case "__quit__": + AnsiConsole.MarkupLine("\n" + Theme.Faint("Later.") + "\n"); + return new AppResult(AppExitReason.Quit); + + default: + var targetMap = TargetMap; + if (targetMap.TryGetValue(choice, out var target)) + await RunTargetAsync(target, targetMap); + break; + } + + if (choice != "__quit__") + { + AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to return to the menu...")); + Console.ReadKey(intercept: true); + } + } + } + + // ── Banner ──────────────────────────────────────────────────────────────── + + private void RenderBanner() + { + // Phosphor green figlet + AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor)); + + // Project + workspace info line + var wsInfo = _workspace is not null + ? $" [{Theme.GreenDim}]∙ {Markup.Escape(_workspace.Name)}[/]" + : string.Empty; + + AnsiConsole.Write( + new Rule($"[bold {Theme.GreenBold}]{Markup.Escape(_config.Name)}[/] [{Theme.GreenDim}]v{Markup.Escape(_config.Version)}[/]{wsInfo}") + .RuleStyle(Theme.DimStyle)); + + AnsiConsole.MarkupLine(Theme.Faint($"root: {_projectRoot}") + "\n"); + } + + // ── Main menu ───────────────────────────────────────────────────────────── + + private string ShowMainMenu() + { + var prompt = new SelectionPrompt() + .Title($"[{Theme.Green}]What would you like to do?[/]") + .PageSize(28) + .MoreChoicesText(Theme.Faint("(scroll to see more)")) + .UseConverter(m => m.Display); + + // Targets, grouped + var groups = _config.Targets + .Where(t => !string.IsNullOrWhiteSpace(t.Label)) + .GroupBy(t => string.IsNullOrWhiteSpace(t.Group) ? "General" : t.Group); + + foreach (var group in groups) + { + var header = new MenuItem( + $"[bold {Theme.Amber}]{Markup.Escape(group.Key.ToUpperInvariant())}[/]", + "__group__"); + + var items = group.Select(t => new MenuItem( + $"[{Theme.Green}]{Markup.Escape(t.Label)}[/] [{Theme.GreenDim}]{Markup.Escape(t.Description)}[/]", + t.Id)).ToList(); + + prompt.AddChoiceGroup(header, items); + } + + // System actions + var systemItems = new List + { + new($"[{Theme.Green}]⚙ Edit environment variables[/]", "__env__"), + }; + + if (_config.Toolchains is not null) + systemItems.Insert(0, new MenuItem( + $"[{Theme.Green}]⬡ Toolchain management[/] [{Theme.GreenDim}]python / node[/]", + "__toolchains__")); + + if (_workspace is not null && (_workspace.Projects.Count > 1)) + systemItems.Insert(0, new MenuItem( + $"[{Theme.Green}]⇄ Switch project[/] [{Theme.GreenDim}]{Markup.Escape(_workspace.Name)}[/]", + "__workspace__")); + + systemItems.Add(new MenuItem($"[{Theme.GreenDim}]✗ Quit[/]", "__quit__")); + + prompt.AddChoiceGroup( + new MenuItem($"[bold {Theme.Amber}]SYSTEM[/]", "__group__"), + systemItems); + + return AnsiConsole.Prompt(prompt).Value; + } + + // ── Target execution ────────────────────────────────────────────────────── + + private async Task RunTargetAsync(BuildTarget target, IReadOnlyDictionary targetMap) + { + AnsiConsole.Clear(); + AnsiConsole.Write(Theme.SectionRule(target.Label)); + + var plan = TargetRunner.ResolvePlan(target, targetMap); + + if (plan.Count == 0) + { + AnsiConsole.MarkupLine(Theme.Warn("This target has no executable steps.")); + return; + } + + if (plan.Count > 1) + { + AnsiConsole.MarkupLine(Theme.Faint($"Execution plan — {plan.Count} steps:")); + foreach (var step in plan) + AnsiConsole.MarkupLine($" [{Theme.GreenDim}]→[/] [{Theme.Green}]{Markup.Escape(step.Label)}[/]"); + AnsiConsole.WriteLine(); + } + + var allOk = true; + var totalSw = System.Diagnostics.Stopwatch.StartNew(); + + foreach (var step in plan) + { + AnsiConsole.Write(Theme.DimRule()); + + var workingDir = Path.GetFullPath(Path.Combine(_projectRoot, step.WorkingDir)); + var cmdDisplay = $"{step.Command} {string.Join(" ", step.Args)}"; + + AnsiConsole.MarkupLine($"[{Theme.GreenDim}]$ {Markup.Escape(cmdDisplay)}[/]"); + AnsiConsole.MarkupLine($"[{Theme.GreenDim}] {Markup.Escape(workingDir)}[/]\n"); + + RunResult result; + try + { + result = await ProcessRunner.RunAsync( + step.Command!, + step.Args, + workingDir, + (line, isErr) => + { + var escaped = Markup.Escape(line); + AnsiConsole.MarkupLine(isErr + ? $"[{Theme.Amber}]{escaped}[/]" + : $"[{Theme.Green}]{escaped}[/]"); + }); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine("\n" + Theme.Fail($"Failed to launch: {ex.Message}")); + allOk = false; + break; + } + + AnsiConsole.WriteLine(); + + if (result.Success) + AnsiConsole.MarkupLine(Theme.Ok($"{step.Label} ({result.Elapsed.TotalSeconds:F1}s)")); + else + { + AnsiConsole.MarkupLine(Theme.Fail($"{step.Label} — exited {result.ExitCode} ({result.Elapsed.TotalSeconds:F1}s)")); + allOk = false; + break; + } + } + + totalSw.Stop(); + AnsiConsole.Write(Theme.SectionRule()); + AnsiConsole.MarkupLine(allOk + ? "\n" + Theme.Ok($"Done! Total: {totalSw.Elapsed.TotalSeconds:F1}s") + : "\n" + Theme.Fail("Build failed. Check output above.")); + } + + // ── Environment editor ──────────────────────────────────────────────────── + + private void EditEnvironment() + { + AnsiConsole.Clear(); + + if (_config.Env.Count == 0) + { + AnsiConsole.MarkupLine(Theme.Warn("No environment variables defined in devtool.json.")); + AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back...")); + Console.ReadKey(intercept: true); + return; + } + + while (true) + { + AnsiConsole.Write(Theme.SectionRule("ENVIRONMENT")); + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderStyle(Theme.DimStyle) + .AddColumn(new TableColumn($"[{Theme.Amber}]Variable[/]")) + .AddColumn(new TableColumn($"[{Theme.Amber}]Current Value[/]")) + .AddColumn(new TableColumn($"[{Theme.Amber}]Description[/]")); + + foreach (var def in _config.Env) + { + var val = Environment.GetEnvironmentVariable(def.Key) ?? def.DefaultValue; + table.AddRow( + Theme.Warn(def.Key), + Theme.Bold(val.Length > 0 ? val : "(not set)"), + Theme.Faint(def.Description)); + } + + AnsiConsole.Write(table); + AnsiConsole.MarkupLine(Theme.Faint("Changes apply to this SDT session only.\n")); + + var choices = _config.Env + .Select(e => + { + var curr = Environment.GetEnvironmentVariable(e.Key) ?? e.DefaultValue; + return new MenuItem( + $"[{Theme.Amber}]{Markup.Escape(e.Key)}[/] [{Theme.GreenDim}]= {Markup.Escape(curr)}[/]", + e.Key); + }) + .Append(new MenuItem(Theme.Faint("← Back"), "__back__")) + .ToList(); + + var selected = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"[{Theme.Green}]Select a variable to edit:[/]") + .UseConverter(m => m.Display) + .AddChoices(choices)); + + if (selected.Value == "__back__") break; + + var envDef = _config.Env.First(e => e.Key == selected.Value); + var current = Environment.GetEnvironmentVariable(envDef.Key) ?? envDef.DefaultValue; + + string newVal; + if (envDef.Options.Count > 0) + { + newVal = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"[{Theme.Amber}]{Markup.Escape(envDef.Key)}[/] [{Theme.GreenDim}]current: {Markup.Escape(current)}[/]") + .AddChoices(envDef.Options)); + } + else + { + newVal = AnsiConsole.Ask( + $"[{Theme.Amber}]{Markup.Escape(envDef.Key)}[/]", current); + } + + Environment.SetEnvironmentVariable(envDef.Key, newVal); + AnsiConsole.MarkupLine("\n" + Theme.Ok($"{envDef.Key} = {newVal}") + "\n"); + Thread.Sleep(500); + AnsiConsole.Clear(); + } + } +} diff --git a/Journal.DevTool/Tui/Theme.cs b/Journal.DevTool/Tui/Theme.cs new file mode 100644 index 0000000..12afd42 --- /dev/null +++ b/Journal.DevTool/Tui/Theme.cs @@ -0,0 +1,54 @@ +using Spectre.Console; + +namespace Sdt.Tui; + +/// +/// SDT phosphor-green colour palette. +/// Primary text is classic terminal phosphor (#00FF41). +/// Modern accent colours are kept for highlights and status. +/// +internal static class Theme +{ + // ── Hex colour constants (use in Spectre markup strings) ───────────────── + public const string Green = "#00ff41"; // primary phosphor — all normal text + public const string GreenDim = "#005c1b"; // muted — borders, secondary info + public const string GreenBold = "#a8ff90"; // bright — selections, emphasis + public const string Amber = "#ffb300"; // warnings / group titles + public const string Red = "#ff4040"; // errors + public const string Ghost = "#003d12"; // near-invisible — decorative scanlines + + // ── Spectre Color instances (for FigletText, Rule styles, etc.) ────────── + public static readonly Color GreenColor = new(0, 255, 65); + public static readonly Color GreenDimColor = new(0, 92, 27); + public static readonly Color GreenBoldColor = new(168, 255, 144); + public static readonly Color AmberColor = new(255, 179, 0); + public static readonly Color RedColor = new(255, 64, 64); + + // ── Pre-built Style objects ─────────────────────────────────────────────── + public static readonly Style PrimaryStyle = new(GreenColor); + public static readonly Style DimStyle = new(GreenDimColor); + public static readonly Style BrightStyle = new(GreenBoldColor, decoration: Decoration.Bold); + public static readonly Style AmberStyle = new(AmberColor); + public static readonly Style RedStyle = new(RedColor, decoration: Decoration.Bold); + + // ── Markup helper methods (auto-escape user content) ───────────────────── + public static string G(string t) => $"[{Green}]{Markup.Escape(t)}[/]"; + public static string Faint(string t) => $"[{GreenDim}]{Markup.Escape(t)}[/]"; + public static string Bold(string t) => $"[bold {GreenBold}]{Markup.Escape(t)}[/]"; + public static string Warn(string t) => $"[{Amber}]{Markup.Escape(t)}[/]"; + public static string Err(string t) => $"[bold {Red}]{Markup.Escape(t)}[/]"; + public static string Ok(string t) => $"[bold {Green}]✓ {Markup.Escape(t)}[/]"; + public static string Fail(string t) => $"[bold {Red}]✗ {Markup.Escape(t)}[/]"; + + // ── Shared UI components ────────────────────────────────────────────────── + public static Rule SectionRule(string? title = null) => title is null + ? new Rule().RuleStyle(DimStyle) + : new Rule($"[bold {GreenBold}]{Markup.Escape(title)}[/]").RuleStyle(DimStyle); + + public static Rule DimRule() => new Rule().RuleStyle(new Style(new Color(0, 40, 12))); + + public static Panel StatusPanel(string markup) => + new Panel(markup) + .BorderStyle(DimStyle) + .Padding(1, 0); +} diff --git a/Journal.DevTool/Tui/ToolchainScreen.cs b/Journal.DevTool/Tui/ToolchainScreen.cs new file mode 100644 index 0000000..cb3db4d --- /dev/null +++ b/Journal.DevTool/Tui/ToolchainScreen.cs @@ -0,0 +1,319 @@ +using Sdt.Config; +using Sdt.Runner; +using Sdt.Tui; +using Spectre.Console; + +namespace Sdt.Tui; + +public sealed class ToolchainScreen +{ + private readonly DevToolConfig _config; + private readonly string _projectRoot; + + public ToolchainScreen(DevToolConfig config, string projectRoot) + { + _config = config; + _projectRoot = projectRoot; + } + + public async Task RunAsync() + { + while (true) + { + AnsiConsole.Clear(); + AnsiConsole.Write(Theme.SectionRule("TOOLCHAINS")); + AnsiConsole.WriteLine(); + + var tc = _config.Toolchains; + if (tc is null) + { + AnsiConsole.MarkupLine(Theme.Warn("No toolchains configured in devtool.json.")); + AnsiConsole.MarkupLine(Theme.Faint("Add a \"toolchains\" section with \"python\" and/or \"node\" entries.")); + AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back...")); + Console.ReadKey(intercept: true); + return; + } + + // Build menu from available toolchains + var choices = new List(); + + if (tc.Python is not null) + { + choices.Add(new MenuItem($"[bold {Theme.GreenBold}]PYTHON[/]", "__group__")); + choices.Add(new MenuItem($"{Theme.G("Check environment")} {Theme.Faint("detect python, venv, pip")}", "py:check")); + choices.Add(new MenuItem($"{Theme.G("Create / recreate venv")} {Theme.Faint($"python -m venv {tc.Python.VenvDir}")}", "py:venv")); + if (tc.Python.Profiles.Count > 0) + choices.Add(new MenuItem($"{Theme.G("Install requirements profile")} {Theme.Faint("select cpu / gpu / nlp...")}", "py:install")); + choices.Add(new MenuItem($"{Theme.G("Upgrade pip")} {Theme.Faint("pip install --upgrade pip")}", "py:upgradepip")); + } + + if (tc.Node is not null) + { + choices.Add(new MenuItem($"[bold {Theme.GreenBold}]NODE / NPM[/]", "__group__")); + choices.Add(new MenuItem($"{Theme.G("Check environment")} {Theme.Faint("detect node, npm, node_modules")}", "node:check")); + choices.Add(new MenuItem($"{Theme.G($"{tc.Node.PackageManager} install")} {Theme.Faint($"in {tc.Node.WorkingDir}")}", "node:install")); + } + + choices.Add(new MenuItem($"[bold {Theme.GreenBold}]──[/]", "__group__")); + choices.Add(new MenuItem(Theme.Faint("← Back"), "__back__")); + + var prompt = new SelectionPrompt() + .Title($"[{Theme.Green}]Select a toolchain action:[/]") + .PageSize(20) + .UseConverter(m => m.Display) + .AddChoices(choices); + + var selected = AnsiConsole.Prompt(prompt); + if (selected.Value == "__back__" || selected.Value == "__group__") return; + + AnsiConsole.Clear(); + AnsiConsole.Write(Theme.SectionRule(selected.Value.Split(':')[0].ToUpperInvariant() + " › " + selected.Value.Split(':')[1])); + AnsiConsole.WriteLine(); + + await HandleActionAsync(selected.Value, tc); + + AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to continue...")); + Console.ReadKey(intercept: true); + } + } + + // ── Actions ─────────────────────────────────────────────────────────────── + + private async Task HandleActionAsync(string action, ToolchainConfig tc) + { + switch (action) + { + case "py:check": await CheckPythonAsync(tc.Python!); break; + case "py:venv": await CreateVenvAsync(tc.Python!); break; + case "py:install": await InstallProfileAsync(tc.Python!); break; + case "py:upgradepip": await UpgradePipAsync(tc.Python!); break; + case "node:check": await CheckNodeAsync(tc.Node!); break; + case "node:install": await NodeInstallAsync(tc.Node!); break; + } + } + + // ── Python ──────────────────────────────────────────────────────────────── + + private async Task CheckPythonAsync(PythonToolchain py) + { + var exe = ResolvePythonExe(py); + var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, py.VenvDir)); + var venvPython = GetVenvPython(venvPath); + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderStyle(Theme.DimStyle) + .AddColumn(new TableColumn($"[{Theme.Amber}]Check[/]").Width(24)) + .AddColumn(new TableColumn($"[{Theme.GreenBold}]Result[/]")); + + // System Python + var pyVersion = await ProbeAsync(exe, "--version"); + table.AddRow(Theme.G("System Python"), pyVersion is not null + ? Theme.Ok(pyVersion.Trim()) + : Theme.Fail($"{exe} not found")); + + // Venv exists? + table.AddRow(Theme.G($"Venv ({py.VenvDir})"), Directory.Exists(venvPath) + ? Theme.Ok("exists " + venvPath) + : Theme.Warn("not found — use 'Create venv'")); + + // Venv Python + if (File.Exists(venvPython)) + { + var venvVersion = await ProbeAsync(venvPython, "--version"); + table.AddRow(Theme.G("Venv Python"), venvVersion is not null + ? Theme.Ok(venvVersion.Trim()) + : Theme.Fail("could not launch")); + } + + // Pip in venv + if (File.Exists(venvPython)) + { + var pipVersion = await ProbeAsync(venvPython, "-m", "pip", "--version"); + table.AddRow(Theme.G("Pip (venv)"), pipVersion is not null + ? Theme.Ok(pipVersion.Trim()) + : Theme.Fail("pip not available")); + } + + AnsiConsole.Write(table); + } + + private async Task CreateVenvAsync(PythonToolchain py) + { + var exe = ResolvePythonExe(py); + var venvDir = py.VenvDir; + var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, venvDir)); + + if (Directory.Exists(venvPath)) + { + var overwrite = AnsiConsole.Confirm( + $"[{Theme.Amber}]Venv already exists at {venvDir}. Recreate it?[/]", defaultValue: false); + if (!overwrite) return; + Directory.Delete(venvPath, recursive: true); + } + + AnsiConsole.MarkupLine(Theme.G($"Creating venv: {exe} -m venv {venvDir}")); + AnsiConsole.WriteLine(); + + await RunLiveAsync(exe, ["-m", "venv", venvDir], _projectRoot); + } + + private async Task InstallProfileAsync(PythonToolchain py) + { + var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, py.VenvDir)); + var venvPy = GetVenvPython(venvPath); + + if (!File.Exists(venvPy)) + { + AnsiConsole.MarkupLine(Theme.Warn("Venv not found. Create it first.")); + return; + } + + var profile = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"[{Theme.Green}]Select requirements profile:[/]") + .UseConverter(p => $"{Theme.Bold(p.Label)} {Theme.Faint(p.RequirementsFile)}") + .AddChoices(py.Profiles)); + + var reqFile = Path.GetFullPath(Path.Combine(_projectRoot, profile.RequirementsFile)); + if (!File.Exists(reqFile)) + { + AnsiConsole.MarkupLine(Theme.Fail($"Requirements file not found: {reqFile}")); + return; + } + + // Upgrade pip first + AnsiConsole.MarkupLine(Theme.Faint("Upgrading pip...")); + await RunLiveAsync(venvPy, ["-m", "pip", "install", "--upgrade", "pip"], _projectRoot); + + // Build install args + var installArgs = new List { "-m", "pip", "install" }; + if (!string.IsNullOrWhiteSpace(profile.ExtraIndexUrl)) + { + installArgs.Add("--extra-index-url"); + installArgs.Add(profile.ExtraIndexUrl); + } + installArgs.Add("-r"); + installArgs.Add(reqFile); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(Theme.G($"Installing {profile.Label}...")); + AnsiConsole.WriteLine(); + await RunLiveAsync(venvPy, installArgs, _projectRoot); + + // Post-install commands + foreach (var cmd in profile.PostInstallCommands) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(Theme.Faint($"Post-install: {cmd}")); + var parts = cmd.Split(' ', 2); + var postArgs = parts.Length > 1 ? parts[1].Split(' ') : Array.Empty(); + await RunLiveAsync(venvPy, ["-m", ..postArgs], _projectRoot); + } + } + + private async Task UpgradePipAsync(PythonToolchain py) + { + var venvPath = Path.GetFullPath(Path.Combine(_projectRoot, py.VenvDir)); + var venvPy = GetVenvPython(venvPath); + var exe = File.Exists(venvPy) ? venvPy : ResolvePythonExe(py); + + AnsiConsole.MarkupLine(Theme.G($"Upgrading pip using: {exe}")); + AnsiConsole.WriteLine(); + await RunLiveAsync(exe, ["-m", "pip", "install", "--upgrade", "pip"], _projectRoot); + } + + // ── Node ────────────────────────────────────────────────────────────────── + + private async Task CheckNodeAsync(NodeToolchain node) + { + var nodeModules = Path.GetFullPath( + Path.Combine(_projectRoot, node.WorkingDir, "node_modules")); + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderStyle(Theme.DimStyle) + .AddColumn(new TableColumn($"[{Theme.Amber}]Check[/]").Width(24)) + .AddColumn(new TableColumn($"[{Theme.GreenBold}]Result[/]")); + + var nodeVersion = await ProbeAsync("node", "--version"); + table.AddRow(Theme.G("Node.js"), nodeVersion is not null + ? Theme.Ok(nodeVersion.Trim()) + : Theme.Fail("node not found in PATH")); + + var npmVersion = await ProbeAsync(node.PackageManager, "--version"); + table.AddRow(Theme.G(node.PackageManager), npmVersion is not null + ? Theme.Ok(npmVersion.Trim()) + : Theme.Fail($"{node.PackageManager} not found in PATH")); + + table.AddRow(Theme.G("node_modules"), Directory.Exists(nodeModules) + ? Theme.Ok("exists") + : Theme.Warn($"not found — run {node.PackageManager} install")); + + AnsiConsole.Write(table); + } + + private async Task NodeInstallAsync(NodeToolchain node) + { + var workDir = Path.GetFullPath(Path.Combine(_projectRoot, node.WorkingDir)); + AnsiConsole.MarkupLine(Theme.G($"{node.PackageManager} install ({workDir})")); + AnsiConsole.WriteLine(); + await RunLiveAsync(node.PackageManager, ["install"], workDir); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private string ResolvePythonExe(PythonToolchain py) + { + if (OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(py.WindowsExecutable)) + return py.WindowsExecutable; + return py.Executable; + } + + private static string GetVenvPython(string venvPath) + { + // Windows: .venv\Scripts\python.exe | Linux/Mac: .venv/bin/python + return OperatingSystem.IsWindows() + ? Path.Combine(venvPath, "Scripts", "python.exe") + : Path.Combine(venvPath, "bin", "python"); + } + + private static async Task ProbeAsync(string command, params string[] args) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = command, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + + using var p = new System.Diagnostics.Process { StartInfo = psi }; + p.Start(); + var output = await p.StandardOutput.ReadToEndAsync(); + var err = await p.StandardError.ReadToEndAsync(); + await p.WaitForExitAsync(); + return p.ExitCode == 0 ? (output + err) : null; + } + catch { return null; } + } + + private static async Task RunLiveAsync(string command, IEnumerable args, string workingDir) + { + var result = await ProcessRunner.RunAsync( + command, args, workingDir, + (line, isErr) => AnsiConsole.MarkupLine( + isErr + ? $"[{Theme.Amber}]{Markup.Escape(line)}[/]" + : $"[{Theme.Green}]{Markup.Escape(line)}[/]")); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(result.Success + ? Theme.Ok($"Done ({result.Elapsed.TotalSeconds:F1}s)") + : Theme.Fail($"Exited {result.ExitCode} ({result.Elapsed.TotalSeconds:F1}s)")); + } +} diff --git a/Journal.DevTool/Tui/WorkspaceScreen.cs b/Journal.DevTool/Tui/WorkspaceScreen.cs new file mode 100644 index 0000000..a7c694c --- /dev/null +++ b/Journal.DevTool/Tui/WorkspaceScreen.cs @@ -0,0 +1,108 @@ +using Sdt.Config; +using Spectre.Console; + +namespace Sdt.Tui; + +public sealed class WorkspaceScreen +{ + private readonly WorkspaceConfig _workspace; + private readonly string _workspaceRoot; + private readonly string _currentProjectRoot; + + public WorkspaceScreen(WorkspaceConfig workspace, string workspaceRoot, string currentProjectRoot) + { + _workspace = workspace; + _workspaceRoot = workspaceRoot; + _currentProjectRoot = currentProjectRoot; + } + + /// + /// Shows the project switcher. Returns the absolute path to the selected project root, + /// or null if the user cancelled. + /// + public string? SelectProject() + { + AnsiConsole.Clear(); + AnsiConsole.Write(Theme.SectionRule("WORKSPACE — " + _workspace.Name)); + AnsiConsole.WriteLine(); + + var projects = _workspace.Projects; + if (projects.Count == 0) + { + AnsiConsole.MarkupLine(Theme.Warn("No projects defined in sdt-workspace.json.")); + AnsiConsole.MarkupLine(Theme.Faint("Add entries to the \"projects\" array.")); + AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back...")); + Console.ReadKey(intercept: true); + return null; + } + + // Build choice list with current project marked + var choices = new List(); + foreach (var proj in projects) + { + var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj); + var devtoolPath = Path.Combine(absPath, "devtool.json"); + var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase); + var exists = File.Exists(devtoolPath); + + var label = isCurrent + ? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/] [{Theme.GreenDim}](current)[/]" + : $"[{Theme.Green}] {Markup.Escape(proj.Name)}[/]"; + + var desc = !exists + ? $" [{Theme.Red}]devtool.json not found[/]" + : string.IsNullOrWhiteSpace(proj.Description) + ? $" [{Theme.GreenDim}]{Markup.Escape(absPath)}[/]" + : $" [{Theme.GreenDim}]{Markup.Escape(proj.Description)}[/]"; + + choices.Add(new WorkspaceMenuItem(label + "\n" + desc, absPath, exists && !isCurrent)); + } + + choices.Add(new WorkspaceMenuItem($"[{Theme.GreenDim}]← Cancel[/]", null, true)); + + // Show project table for overview + var table = new Table() + .Border(TableBorder.Rounded) + .BorderStyle(Theme.DimStyle) + .AddColumn(new TableColumn($"[{Theme.Amber}]Project[/]")) + .AddColumn(new TableColumn($"[{Theme.Amber}]Path[/]")) + .AddColumn(new TableColumn($"[{Theme.Amber}]Status[/]").Width(12)); + + foreach (var proj in projects) + { + var absPath = WorkspaceLoader.ResolveProjectRoot(_workspaceRoot, proj); + var isCurrent = string.Equals(absPath, _currentProjectRoot, StringComparison.OrdinalIgnoreCase); + var hasConfig = File.Exists(Path.Combine(absPath, "devtool.json")); + + table.AddRow( + isCurrent + ? $"[bold {Theme.GreenBold}]► {Markup.Escape(proj.Name)}[/]" + : Theme.G(proj.Name), + Theme.Faint(proj.Path), + hasConfig ? Theme.Ok("ready") : Theme.Fail("no config")); + } + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + + var switchable = choices.Where(c => c.Selectable).ToList(); + if (switchable.Count == 1) // only Cancel + { + AnsiConsole.MarkupLine(Theme.Warn("No other projects available to switch to.")); + AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to go back...")); + Console.ReadKey(intercept: true); + return null; + } + + var selected = AnsiConsole.Prompt( + new SelectionPrompt() + .Title($"[{Theme.Green}]Switch to project:[/]") + .PageSize(15) + .UseConverter(m => m.Display) + .AddChoices(switchable)); + + return selected.AbsPath; // null = cancelled + } + + private sealed record WorkspaceMenuItem(string Display, string? AbsPath, bool Selectable); +} diff --git a/Journal.slnx b/Journal.slnx index dfef454..83e23b7 100644 --- a/Journal.slnx +++ b/Journal.slnx @@ -2,4 +2,5 @@ + diff --git a/devtool.json b/devtool.json new file mode 100644 index 0000000..f3b888a --- /dev/null +++ b/devtool.json @@ -0,0 +1,301 @@ +{ + "name": "Project Journal", + "version": "0.1.0", + "toolchains": { + "python": { + "executable": "python3.14", + "windowsExecutable": "py", + "launcherVersion": "-3.14", + "venvDir": ".venv", + "pipScript": "scripts/pip-min.ps1", + "profiles": [ + { + "id": "cpu", + "label": "CPU only (default)", + "requirementsFile": "requirements_cpu_only.txt", + "extraIndexUrl": "https://download.pytorch.org/whl/cpu" + }, + { + "id": "gpu", + "label": "GPU / CUDA", + "requirementsFile": "requirements_gpu.txt" + }, + { + "id": "nlp", + "label": "NLP / spaCy (optional)", + "requirementsFile": "requirements_nlp_optional.txt", + "postInstallCommands": [ + "spacy download en_core_web_sm" + ] + } + ] + }, + "node": { + "packageManager": "npm", + "workingDir": "Journal.App" + } + }, + "targets": [ + { + "id": "sidecar", + "label": "Publish Sidecar", + "description": "Build Journal.Sidecar as self-contained exe → output/", + "group": "Build", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-sidecar.ps1", + "-Configuration", + "Release", + "-Runtime", + "win-x64" + ], + "workingDir": ".", + "dependsOn": [] + }, + { + "id": "web", + "label": "Build Web UI", + "description": "Build SvelteKit bundle → Journal.App/build/", + "group": "Build", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "web" + ], + "workingDir": ".", + "dependsOn": [] + }, + { + "id": "webgateway", + "label": "Publish WebGateway", + "description": "Publish ASP.NET host with embedded web UI → output/webgateway/", + "group": "Build", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-webgateway.ps1", + "-Configuration", + "Release", + "-Runtime", + "win-x64" + ], + "workingDir": ".", + "dependsOn": [ + "web" + ] + }, + { + "id": "tauri", + "label": "Build Tauri Desktop App", + "description": "Build desktop exe (no installer) → Journal.App/src-tauri/target/release/", + "group": "Build", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "tauri", + "-TauriBundles", + "none" + ], + "workingDir": ".", + "dependsOn": [ + "sidecar" + ] + }, + { + "id": "tauri-nsis", + "label": "Build Tauri + NSIS Installer", + "description": "Build desktop exe with NSIS installer package", + "group": "Build", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/publish-app.ps1", + "-Target", + "tauri", + "-TauriBundles", + "nsis" + ], + "workingDir": ".", + "dependsOn": [ + "sidecar" + ] + }, + { + "id": "build-dotnet", + "label": "Build .NET Projects", + "description": "dotnet build — all C# projects in solution", + "group": "Build", + "command": "dotnet", + "args": [ + "build" + ], + "workingDir": ".", + "dependsOn": [] + }, + { + "id": "all", + "label": "Full Release Build ✦", + "description": "Sidecar → Web → WebGateway → Tauri, in dependency order", + "group": "Build", + "command": null, + "args": [], + "workingDir": ".", + "dependsOn": [ + "sidecar", + "web", + "webgateway", + "tauri" + ] + }, + { + "id": "run-gateway", + "label": "Run WebGateway", + "description": "Start HTTP gateway dev server at http://localhost:5180", + "group": "Dev", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/run-webgateway.ps1" + ], + "workingDir": ".", + "dependsOn": [] + }, + { + "id": "test", + "label": "Run Smoke Tests", + "description": "Run all ~80 integration tests in Journal.SmokeTests", + "group": "Test", + "command": "dotnet", + "args": [ + "run", + "--project", + "Journal.SmokeTests/Journal.SmokeTests.csproj" + ], + "workingDir": ".", + "dependsOn": [] + }, + { + "id": "gate", + "label": "Run Migration Gate", + "description": "Full build + smoke tests + parity check", + "group": "Test", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/migration-gate.ps1" + ], + "workingDir": ".", + "dependsOn": [] + }, + { + "id": "nuget-export", + "label": "Export NuGet Cache", + "description": "Prime and export .nuget cache to zip for offline use", + "group": "Cache", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/nuget-export-cache.ps1" + ], + "workingDir": ".", + "dependsOn": [] + }, + { + "id": "nuget-import", + "label": "Import NuGet Cache", + "description": "Import cache zip and validate restore", + "group": "Cache", + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "scripts/nuget-import-cache.ps1" + ], + "workingDir": ".", + "dependsOn": [] + } + ], + "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": [] + } + ] +} \ No newline at end of file diff --git a/justfile b/justfile new file mode 100644 index 0000000..151bb9f --- /dev/null +++ b/justfile @@ -0,0 +1,105 @@ +# SDT — Project Journal Justfile +# Install just: https://just.systems/man/en/packages.html + +set windows-shell := ["pwsh", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"] +set shell := ["pwsh", "-c"] + +# Detect runtime from OS +runtime := if os() == "windows" { "win-x64" } else { "linux-x64" } + +# ── Default: list available recipes ──────────────────────────────────────────── +default: + @just --list + +# ── Build ────────────────────────────────────────────────────────────────────── + +# Build Journal.Sidecar as self-contained single-file exe +sidecar: + & ./scripts/publish-sidecar.ps1 -Configuration Release -Runtime {{runtime}} + +# Build SvelteKit web bundle (output: Journal.App/build/) +web: + & ./scripts/publish-app.ps1 -Target web + +# Publish WebGateway with embedded web UI (depends: web) +webgateway: web + & ./scripts/publish-webgateway.ps1 -Configuration Release -Runtime {{runtime}} + +# Build Tauri desktop exe — no installer (depends: sidecar) +tauri: sidecar + & ./scripts/publish-app.ps1 -Target tauri -TauriBundles none + +# Build Tauri with NSIS installer (depends: sidecar) +tauri-nsis: sidecar + & ./scripts/publish-app.ps1 -Target tauri -TauriBundles nsis + +# Build Tauri with MSI installer (depends: sidecar) +tauri-msi: sidecar + & ./scripts/publish-app.ps1 -Target tauri -TauriBundles msi + +# Full release build — everything in correct order +all: sidecar web webgateway tauri + +# ── Dev ──────────────────────────────────────────────────────────────────────── + +# Run WebGateway dev server (http://localhost:5180) +run: + & ./scripts/run-webgateway.ps1 + +# Run WebGateway with pinned project root (avoids multi-clone ambiguity) +run-pinned: + & ./scripts/run-webgateway.ps1 -ProjectRoot {{justfile_directory()}} -Urls http://0.0.0.0:5180 + +# SvelteKit dev server only (http://localhost:1420) +dev-app: + Set-Location Journal.App; npm run dev + +# Tauri dev mode (desktop window + hot reload) +dev-tauri: sidecar + Set-Location Journal.App; npm run tauri dev + +# ── Test ─────────────────────────────────────────────────────────────────────── + +# Run all smoke tests (~80 integration tests) +test: + dotnet run --project Journal.SmokeTests/Journal.SmokeTests.csproj + +# Full migration gate (build + smoke + parity) +gate: + & ./scripts/migration-gate.ps1 + +# Migration gate — skip smoke tests +gate-fast: + & ./scripts/migration-gate.ps1 -SkipSmoke + +# Migration gate — skip API contract tests +gate-no-api: + & ./scripts/migration-gate.ps1 -SkipApi + +# ── .NET ─────────────────────────────────────────────────────────────────────── + +# dotnet build all projects +build: + dotnet build + +# dotnet build with resilient NuGet defaults (use in restricted environments) +build-safe: + & ./scripts/dotnet-min.ps1 build Journal.Core/Journal.Core.csproj + & ./scripts/dotnet-min.ps1 build Journal.Sidecar/Journal.Sidecar.csproj + & ./scripts/dotnet-min.ps1 build Journal.WebGateway/Journal.WebGateway.csproj + +# ── NuGet Cache ──────────────────────────────────────────────────────────────── + +# Export NuGet cache to zip for offline/transfer use +nuget-export zip="nuget-cache-export.zip": + & ./scripts/nuget-export-cache.ps1 -OutputZip {{zip}} + +# Import NuGet cache zip and validate restore +nuget-import zip="nuget-cache-export.zip": + & ./scripts/nuget-import-cache.ps1 -InputZip {{zip}} + +# ── SDT ──────────────────────────────────────────────────────────────────────── + +# Launch SDT dev tool TUI +sdt: + dotnet run --project Journal.DevTool/Journal.DevTool.csproj