feat(sdt): add Journal.DevTool TUI, devtool.json, and justfile
- Journal.DevTool: C# TUI dev tool (SDT) using Spectre.Console - Phosphor green (#00FF41) terminal aesthetic with amber/red accents - Grouped build target menu driven by devtool.json config - Topological dependency resolver for DependsOn chains - Live stdout/stderr streaming per target step - Interactive environment variable editor (dropdown + free-text) - Toolchain management screen: Python venv create/install/upgrade, Node npm install - Workspace switcher: reads sdt-workspace.json, hot-switches between projects - Outputs as 'sdt' executable (net10.0) - devtool.json: project config for SDT - All build targets: sidecar, web, webgateway, tauri, tauri-nsis, all (virtual) - Dev targets: run-gateway - Test targets: test (smoke), gate (migration) - Cache targets: nuget-export, nuget-import - Toolchains: Python 3.14 (cpu/gpu/nlp profiles) + Node/npm (Journal.App) - Env vars: AI provider, log level, NLP backend, path overrides - justfile: just command runner recipes wrapping existing scripts - Correct dependency ordering (sidecar before tauri, web before webgateway) - OS-aware runtime detection (win-x64 / linux-x64) - Recipes: sidecar, web, webgateway, tauri, tauri-nsis, all, run, dev-app, test, gate, build, nuget-export/import, sdt - Journal.slnx: added Journal.DevTool project
This commit is contained in:
parent
ea92c2e850
commit
5b383858ae
44
Journal.DevTool/Config/ConfigLoader.cs
Normal file
44
Journal.DevTool/Config/ConfigLoader.cs
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks up from <paramref name="startDir"/> (or CWD) until it finds devtool.json.
|
||||||
|
/// Returns null if not found.
|
||||||
|
/// </summary>
|
||||||
|
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<DevToolConfig>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
Journal.DevTool/Config/DevToolConfig.cs
Normal file
86
Journal.DevTool/Config/DevToolConfig.cs
Normal file
@ -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<BuildTarget> Targets { get; init; } = [];
|
||||||
|
public List<EnvVarDef> 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";
|
||||||
|
|
||||||
|
/// <summary>Executable name. Null = virtual aggregator (runs DependsOn only).</summary>
|
||||||
|
public string? Command { get; init; }
|
||||||
|
|
||||||
|
public List<string> Args { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Working directory relative to project root.</summary>
|
||||||
|
public string WorkingDir { get; init; } = ".";
|
||||||
|
|
||||||
|
public List<string> 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; } = "";
|
||||||
|
|
||||||
|
/// <summary>If non-empty, shown as a dropdown. Otherwise free-text input.</summary>
|
||||||
|
public List<string> Options { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toolchain config ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public sealed class ToolchainConfig
|
||||||
|
{
|
||||||
|
public PythonToolchain? Python { get; init; }
|
||||||
|
public NodeToolchain? Node { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PythonToolchain
|
||||||
|
{
|
||||||
|
/// <summary>Python executable (e.g. "python3.14", "python").</summary>
|
||||||
|
public string Executable { get; init; } = "python";
|
||||||
|
|
||||||
|
/// <summary>Windows-specific override (e.g. "py" when using the launcher).</summary>
|
||||||
|
public string? WindowsExecutable { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optional version flag to pass (e.g. "-3.14" for py launcher).</summary>
|
||||||
|
public string? LauncherVersion { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Venv directory relative to project root.</summary>
|
||||||
|
public string VenvDir { get; init; } = ".venv";
|
||||||
|
|
||||||
|
public List<PythonProfile> Profiles { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Optional path to a pip wrapper script (relative to project root).</summary>
|
||||||
|
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<string> PostInstallCommands { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NodeToolchain
|
||||||
|
{
|
||||||
|
/// <summary>Package manager: "npm", "pnpm", or "yarn".</summary>
|
||||||
|
public string PackageManager { get; init; } = "npm";
|
||||||
|
|
||||||
|
/// <summary>Working directory for the frontend (relative to project root).</summary>
|
||||||
|
public string WorkingDir { get; init; } = ".";
|
||||||
|
}
|
||||||
19
Journal.DevTool/Config/WorkspaceConfig.cs
Normal file
19
Journal.DevTool/Config/WorkspaceConfig.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
namespace Sdt.Config;
|
||||||
|
|
||||||
|
public sealed class WorkspaceConfig
|
||||||
|
{
|
||||||
|
public string Name { get; init; } = "SDT Workspace";
|
||||||
|
public List<WorkspaceProject> Projects { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WorkspaceProject
|
||||||
|
{
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public string Description { get; init; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relative path from the sdt-workspace.json directory to the project root
|
||||||
|
/// (the directory containing devtool.json).
|
||||||
|
/// </summary>
|
||||||
|
public string Path { get; init; } = "";
|
||||||
|
}
|
||||||
52
Journal.DevTool/Config/WorkspaceLoader.cs
Normal file
52
Journal.DevTool/Config/WorkspaceLoader.cs
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks up from <paramref name="startDir"/> (or CWD) to find sdt-workspace.json.
|
||||||
|
/// Returns null if not found.
|
||||||
|
/// </summary>
|
||||||
|
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<WorkspaceConfig>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the absolute project root for a workspace project entry.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResolveProjectRoot(string workspaceRoot, WorkspaceProject project)
|
||||||
|
=> Path.GetFullPath(Path.Combine(workspaceRoot, project.Path));
|
||||||
|
}
|
||||||
17
Journal.DevTool/Journal.DevTool.csproj
Normal file
17
Journal.DevTool/Journal.DevTool.csproj
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AssemblyName>sdt</AssemblyName>
|
||||||
|
<RootNamespace>Sdt</RootNamespace>
|
||||||
|
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
57
Journal.DevTool/Program.cs
Normal file
57
Journal.DevTool/Program.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
60
Journal.DevTool/Runner/ProcessRunner.cs
Normal file
60
Journal.DevTool/Runner/ProcessRunner.cs
Normal file
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a command with the given args, streaming stdout/stderr via <paramref name="onOutput"/>.
|
||||||
|
/// onOutput receives (line, isStderr).
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<RunResult> RunAsync(
|
||||||
|
string command,
|
||||||
|
IEnumerable<string> args,
|
||||||
|
string workingDir,
|
||||||
|
Action<string, bool> 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<string> emit, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string? line;
|
||||||
|
while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) is not null
|
||||||
|
&& !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
emit(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Journal.DevTool/Runner/TargetRunner.cs
Normal file
41
Journal.DevTool/Runner/TargetRunner.cs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
using Sdt.Config;
|
||||||
|
|
||||||
|
namespace Sdt.Runner;
|
||||||
|
|
||||||
|
public static class TargetRunner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the ordered list of real (non-virtual) steps needed to execute <paramref name="target"/>,
|
||||||
|
/// respecting DependsOn chains. Each step appears at most once.
|
||||||
|
/// </summary>
|
||||||
|
public static List<BuildTarget> ResolvePlan(
|
||||||
|
BuildTarget target,
|
||||||
|
IReadOnlyDictionary<string, BuildTarget> allTargets)
|
||||||
|
{
|
||||||
|
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var plan = new List<BuildTarget>();
|
||||||
|
Visit(target, allTargets, visited, plan);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Visit(
|
||||||
|
BuildTarget target,
|
||||||
|
IReadOnlyDictionary<string, BuildTarget> allTargets,
|
||||||
|
HashSet<string> visited,
|
||||||
|
List<BuildTarget> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
311
Journal.DevTool/Tui/App.cs
Normal file
311
Journal.DevTool/Tui/App.cs
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
using Sdt.Config;
|
||||||
|
using Sdt.Runner;
|
||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace Sdt.Tui;
|
||||||
|
|
||||||
|
/// <summary>Thin wrapper used in Spectre.Console selection prompts.</summary>
|
||||||
|
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<string, BuildTarget> 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<AppResult> 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<MenuItem>()
|
||||||
|
.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<MenuItem>
|
||||||
|
{
|
||||||
|
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<string, BuildTarget> 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<MenuItem>()
|
||||||
|
.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<string>()
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
Journal.DevTool/Tui/Theme.cs
Normal file
54
Journal.DevTool/Tui/Theme.cs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace Sdt.Tui;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SDT phosphor-green colour palette.
|
||||||
|
/// Primary text is classic terminal phosphor (#00FF41).
|
||||||
|
/// Modern accent colours are kept for highlights and status.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
319
Journal.DevTool/Tui/ToolchainScreen.cs
Normal file
319
Journal.DevTool/Tui/ToolchainScreen.cs
Normal file
@ -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<MenuItem>();
|
||||||
|
|
||||||
|
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<MenuItem>()
|
||||||
|
.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<PythonProfile>()
|
||||||
|
.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<string> { "-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<string>();
|
||||||
|
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<string?> 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<string> 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)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
108
Journal.DevTool/Tui/WorkspaceScreen.cs
Normal file
108
Journal.DevTool/Tui/WorkspaceScreen.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the project switcher. Returns the absolute path to the selected project root,
|
||||||
|
/// or null if the user cancelled.
|
||||||
|
/// </summary>
|
||||||
|
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<WorkspaceMenuItem>();
|
||||||
|
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<WorkspaceMenuItem>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
@ -2,4 +2,5 @@
|
|||||||
<Project Path="Journal.Core/Journal.Core.csproj" />
|
<Project Path="Journal.Core/Journal.Core.csproj" />
|
||||||
<Project Path="Journal.Sidecar/Journal.Sidecar.csproj" />
|
<Project Path="Journal.Sidecar/Journal.Sidecar.csproj" />
|
||||||
<Project Path="Journal.SmokeTests/Journal.SmokeTests.csproj" />
|
<Project Path="Journal.SmokeTests/Journal.SmokeTests.csproj" />
|
||||||
|
<Project Path="Journal.DevTool/Journal.DevTool.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
301
devtool.json
Normal file
301
devtool.json
Normal file
@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
105
justfile
Normal file
105
justfile
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user