Add guided CLI workflows and config commands
- introduce `sdt` subcommands for run, debug, setup, env, favorite, and explain - add project/workspace discovery plus config bootstrap and migration helpers - expand tests for CLI parsing, project role detection, and headless flows
This commit is contained in:
parent
104c8eab91
commit
d5a74be368
2
.gitignore
vendored
2
.gitignore
vendored
@ -17,4 +17,4 @@ publish-test/
|
||||
/node_modules/
|
||||
/src/DevTool.Host.Gui/TauriShell/node_modules/
|
||||
output/
|
||||
|
||||
DevTool/
|
||||
|
||||
908
Cli/CliApplication.cs
Normal file
908
Cli/CliApplication.cs
Normal file
@ -0,0 +1,908 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Sdt.Bridge;
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Sdt.Tui;
|
||||
|
||||
namespace Sdt.Cli;
|
||||
|
||||
public sealed class CliApplication
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
static CliApplication()
|
||||
{
|
||||
JsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
var invocation = CliParser.Parse(args);
|
||||
return invocation.Command.ToLowerInvariant() switch
|
||||
{
|
||||
"interactive" => await LaunchInteractiveAsync([]),
|
||||
"init" => await LaunchInteractiveAsync(["init"]),
|
||||
"run" => await ExecuteRunAsync(invocation),
|
||||
"debug" => await ExecuteDebugAsync(invocation),
|
||||
"workspace" => ExecuteWorkspace(invocation),
|
||||
"bridge" => await ExecuteBridgeAsync(invocation),
|
||||
"doctor" => await ExecuteDoctorAsync(invocation),
|
||||
"setup" => await ExecuteSetupAsync(invocation),
|
||||
"env" => ExecuteEnv(invocation),
|
||||
"favorite" => await ExecuteFavoriteAsync(invocation),
|
||||
"config" => ExecuteConfig(invocation),
|
||||
"explain" => await ExecuteExplainAsync(invocation),
|
||||
"help" => ExecuteHelp(),
|
||||
_ => WriteValidationError($"Unknown command '{invocation.Command}'. Run `sdt help`."),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<int> LaunchInteractiveAsync(IReadOnlyList<string> cliArgs)
|
||||
{
|
||||
var workspaceResult = WorkspaceLoader.FindAndLoad();
|
||||
var projectResult = ConfigLoader.FindAndLoad();
|
||||
var forceInit = cliArgs.Any(a => string.Equals(a, "init", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(a, "--init", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (forceInit)
|
||||
{
|
||||
var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory());
|
||||
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
|
||||
Spectre.Console.AnsiConsole.MarkupLine(Theme.Ok($"Initialized config at {path}"));
|
||||
projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot);
|
||||
}
|
||||
|
||||
if (projectResult is null)
|
||||
{
|
||||
Spectre.Console.AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No SDT project config found[/] (expected `sdtconfig-*.json` or `devtool.json`) in current directory or any parent.");
|
||||
var bootstrap = Spectre.Console.AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Generate a default SDT project config for this project now?[/]",
|
||||
defaultValue: true);
|
||||
if (!bootstrap)
|
||||
{
|
||||
Spectre.Console.AnsiConsole.MarkupLine(Theme.Faint("Create an sdtconfig-<ProjectName>.json (or devtool.json) in your project root to get started."));
|
||||
return 1;
|
||||
}
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory());
|
||||
Spectre.Console.AnsiConsole.MarkupLine(Theme.Faint($"Detected project root: {scan.ProjectRoot}"));
|
||||
if (scan.ToolFamilies.Count > 0)
|
||||
Spectre.Console.AnsiConsole.MarkupLine(Theme.Faint($"Detected tool families: {string.Join(", ", scan.ToolFamilies)}"));
|
||||
|
||||
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
var preview = ConfigBootstrapper.ToJson(generated);
|
||||
var previewPanel = new Spectre.Console.Panel(Spectre.Console.Markup.Escape(preview))
|
||||
{
|
||||
Header = new Spectre.Console.PanelHeader("Generated project config preview")
|
||||
};
|
||||
previewPanel.BorderStyle = Theme.DimStyle;
|
||||
Spectre.Console.AnsiConsole.Write(previewPanel);
|
||||
|
||||
var confirmWrite = Spectre.Console.AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Write generated project config to {scan.ProjectRoot}?[/]",
|
||||
defaultValue: true);
|
||||
if (!confirmWrite)
|
||||
return 1;
|
||||
|
||||
var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
|
||||
Spectre.Console.AnsiConsole.MarkupLine(Theme.Ok($"Created {path}"));
|
||||
projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot);
|
||||
if (projectResult is null)
|
||||
{
|
||||
Spectre.Console.AnsiConsole.MarkupLine(Theme.Fail("Generated config could not be reloaded."));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
var currentLoaded = projectResult;
|
||||
var (workspace, workspaceRoot) = workspaceResult.HasValue
|
||||
? (workspaceResult.Value.Config, workspaceResult.Value.WorkspaceRoot)
|
||||
: ((WorkspaceConfig?)null, (string?)null);
|
||||
string? pendingWorkflowId = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var app = new App(
|
||||
currentLoaded.Config,
|
||||
currentLoaded.ProjectRoot,
|
||||
currentLoaded.Warnings,
|
||||
workspace,
|
||||
workspaceRoot,
|
||||
pendingWorkflowId);
|
||||
pendingWorkflowId = null;
|
||||
|
||||
var result = await app.RunAsync();
|
||||
|
||||
if (result.Reason == AppExitReason.Quit)
|
||||
break;
|
||||
|
||||
if (result.Reason == AppExitReason.SwitchProject && result.NewProjectRoot is not null)
|
||||
{
|
||||
LoadedProjectConfig? loaded;
|
||||
try
|
||||
{
|
||||
loaded = ConfigLoader.FindAndLoad(result.NewProjectRoot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Spectre.Console.AnsiConsole.MarkupLine(Theme.Fail(ex.Message));
|
||||
Spectre.Console.AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (loaded is null)
|
||||
{
|
||||
Spectre.Console.AnsiConsole.MarkupLine(Theme.Fail($"No SDT project config found at: {result.NewProjectRoot}"));
|
||||
Spectre.Console.AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
continue;
|
||||
}
|
||||
|
||||
currentLoaded = loaded;
|
||||
pendingWorkflowId = result.RunWorkflowId;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteRunAsync(CliInvocation invocation)
|
||||
{
|
||||
var loaded = LoadProject(invocation);
|
||||
if (loaded is null)
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
if (invocation.Positionals.Count == 0)
|
||||
return WriteValidationError("Missing workflow id or alias. Usage: sdt run <workflow> [--json] [--project-root <path>] [--env-profile <id>] [--non-interactive] [--auto-install]");
|
||||
|
||||
var normalized = WorkflowModelBuilder.Normalize(loaded.Config, ResolveLegacyMode(), new RequirementResolver());
|
||||
var workflow = GuidedTaskCatalog.FindWorkflow(normalized.Workflows, invocation.Positionals[0]);
|
||||
if (workflow is null)
|
||||
return WriteValidationError($"Workflow '{invocation.Positionals[0]}' was not found.");
|
||||
|
||||
var service = new HeadlessExecutionService();
|
||||
return await service.RunWorkflowAsync(
|
||||
loaded,
|
||||
new HeadlessRunRequest(
|
||||
WorkflowId: workflow.Id,
|
||||
ProjectRoot: loaded.ProjectRoot,
|
||||
EnvProfile: invocation.GetOption("--env-profile"),
|
||||
NonInteractive: invocation.HasOption("--non-interactive") || RuntimePolicy.IsNonInteractive(),
|
||||
AutoInstall: invocation.HasOption("--auto-install"),
|
||||
JsonOutput: invocation.HasOption("--json")));
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteDebugAsync(CliInvocation invocation)
|
||||
{
|
||||
var loaded = LoadProject(invocation);
|
||||
if (loaded is null)
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
if (invocation.Positionals.Count == 0)
|
||||
return WriteValidationError("Missing debug profile id or alias. Usage: sdt debug <profile> [--json] [--project-root <path>] [--env-profile <id>] [--non-interactive] [--auto-install]");
|
||||
|
||||
var profile = GuidedTaskCatalog.FindDebugProfile(loaded.Config.Debug?.Profiles ?? [], invocation.Positionals[0]);
|
||||
if (profile is null)
|
||||
return WriteValidationError($"Debug profile '{invocation.Positionals[0]}' was not found.");
|
||||
|
||||
var service = new HeadlessExecutionService();
|
||||
return await service.RunDebugAsync(
|
||||
loaded,
|
||||
new HeadlessDebugRequest(
|
||||
ProfileId: profile.Id,
|
||||
ProjectRoot: loaded.ProjectRoot,
|
||||
EnvProfile: invocation.GetOption("--env-profile"),
|
||||
NonInteractive: invocation.HasOption("--non-interactive") || RuntimePolicy.IsNonInteractive(),
|
||||
AutoInstall: invocation.HasOption("--auto-install"),
|
||||
JsonOutput: invocation.HasOption("--json")));
|
||||
}
|
||||
|
||||
private static int ExecuteWorkspace(CliInvocation invocation)
|
||||
{
|
||||
if (!string.Equals(invocation.Subcommand, "scan", StringComparison.OrdinalIgnoreCase))
|
||||
return WriteValidationError("Usage: sdt workspace scan [--json] [--project-root <path>]");
|
||||
|
||||
var startDir = ResolveProjectRoot(invocation);
|
||||
var asJson = invocation.HasOption("--json");
|
||||
var workspaceLoaded = WorkspaceLoader.FindAndLoad(startDir);
|
||||
if (workspaceLoaded is null)
|
||||
{
|
||||
WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, success = false, message = "No workspace could be discovered." });
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
var (workspace, workspaceRoot) = workspaceLoaded.Value;
|
||||
var currentRoot = ConfigLoader.FindAndLoad(startDir)?.ProjectRoot ?? startDir;
|
||||
var scan = WorkspaceLoader.ScanInventory(workspaceRoot, currentRoot, workspace);
|
||||
if (asJson)
|
||||
{
|
||||
WriteJson(new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
success = true,
|
||||
workspaceRoot = scan.WorkspaceRoot,
|
||||
knownProjects = scan.KnownProjects,
|
||||
candidates = scan.Candidates,
|
||||
snapshot = scan.Snapshot
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Workspace root: {scan.WorkspaceRoot}");
|
||||
Console.WriteLine($"Known projects: {scan.KnownProjects.Count}");
|
||||
Console.WriteLine($"Candidates: {scan.Candidates.Count}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteBridgeAsync(CliInvocation invocation)
|
||||
{
|
||||
if (!string.Equals(invocation.Subcommand, "--stdio", StringComparison.OrdinalIgnoreCase) &&
|
||||
!invocation.HasOption("--stdio"))
|
||||
{
|
||||
return WriteValidationError("Usage: sdt bridge --stdio [--project-root <path>]");
|
||||
}
|
||||
|
||||
var server = new BridgeStdioServer(ResolveProjectRoot(invocation));
|
||||
return await server.RunAsync();
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteDoctorAsync(CliInvocation invocation)
|
||||
{
|
||||
if (invocation.Subcommand is not null &&
|
||||
!string.Equals(invocation.Subcommand, "run", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return WriteValidationError("Usage: sdt doctor [run] [--json] [--project-root <path>]");
|
||||
}
|
||||
|
||||
var loaded = LoadProject(invocation);
|
||||
if (loaded is null)
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
var doctor = new ConfigDoctorService(new ToolProbeService(), new RequirementResolver());
|
||||
var report = await doctor.RunAsync(loaded.Config, loaded.ProjectRoot);
|
||||
if (invocation.HasOption("--json"))
|
||||
{
|
||||
WriteJson(new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
projectRoot = loaded.ProjectRoot,
|
||||
failCount = report.Checks.Count(c => c.Status == DoctorStatus.Fail),
|
||||
warnCount = report.Checks.Count(c => c.Status == DoctorStatus.Warn),
|
||||
checks = report.Checks
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Doctor: {loaded.ProjectRoot}");
|
||||
foreach (var check in report.Checks)
|
||||
Console.WriteLine($"- [{check.Status}] {check.Name}: {check.Detail}");
|
||||
}
|
||||
|
||||
return report.HasFailures
|
||||
? ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed)
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteSetupAsync(CliInvocation invocation)
|
||||
{
|
||||
var subcommand = string.IsNullOrWhiteSpace(invocation.Subcommand)
|
||||
? "plan"
|
||||
: invocation.Subcommand!;
|
||||
|
||||
if (string.Equals(subcommand, "doctor", StringComparison.OrdinalIgnoreCase))
|
||||
return await ExecuteDoctorAsync(new CliInvocation("doctor", "run", invocation.Positionals, invocation.Options));
|
||||
|
||||
return subcommand.ToLowerInvariant() switch
|
||||
{
|
||||
"plan" => await ExecuteSetupPlanAsync(invocation),
|
||||
"apply" => await ExecuteSetupApplyAsync(invocation),
|
||||
_ => WriteValidationError("Usage: sdt setup [plan|apply|doctor] [--json] [--project-root <path>]"),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteSetupPlanAsync(CliInvocation invocation)
|
||||
{
|
||||
var loaded = LoadProject(invocation);
|
||||
if (loaded is null)
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
var doctor = new ConfigDoctorService(new ToolProbeService(), new RequirementResolver());
|
||||
var report = await doctor.RunAsync(loaded.Config, loaded.ProjectRoot);
|
||||
var updater = new SetupWizardConfigService(new RequirementResolver());
|
||||
var update = updater.ApplyRecommendedDefaults(loaded.Config);
|
||||
var fixer = new ConfigDoctorAutoFixService();
|
||||
var missingDirs = fixer.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot);
|
||||
|
||||
var payload = new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
projectRoot = loaded.ProjectRoot,
|
||||
doctor = new
|
||||
{
|
||||
failCount = report.Checks.Count(c => c.Status == DoctorStatus.Fail),
|
||||
warnCount = report.Checks.Count(c => c.Status == DoctorStatus.Warn),
|
||||
checks = report.Checks
|
||||
},
|
||||
safePlan = new
|
||||
{
|
||||
createMissingWorkingDirectories = missingDirs,
|
||||
recommendedConfigChanges = update.Changes
|
||||
},
|
||||
suggestedCommands = new[]
|
||||
{
|
||||
"sdt doctor run",
|
||||
"sdt setup apply --safe",
|
||||
"sdt config migrate-preview"
|
||||
}
|
||||
};
|
||||
|
||||
if (invocation.HasOption("--json"))
|
||||
WriteJson(payload);
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Setup plan for {loaded.ProjectRoot}");
|
||||
Console.WriteLine($"- doctor failures: {report.Checks.Count(c => c.Status == DoctorStatus.Fail)}");
|
||||
Console.WriteLine($"- doctor warnings: {report.Checks.Count(c => c.Status == DoctorStatus.Warn)}");
|
||||
Console.WriteLine($"- missing directories: {missingDirs.Count}");
|
||||
Console.WriteLine($"- recommended config changes: {update.Changes.Count}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteSetupApplyAsync(CliInvocation invocation)
|
||||
{
|
||||
var loaded = LoadProject(invocation);
|
||||
if (loaded is null)
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
var safeOnly = invocation.HasOption("--safe") || !invocation.HasOption("--migrate-legacy");
|
||||
var fixer = new ConfigDoctorAutoFixService();
|
||||
var updater = new SetupWizardConfigService(new RequirementResolver());
|
||||
var missingDirs = fixer.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot);
|
||||
var dirResult = fixer.CreateMissingWorkingDirectories(missingDirs);
|
||||
var update = updater.ApplyRecommendedDefaults(loaded.Config);
|
||||
var saveResult = update.Changes.Count == 0
|
||||
? new LegacyMigrationApplyResult(true, "No config changes needed.")
|
||||
: ProjectConfigFileOperations.SaveWithBackup(loaded.ProjectRoot, update.Config);
|
||||
|
||||
LegacyMigrationApplyResult? migrationResult = null;
|
||||
if (!safeOnly && loaded.Config.Targets.Count > 0)
|
||||
{
|
||||
var configPath = ConfigLoader.FindConfigPath(loaded.ProjectRoot);
|
||||
if (!string.IsNullOrWhiteSpace(configPath))
|
||||
migrationResult = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
projectRoot = loaded.ProjectRoot,
|
||||
safe = safeOnly,
|
||||
createdDirectories = dirResult.CreatedDirectories,
|
||||
configSaved = saveResult.Success,
|
||||
backupPath = saveResult.BackupPath,
|
||||
recommendedChanges = update.Changes,
|
||||
legacyMigration = migrationResult
|
||||
};
|
||||
|
||||
if (invocation.HasOption("--json"))
|
||||
WriteJson(payload);
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Setup apply for {loaded.ProjectRoot}");
|
||||
Console.WriteLine($"- created directories: {dirResult.CreatedDirectories}");
|
||||
Console.WriteLine($"- config changes: {update.Changes.Count}");
|
||||
if (!string.IsNullOrWhiteSpace(saveResult.BackupPath))
|
||||
Console.WriteLine($"- backup: {saveResult.BackupPath}");
|
||||
if (migrationResult is not null)
|
||||
Console.WriteLine($"- legacy migration: {migrationResult.Message}");
|
||||
}
|
||||
|
||||
if (!dirResult.Success || !saveResult.Success || (migrationResult is not null && !migrationResult.Success))
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int ExecuteEnv(CliInvocation invocation)
|
||||
{
|
||||
var subcommand = string.IsNullOrWhiteSpace(invocation.Subcommand)
|
||||
? "list"
|
||||
: invocation.Subcommand!;
|
||||
|
||||
return subcommand.ToLowerInvariant() switch
|
||||
{
|
||||
"list" => ExecuteEnvList(invocation),
|
||||
"use" => ExecuteEnvUse(invocation),
|
||||
_ => WriteValidationError("Usage: sdt env [list|use] [--json] [--project-root <path>]"),
|
||||
};
|
||||
}
|
||||
|
||||
private static int ExecuteEnvList(CliInvocation invocation)
|
||||
{
|
||||
var loaded = LoadProject(invocation);
|
||||
if (loaded is null)
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
var payload = new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
projectRoot = loaded.ProjectRoot,
|
||||
activeProfile = loaded.Config.EnvProfiles?.Active,
|
||||
profiles = loaded.Config.EnvProfiles?.Profiles ?? [],
|
||||
env = loaded.Config.Env
|
||||
};
|
||||
|
||||
if (invocation.HasOption("--json"))
|
||||
WriteJson(payload);
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Environment for {loaded.ProjectRoot}");
|
||||
Console.WriteLine($"- active profile: {loaded.Config.EnvProfiles?.Active ?? "(none)"}");
|
||||
foreach (var profile in loaded.Config.EnvProfiles?.Profiles ?? [])
|
||||
Console.WriteLine($"- profile {profile.Id}: {profile.Description}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int ExecuteEnvUse(CliInvocation invocation)
|
||||
{
|
||||
var loaded = LoadProject(invocation);
|
||||
if (loaded is null)
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
if (invocation.Positionals.Count == 0)
|
||||
return WriteValidationError("Usage: sdt env use <profile> [--json] [--project-root <path>]");
|
||||
|
||||
var profileId = invocation.Positionals[0];
|
||||
var envProfiles = loaded.Config.EnvProfiles;
|
||||
if (envProfiles is null || !envProfiles.Profiles.Any(p => string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase)))
|
||||
return WriteValidationError($"Env profile '{profileId}' was not found.");
|
||||
|
||||
var updated = new DevToolConfig
|
||||
{
|
||||
Name = loaded.Config.Name,
|
||||
Version = loaded.Config.Version,
|
||||
Targets = loaded.Config.Targets,
|
||||
Workflows = loaded.Config.Workflows,
|
||||
Env = loaded.Config.Env,
|
||||
EnvProfiles = new EnvProfilesConfig
|
||||
{
|
||||
Active = profileId,
|
||||
Profiles = envProfiles.Profiles
|
||||
},
|
||||
Toolchains = loaded.Config.Toolchains,
|
||||
Tooling = loaded.Config.Tooling,
|
||||
Project = loaded.Config.Project,
|
||||
Debug = loaded.Config.Debug
|
||||
};
|
||||
|
||||
var save = ProjectConfigFileOperations.SaveWithBackup(loaded.ProjectRoot, updated);
|
||||
if (invocation.HasOption("--json"))
|
||||
{
|
||||
WriteJson(new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
projectRoot = loaded.ProjectRoot,
|
||||
activeProfile = profileId,
|
||||
save
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Active env profile: {profileId}");
|
||||
if (!string.IsNullOrWhiteSpace(save.BackupPath))
|
||||
Console.WriteLine($"Backup: {save.BackupPath}");
|
||||
}
|
||||
|
||||
return save.Success ? 0 : ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteFavoriteAsync(CliInvocation invocation)
|
||||
{
|
||||
var subcommand = string.IsNullOrWhiteSpace(invocation.Subcommand)
|
||||
? "list"
|
||||
: invocation.Subcommand!;
|
||||
|
||||
return subcommand.ToLowerInvariant() switch
|
||||
{
|
||||
"list" => ExecuteFavoriteList(invocation),
|
||||
"pin" => ExecuteFavoritePin(invocation),
|
||||
"run" => await ExecuteFavoriteRunAsync(invocation),
|
||||
_ => WriteValidationError("Usage: sdt favorite [list|pin|run] ..."),
|
||||
};
|
||||
}
|
||||
|
||||
private static int ExecuteFavoriteList(CliInvocation invocation)
|
||||
{
|
||||
var workspaceLoaded = WorkspaceLoader.FindAndLoad(ResolveProjectRoot(invocation));
|
||||
if (workspaceLoaded is null)
|
||||
return WriteValidationError("No workspace could be discovered for favorites.");
|
||||
|
||||
var (workspace, workspaceRoot) = workspaceLoaded.Value;
|
||||
var payload = workspace.Favorites.Select(f => new
|
||||
{
|
||||
id = GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId),
|
||||
projectPath = f.ProjectPath,
|
||||
workflowId = f.WorkflowId,
|
||||
label = f.Label,
|
||||
resolvedProjectRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f)
|
||||
}).ToList();
|
||||
|
||||
if (invocation.HasOption("--json"))
|
||||
WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, workspaceRoot, favorites = payload });
|
||||
else
|
||||
foreach (var favorite in payload) Console.WriteLine($"{favorite.id}: {favorite.label ?? favorite.workflowId} ({favorite.resolvedProjectRoot})");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int ExecuteFavoritePin(CliInvocation invocation)
|
||||
{
|
||||
if (invocation.Positionals.Count == 0)
|
||||
return WriteValidationError("Usage: sdt favorite pin <workflow> --id <favoriteId> [--label <label>] [--project-root <path>]");
|
||||
|
||||
var loaded = LoadProject(invocation);
|
||||
if (loaded is null)
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
var normalized = WorkflowModelBuilder.Normalize(loaded.Config, ResolveLegacyMode(), new RequirementResolver());
|
||||
var workflow = GuidedTaskCatalog.FindWorkflow(normalized.Workflows, invocation.Positionals[0]);
|
||||
if (workflow is null)
|
||||
return WriteValidationError($"Workflow '{invocation.Positionals[0]}' was not found.");
|
||||
|
||||
var workspaceLoaded = WorkspaceLoader.FindAndLoad(loaded.ProjectRoot);
|
||||
if (workspaceLoaded is null)
|
||||
return WriteValidationError("No workspace could be discovered for favorites.");
|
||||
|
||||
var (workspace, workspaceRoot) = workspaceLoaded.Value;
|
||||
var requestedId = invocation.GetOption("--id");
|
||||
var favoriteId = string.IsNullOrWhiteSpace(requestedId)
|
||||
? EnsureUniqueFavoriteId(workspace, GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow))
|
||||
: requestedId!;
|
||||
var label = invocation.GetOption("--label") ?? GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow);
|
||||
var projectPath = Path.GetRelativePath(workspaceRoot, loaded.ProjectRoot);
|
||||
|
||||
var existing = workspace.Favorites.FirstOrDefault(f => string.Equals(f.Id, favoriteId, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing is not null)
|
||||
workspace.Favorites.Remove(existing);
|
||||
|
||||
workspace.Favorites.Add(new WorkspaceFavorite
|
||||
{
|
||||
Id = favoriteId,
|
||||
ProjectPath = projectPath,
|
||||
WorkflowId = workflow.Id,
|
||||
Label = label
|
||||
});
|
||||
WorkspaceLoader.Save(workspaceRoot, workspace);
|
||||
|
||||
if (invocation.HasOption("--json"))
|
||||
{
|
||||
WriteJson(new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
workspaceRoot,
|
||||
favorite = new { id = favoriteId, label, workflowId = workflow.Id, projectPath }
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Pinned favorite '{favoriteId}' -> {workflow.Id}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteFavoriteRunAsync(CliInvocation invocation)
|
||||
{
|
||||
if (invocation.Positionals.Count == 0)
|
||||
return WriteValidationError("Usage: sdt favorite run <favoriteId> [--json] [--project-root <path>] [--env-profile <id>] [--non-interactive] [--auto-install]");
|
||||
|
||||
var workspaceLoaded = WorkspaceLoader.FindAndLoad(ResolveProjectRoot(invocation));
|
||||
if (workspaceLoaded is null)
|
||||
return WriteValidationError("No workspace could be discovered for favorites.");
|
||||
|
||||
var (workspace, workspaceRoot) = workspaceLoaded.Value;
|
||||
var favorite = workspace.Favorites.FirstOrDefault(f =>
|
||||
string.Equals(GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId), invocation.Positionals[0], StringComparison.OrdinalIgnoreCase));
|
||||
if (favorite is null)
|
||||
return WriteValidationError($"Favorite '{invocation.Positionals[0]}' was not found.");
|
||||
|
||||
var favoriteRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, favorite);
|
||||
var loaded = ConfigLoader.FindAndLoad(favoriteRoot);
|
||||
if (loaded is null)
|
||||
return WriteValidationError($"Favorite project could not be loaded: {favoriteRoot}");
|
||||
|
||||
var service = new HeadlessExecutionService();
|
||||
return await service.RunWorkflowAsync(
|
||||
loaded,
|
||||
new HeadlessRunRequest(
|
||||
WorkflowId: favorite.WorkflowId,
|
||||
ProjectRoot: loaded.ProjectRoot,
|
||||
EnvProfile: invocation.GetOption("--env-profile"),
|
||||
NonInteractive: invocation.HasOption("--non-interactive") || RuntimePolicy.IsNonInteractive(),
|
||||
AutoInstall: invocation.HasOption("--auto-install"),
|
||||
JsonOutput: invocation.HasOption("--json")));
|
||||
}
|
||||
|
||||
private static int ExecuteConfig(CliInvocation invocation)
|
||||
{
|
||||
var subcommand = string.IsNullOrWhiteSpace(invocation.Subcommand)
|
||||
? string.Empty
|
||||
: invocation.Subcommand!;
|
||||
|
||||
return subcommand.ToLowerInvariant() switch
|
||||
{
|
||||
"migrate-preview" => ExecuteConfigMigratePreview(invocation),
|
||||
"migrate-apply" => ExecuteConfigMigrateApply(invocation),
|
||||
_ => WriteValidationError("Usage: sdt config [migrate-preview|migrate-apply] [--json] [--project-root <path>]"),
|
||||
};
|
||||
}
|
||||
|
||||
private static int ExecuteConfigMigratePreview(CliInvocation invocation)
|
||||
{
|
||||
var configPath = ConfigLoader.FindConfigPath(ResolveProjectRoot(invocation));
|
||||
if (string.IsNullOrWhiteSpace(configPath))
|
||||
return WriteValidationError("No project config found for migration preview.");
|
||||
|
||||
var result = ConfigLoader.WriteLegacyMigrationPreview(configPath, invocation.GetOption("--output"));
|
||||
if (invocation.HasOption("--json"))
|
||||
WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, result });
|
||||
else
|
||||
Console.WriteLine(result.Message + (string.IsNullOrWhiteSpace(result.ConfigPath) ? string.Empty : $" {result.ConfigPath}"));
|
||||
|
||||
return result.Success ? 0 : ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
private static int ExecuteConfigMigrateApply(CliInvocation invocation)
|
||||
{
|
||||
var configPath = ConfigLoader.FindConfigPath(ResolveProjectRoot(invocation));
|
||||
if (string.IsNullOrWhiteSpace(configPath))
|
||||
return WriteValidationError("No project config found for migration.");
|
||||
|
||||
var result = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
|
||||
if (invocation.HasOption("--json"))
|
||||
WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, result });
|
||||
else
|
||||
Console.WriteLine(result.Message + (string.IsNullOrWhiteSpace(result.BackupPath) ? string.Empty : $" Backup: {result.BackupPath}"));
|
||||
|
||||
return result.Success ? 0 : ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteExplainAsync(CliInvocation invocation)
|
||||
{
|
||||
if (invocation.Positionals.Count == 0)
|
||||
return WriteValidationError("Usage: sdt explain <task> [--json] [--project-root <path>] [--env-profile <id>]");
|
||||
|
||||
var selector = invocation.Positionals[0];
|
||||
if (string.Equals(selector, "setup", StringComparison.OrdinalIgnoreCase))
|
||||
return await ExecuteSetupPlanAsync(invocation);
|
||||
if (string.Equals(selector, "doctor", StringComparison.OrdinalIgnoreCase))
|
||||
return await ExecuteDoctorAsync(new CliInvocation("doctor", "run", [], invocation.Options));
|
||||
|
||||
var workspaceLoaded = WorkspaceLoader.FindAndLoad(ResolveProjectRoot(invocation));
|
||||
if (workspaceLoaded is not null)
|
||||
{
|
||||
var favorite = workspaceLoaded.Value.Config.Favorites.FirstOrDefault(f =>
|
||||
string.Equals(GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId), selector, StringComparison.OrdinalIgnoreCase));
|
||||
if (favorite is not null)
|
||||
{
|
||||
var resolvedRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceLoaded.Value.WorkspaceRoot, favorite);
|
||||
var payload = new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
task = selector,
|
||||
type = "favorite",
|
||||
projectRoot = resolvedRoot,
|
||||
workflowId = favorite.WorkflowId,
|
||||
label = favorite.Label,
|
||||
runCommand = $"sdt favorite run {GuidedTaskCatalog.EnsureFavoriteId(favorite, favorite.WorkflowId)} --project-root \"{ResolveProjectRoot(invocation)}\""
|
||||
};
|
||||
if (invocation.HasOption("--json"))
|
||||
WriteJson(payload);
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Favorite {selector}");
|
||||
Console.WriteLine($"- project root: {resolvedRoot}");
|
||||
Console.WriteLine($"- workflow: {favorite.WorkflowId}");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
var loaded = LoadProject(invocation);
|
||||
if (loaded is null)
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
var normalized = WorkflowModelBuilder.Normalize(loaded.Config, ResolveLegacyMode(), new RequirementResolver());
|
||||
var workflow = GuidedTaskCatalog.FindWorkflow(normalized.Workflows, selector);
|
||||
if (workflow is not null)
|
||||
{
|
||||
var requirementResolver = new RequirementResolver();
|
||||
var tools = workflow.Steps
|
||||
.SelectMany(step => requirementResolver.Resolve(step))
|
||||
.Select(req => req.Tool)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var payload = new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
task = selector,
|
||||
type = "workflow",
|
||||
projectRoot = loaded.ProjectRoot,
|
||||
envProfile = invocation.GetOption("--env-profile") ?? loaded.Config.EnvProfiles?.Active,
|
||||
workflow = new
|
||||
{
|
||||
id = workflow.Id,
|
||||
label = GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow),
|
||||
description = workflow.Description,
|
||||
category = GuidedTaskCatalog.GetWorkflowCategory(workflow),
|
||||
aliases = workflow.Aliases,
|
||||
tags = workflow.Tags,
|
||||
steps = workflow.Steps.Select(step => new
|
||||
{
|
||||
id = step.Id,
|
||||
label = step.Label,
|
||||
command = step.Command,
|
||||
action = step.Action,
|
||||
workingDir = step.WorkingDir
|
||||
})
|
||||
},
|
||||
toolsChecked = tools,
|
||||
runCommand = $"sdt run {workflow.Id} --project-root \"{loaded.ProjectRoot}\""
|
||||
};
|
||||
|
||||
if (invocation.HasOption("--json"))
|
||||
WriteJson(payload);
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Workflow {GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow)}");
|
||||
Console.WriteLine($"- project root: {loaded.ProjectRoot}");
|
||||
Console.WriteLine($"- description: {workflow.Description}");
|
||||
Console.WriteLine($"- tools checked: {(tools.Count == 0 ? "(none)" : string.Join(", ", tools))}");
|
||||
Console.WriteLine($"- run command: sdt run {workflow.Id} --project-root \"{loaded.ProjectRoot}\"");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
var profile = GuidedTaskCatalog.FindDebugProfile(loaded.Config.Debug?.Profiles ?? [], selector);
|
||||
if (profile is not null)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
task = selector,
|
||||
type = "debug",
|
||||
projectRoot = loaded.ProjectRoot,
|
||||
envProfile = invocation.GetOption("--env-profile") ?? loaded.Config.EnvProfiles?.Active,
|
||||
profile = new
|
||||
{
|
||||
id = profile.Id,
|
||||
label = GuidedTaskCatalog.GetDebugDisplayLabel(profile),
|
||||
description = profile.Description,
|
||||
category = GuidedTaskCatalog.GetDebugCategory(profile),
|
||||
aliases = profile.Aliases,
|
||||
tags = profile.Tags,
|
||||
command = profile.Command,
|
||||
args = profile.Args,
|
||||
workingDir = profile.WorkingDir
|
||||
},
|
||||
runCommand = $"sdt debug {profile.Id} --project-root \"{loaded.ProjectRoot}\""
|
||||
};
|
||||
|
||||
if (invocation.HasOption("--json"))
|
||||
WriteJson(payload);
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Debug {GuidedTaskCatalog.GetDebugDisplayLabel(profile)}");
|
||||
Console.WriteLine($"- project root: {loaded.ProjectRoot}");
|
||||
Console.WriteLine($"- description: {profile.Description}");
|
||||
Console.WriteLine($"- run command: sdt debug {profile.Id} --project-root \"{loaded.ProjectRoot}\"");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return WriteValidationError($"Task '{selector}' was not found as a workflow, debug profile, or favorite.");
|
||||
}
|
||||
|
||||
private static int ExecuteHelp()
|
||||
{
|
||||
Console.WriteLine("""
|
||||
SDT
|
||||
|
||||
Guided commands:
|
||||
sdt setup plan
|
||||
sdt setup apply --safe
|
||||
sdt doctor run
|
||||
sdt env list
|
||||
sdt env use <profile>
|
||||
sdt favorite list
|
||||
sdt favorite pin <workflow> --id <favoriteId>
|
||||
sdt favorite run <favoriteId>
|
||||
sdt explain <task>
|
||||
|
||||
Expert commands:
|
||||
sdt run <workflow>
|
||||
sdt debug <profile>
|
||||
sdt workspace scan
|
||||
sdt bridge --stdio
|
||||
sdt config migrate-preview
|
||||
sdt config migrate-apply
|
||||
|
||||
Common flags:
|
||||
--project-root <path>
|
||||
--env-profile <id>
|
||||
--json
|
||||
--non-interactive
|
||||
--auto-install
|
||||
--brief
|
||||
--plain
|
||||
""");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static LoadedProjectConfig? LoadProject(CliInvocation invocation)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ConfigLoader.FindAndLoad(ResolveProjectRoot(invocation));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, success = false, message = ex.Message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveProjectRoot(CliInvocation invocation)
|
||||
=> invocation.GetOption("--project-root") ?? Directory.GetCurrentDirectory();
|
||||
|
||||
private static int WriteValidationError(string message)
|
||||
{
|
||||
WriteJson(new
|
||||
{
|
||||
contractVersion = HeadlessExecutionService.ContractVersion,
|
||||
success = false,
|
||||
message
|
||||
});
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
private static void WriteJson(object payload)
|
||||
=> Console.WriteLine(JsonSerializer.Serialize(payload, JsonOptions));
|
||||
|
||||
private static LegacyMode ResolveLegacyMode()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
|
||||
return string.Equals(raw, "compat", StringComparison.OrdinalIgnoreCase)
|
||||
? LegacyMode.Compat
|
||||
: LegacyMode.Strict;
|
||||
}
|
||||
|
||||
private static string EnsureUniqueFavoriteId(WorkspaceConfig workspace, string baseValue)
|
||||
{
|
||||
var slug = GuidedTaskCatalog.Slugify(baseValue);
|
||||
var candidate = slug;
|
||||
var counter = 2;
|
||||
while (workspace.Favorites.Any(f => string.Equals(GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId), candidate, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
candidate = $"{slug}-{counter}";
|
||||
counter++;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
13
Cli/CliInvocation.cs
Normal file
13
Cli/CliInvocation.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace Sdt.Cli;
|
||||
|
||||
public sealed record CliInvocation(
|
||||
string Command,
|
||||
string? Subcommand,
|
||||
IReadOnlyList<string> Positionals,
|
||||
IReadOnlyDictionary<string, string?> Options)
|
||||
{
|
||||
public bool HasOption(string name) => Options.ContainsKey(name);
|
||||
|
||||
public string? GetOption(string name)
|
||||
=> Options.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
94
Cli/CliParser.cs
Normal file
94
Cli/CliParser.cs
Normal file
@ -0,0 +1,94 @@
|
||||
namespace Sdt.Cli;
|
||||
|
||||
public static class CliParser
|
||||
{
|
||||
private static readonly HashSet<string> SubcommandRoots = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"workspace",
|
||||
"bridge",
|
||||
"setup",
|
||||
"doctor",
|
||||
"env",
|
||||
"favorite",
|
||||
"config",
|
||||
};
|
||||
|
||||
public static CliInvocation Parse(IReadOnlyList<string> args)
|
||||
{
|
||||
if (args.Count == 0)
|
||||
return new CliInvocation("interactive", null, [], new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
if (args.Any(a => string.Equals(a, "--plain", StringComparison.OrdinalIgnoreCase)))
|
||||
ApplyPlainOutputMode();
|
||||
|
||||
if (args.Count == 1 &&
|
||||
(string.Equals(args[0], "--help", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(args[0], "-h", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(args[0], "help", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return new CliInvocation("help", null, [], new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (args.Count == 1 &&
|
||||
(string.Equals(args[0], "init", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(args[0], "--init", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return new CliInvocation("init", null, [], new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var command = args[0];
|
||||
string? subcommand = null;
|
||||
var optionStart = args.Count;
|
||||
|
||||
if (SubcommandRoots.Contains(command) &&
|
||||
args.Count > 1 &&
|
||||
!args[1].StartsWith("-", StringComparison.Ordinal))
|
||||
{
|
||||
subcommand = args[1];
|
||||
optionStart = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
optionStart = 1;
|
||||
}
|
||||
|
||||
var tail = args.Skip(optionStart).ToArray();
|
||||
var options = ParseOptions(tail, out var positional);
|
||||
|
||||
return new CliInvocation(command, subcommand, positional, options);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> ParseOptions(string[] args, out List<string> positional)
|
||||
{
|
||||
var options = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
positional = [];
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var token = args[i];
|
||||
if (!token.StartsWith("-", StringComparison.Ordinal))
|
||||
{
|
||||
positional.Add(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal))
|
||||
{
|
||||
options[token] = args[i + 1];
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
options[token] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static void ApplyPlainOutputMode()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("NO_COLOR", "1");
|
||||
Environment.SetEnvironmentVariable("SDT_NO_COLOR", "1");
|
||||
Environment.SetEnvironmentVariable("SDT_NO_UNICODE", "1");
|
||||
}
|
||||
}
|
||||
358
Program.cs
358
Program.cs
@ -1,365 +1,15 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Sdt.Bridge;
|
||||
using Sdt.Cli;
|
||||
using Sdt.Tui;
|
||||
using Spectre.Console;
|
||||
|
||||
try
|
||||
{
|
||||
var cliArgs = Environment.GetCommandLineArgs().Skip(1).ToArray();
|
||||
if (TryGetWorkspaceCommand(cliArgs, out var workspaceCommand))
|
||||
return RunWorkspaceCommand(cliArgs, workspaceCommand);
|
||||
|
||||
if (TryGetBridgeCommand(cliArgs, out var bridgeCommand))
|
||||
return await RunBridgeCommandAsync(cliArgs, bridgeCommand);
|
||||
|
||||
if (TryGetHeadlessCommand(cliArgs, out var headlessKind))
|
||||
{
|
||||
var exit = await RunHeadlessAsync(cliArgs, headlessKind);
|
||||
return exit;
|
||||
}
|
||||
|
||||
// ── Workspace + project discovery ────────────────────────────────────────
|
||||
var workspaceResult = WorkspaceLoader.FindAndLoad();
|
||||
var projectResult = ConfigLoader.FindAndLoad();
|
||||
var forceInit = cliArgs.Any(a => string.Equals(a, "init", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(a, "--init", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (forceInit)
|
||||
{
|
||||
var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory());
|
||||
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Initialized config at {path}"));
|
||||
projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot);
|
||||
}
|
||||
|
||||
if (projectResult is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No SDT project config found[/] (expected `sdtconfig-*.json` or `devtool.json`) in current directory or any parent.");
|
||||
var bootstrap = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Generate a default SDT project config for this project now?[/]",
|
||||
defaultValue: true);
|
||||
if (!bootstrap)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Create an sdtconfig-<ProjectName>.json (or devtool.json) in your project root to get started."));
|
||||
return 1;
|
||||
}
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory());
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Detected project root: {scan.ProjectRoot}"));
|
||||
if (scan.ToolFamilies.Count > 0)
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Detected tool families: {string.Join(", ", scan.ToolFamilies)}"));
|
||||
|
||||
var generated = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
var preview = ConfigBootstrapper.ToJson(generated);
|
||||
AnsiConsole.Write(new Panel(Markup.Escape(preview)).Header("Generated project config preview").BorderStyle(Theme.DimStyle));
|
||||
|
||||
var confirmWrite = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Write generated project config to {scan.ProjectRoot}?[/]",
|
||||
defaultValue: true);
|
||||
if (!confirmWrite)
|
||||
return 1;
|
||||
|
||||
var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false);
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Created {path}"));
|
||||
projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot);
|
||||
if (projectResult is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail("Generated config could not be reloaded."));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main run loop (handles workspace project switching) ────────────────
|
||||
var currentLoaded = projectResult;
|
||||
var (workspace, workspaceRoot) = workspaceResult.HasValue
|
||||
? (workspaceResult.Value.Config, workspaceResult.Value.WorkspaceRoot)
|
||||
: ((WorkspaceConfig?)null, (string?)null);
|
||||
string? pendingWorkflowId = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var app = new App(
|
||||
currentLoaded.Config,
|
||||
currentLoaded.ProjectRoot,
|
||||
currentLoaded.Warnings,
|
||||
workspace,
|
||||
workspaceRoot,
|
||||
pendingWorkflowId);
|
||||
pendingWorkflowId = null;
|
||||
|
||||
var result = await app.RunAsync();
|
||||
|
||||
if (result.Reason == AppExitReason.Quit)
|
||||
break;
|
||||
|
||||
if (result.Reason == AppExitReason.SwitchProject && result.NewProjectRoot is not null)
|
||||
{
|
||||
LoadedProjectConfig? loaded;
|
||||
try
|
||||
{
|
||||
loaded = ConfigLoader.FindAndLoad(result.NewProjectRoot);
|
||||
var app = new CliApplication();
|
||||
return await app.RunAsync(Environment.GetCommandLineArgs().Skip(1).ToArray());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail(ex.Message));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
continue;
|
||||
}
|
||||
if (loaded is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail($"No SDT project config found at: {result.NewProjectRoot}"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
continue;
|
||||
}
|
||||
|
||||
currentLoaded = loaded;
|
||||
pendingWorkflowId = result.RunWorkflowId;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var message = ex.Message;
|
||||
var isExpectedMigrationError =
|
||||
ex is InvalidOperationException &&
|
||||
message.Contains("Legacy targets-only config detected", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
AnsiConsole.MarkupLine(Theme.Fail($"Fatal: {message}"));
|
||||
if (isExpectedMigrationError)
|
||||
{
|
||||
var configPath = ConfigLoader.FindConfigPath();
|
||||
if (!string.IsNullOrWhiteSpace(configPath))
|
||||
{
|
||||
var migrate = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Apply automatic migration now (creates backup + converts targets -> workflows)?[/]",
|
||||
defaultValue: true);
|
||||
if (migrate)
|
||||
{
|
||||
var result = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
|
||||
if (result.Success)
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Ok("Migration applied successfully."));
|
||||
if (!string.IsNullOrWhiteSpace(result.BackupPath))
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Backup: {result.BackupPath}"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Run sdt.exe again in strict mode."));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Fail($"Migration failed: {result.Message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExpectedMigrationError)
|
||||
AnsiConsole.MarkupLine(Theme.Fail($"Fatal: {ex.Message}"));
|
||||
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static bool TryGetHeadlessCommand(IReadOnlyList<string> cliArgs, out string kind)
|
||||
{
|
||||
kind = "";
|
||||
if (cliArgs.Count == 0)
|
||||
return false;
|
||||
|
||||
if (string.Equals(cliArgs[0], "run", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kind = "run";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(cliArgs[0], "debug", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kind = "debug";
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryGetWorkspaceCommand(IReadOnlyList<string> cliArgs, out string command)
|
||||
{
|
||||
command = "";
|
||||
if (cliArgs.Count < 2)
|
||||
return false;
|
||||
|
||||
if (!string.Equals(cliArgs[0], "workspace", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (string.Equals(cliArgs[1], "scan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
command = "scan";
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryGetBridgeCommand(IReadOnlyList<string> cliArgs, out string command)
|
||||
{
|
||||
command = "";
|
||||
if (cliArgs.Count < 2)
|
||||
return false;
|
||||
|
||||
if (!string.Equals(cliArgs[0], "bridge", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (string.Equals(cliArgs[1], "--stdio", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
command = "stdio";
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static async Task<int> RunBridgeCommandAsync(IReadOnlyList<string> cliArgs, string command)
|
||||
{
|
||||
if (!string.Equals(command, "stdio", StringComparison.OrdinalIgnoreCase))
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
var options = ParseOptions(cliArgs.Skip(2).ToArray(), out _);
|
||||
var startDir = options.TryGetValue("--project-root", out var root) && !string.IsNullOrWhiteSpace(root)
|
||||
? root
|
||||
: Directory.GetCurrentDirectory();
|
||||
|
||||
var server = new BridgeStdioServer(startDir);
|
||||
return await server.RunAsync();
|
||||
}
|
||||
|
||||
static int RunWorkspaceCommand(IReadOnlyList<string> cliArgs, string command)
|
||||
{
|
||||
if (!string.Equals(command, "scan", StringComparison.OrdinalIgnoreCase))
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
|
||||
var options = ParseOptions(cliArgs.Skip(2).ToArray(), out _);
|
||||
var startDir = options.TryGetValue("--project-root", out var root) && !string.IsNullOrWhiteSpace(root)
|
||||
? root
|
||||
: Directory.GetCurrentDirectory();
|
||||
var asJson = options.ContainsKey("--json");
|
||||
|
||||
var workspaceLoaded = WorkspaceLoader.FindAndLoad(startDir);
|
||||
if (workspaceLoaded is null)
|
||||
{
|
||||
var payload = new { success = false, message = "No workspace could be discovered." };
|
||||
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload));
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
var (workspace, workspaceRoot) = workspaceLoaded.Value;
|
||||
var currentRoot = ConfigLoader.FindAndLoad(startDir)?.ProjectRoot ?? startDir;
|
||||
var scan = WorkspaceLoader.ScanInventory(workspaceRoot, currentRoot, workspace);
|
||||
if (asJson)
|
||||
{
|
||||
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(scan, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
}.WithEnumStrings()));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Workspace root: {scan.WorkspaceRoot}");
|
||||
Console.WriteLine($"Known projects: {scan.KnownProjects.Count}");
|
||||
Console.WriteLine($"Candidates: {scan.Candidates.Count}");
|
||||
foreach (var item in scan.Candidates)
|
||||
Console.WriteLine($" - {item.DisplayName} [{item.PrimaryKind}] {item.RootPath}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> RunHeadlessAsync(IReadOnlyList<string> cliArgs, string kind)
|
||||
{
|
||||
var options = ParseOptions(cliArgs.Skip(1).ToArray(), out var positional);
|
||||
var json = options.ContainsKey("--json");
|
||||
var startDir = options.TryGetValue("--project-root", out var projectRootOpt)
|
||||
? projectRootOpt
|
||||
: Directory.GetCurrentDirectory();
|
||||
var envProfile = options.TryGetValue("--env-profile", out var profile) ? profile : null;
|
||||
var nonInteractive = options.ContainsKey("--non-interactive") || RuntimePolicy.IsNonInteractive();
|
||||
|
||||
var loaded = ConfigLoader.FindAndLoad(startDir);
|
||||
if (loaded is null)
|
||||
{
|
||||
Console.WriteLine("{\"success\":false,\"message\":\"No SDT project config found (expected sdtconfig-*.json or devtool.json).\"}");
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
var service = new HeadlessExecutionService();
|
||||
if (kind == "run")
|
||||
{
|
||||
if (positional.Count == 0)
|
||||
{
|
||||
Console.WriteLine("{\"success\":false,\"message\":\"Missing workflow id. Usage: sdt run <workflowId> [--json] [--project-root <path>] [--env-profile <id>] [--non-interactive]\"}");
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
return await service.RunWorkflowAsync(
|
||||
loaded,
|
||||
new HeadlessRunRequest(
|
||||
WorkflowId: positional[0],
|
||||
ProjectRoot: loaded.ProjectRoot,
|
||||
EnvProfile: envProfile,
|
||||
NonInteractive: nonInteractive,
|
||||
JsonOutput: json));
|
||||
}
|
||||
|
||||
if (positional.Count == 0)
|
||||
{
|
||||
Console.WriteLine("{\"success\":false,\"message\":\"Missing debug profile id. Usage: sdt debug <profileId> [--json] [--project-root <path>] [--env-profile <id>] [--non-interactive]\"}");
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
return await service.RunDebugAsync(
|
||||
loaded,
|
||||
new HeadlessDebugRequest(
|
||||
ProfileId: positional[0],
|
||||
ProjectRoot: loaded.ProjectRoot,
|
||||
EnvProfile: envProfile,
|
||||
NonInteractive: nonInteractive,
|
||||
JsonOutput: json));
|
||||
}
|
||||
|
||||
static Dictionary<string, string?> ParseOptions(string[] args, out List<string> positional)
|
||||
{
|
||||
var options = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
positional = [];
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var token = args[i];
|
||||
if (!token.StartsWith("-", StringComparison.Ordinal))
|
||||
{
|
||||
positional.Add(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal))
|
||||
{
|
||||
options[token] = args[i + 1];
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
options[token] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
static class JsonOptionsExtensions
|
||||
{
|
||||
public static System.Text.Json.JsonSerializerOptions WithEnumStrings(this System.Text.Json.JsonSerializerOptions options)
|
||||
{
|
||||
options.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
@ -274,10 +274,3 @@ Verify workflow route/path resolution:
|
||||
python scripts/verify-workflow-routes.py --project-root .
|
||||
python scripts/verify-workflow-routes.py --project-root . --workflow build --workflow tauri --execute --env-profile dev
|
||||
```
|
||||
|
||||
## Reliability Matrix
|
||||
|
||||
- CI matrix workflow: [reliability-matrix.yml](/e:/stansshit/csharp/DevTool-master/.github/workflows/reliability-matrix.yml)
|
||||
- Runbook: [reliability-matrix-runbook.md](/e:/stansshit/csharp/DevTool-master/docs/reliability-matrix-runbook.md)
|
||||
- Results log: [reliability-matrix-results.md](/e:/stansshit/csharp/DevTool-master/docs/reliability-matrix-results.md)
|
||||
- Milestone status (Windows/Linux shipped, macOS delegated): [matrix-status.md](/e:/stansshit/csharp/DevTool-master/docs/matrix-status.md)
|
||||
|
||||
@ -109,6 +109,41 @@ def bounded_find_files(root: str, extension: str, max_depth: int) -> list[str]:
|
||||
return sorted(results)
|
||||
|
||||
|
||||
def bounded_find_matching_files(root: str, max_depth: int, matcher) -> list[str]:
|
||||
root_path = pathlib.Path(root).resolve()
|
||||
results: list[str] = []
|
||||
for current_root, dirs, files in os.walk(root_path):
|
||||
rel = pathlib.Path(current_root).resolve().relative_to(root_path)
|
||||
depth = len(rel.parts)
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_SCAN_DIRS]
|
||||
if depth > max_depth:
|
||||
dirs[:] = []
|
||||
continue
|
||||
|
||||
for name in files:
|
||||
if matcher(name):
|
||||
results.append(str(pathlib.Path(current_root) / name))
|
||||
return sorted(results)
|
||||
|
||||
|
||||
def has_python_tests(root: str) -> bool:
|
||||
config_names = {"pytest.ini", "tox.ini", "conftest.py"}
|
||||
config_hits = bounded_find_matching_files(
|
||||
root,
|
||||
max_depth=4,
|
||||
matcher=lambda name: name in config_names or name == "pyproject.toml",
|
||||
)
|
||||
if config_hits:
|
||||
return True
|
||||
|
||||
test_hits = bounded_find_matching_files(
|
||||
root,
|
||||
max_depth=5,
|
||||
matcher=lambda name: name.startswith("test_") and name.endswith(".py") or name.endswith("_test.py"),
|
||||
)
|
||||
return len(test_hits) > 0
|
||||
|
||||
|
||||
def run_dotnet_action(project_root: str, working_dir: str, verb: str) -> tuple[int, StepResult]:
|
||||
cwd = resolve_cwd(project_root, working_dir)
|
||||
target = discover_dotnet_target(project_root, cwd)
|
||||
@ -380,7 +415,27 @@ def action_python_pip_sync(args: argparse.Namespace) -> tuple[int, StepResult]:
|
||||
|
||||
def action_python_pytest(args: argparse.Namespace) -> tuple[int, StepResult]:
|
||||
cwd = resolve_cwd(args.project_root, args.working_dir)
|
||||
if not has_python_tests(cwd):
|
||||
return 0, {
|
||||
"command": resolve_python_executable(),
|
||||
"args": ["-m", "pytest"],
|
||||
"cwd": cwd,
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 0.0,
|
||||
"status": "skipped",
|
||||
"failure_reason": None,
|
||||
"skip_reason": "not_applicable_no_python_tests",
|
||||
"message": "No Python test files or pytest config detected. Skipping pytest.",
|
||||
}
|
||||
|
||||
step = run_step(resolve_python_executable(), ["-m", "pytest"], cwd)
|
||||
if int(step["exit_code"]) == 5 and not has_python_tests(cwd):
|
||||
step["exit_code"] = 0
|
||||
step["status"] = "skipped"
|
||||
step["failure_reason"] = None
|
||||
step["skip_reason"] = "not_applicable_no_python_tests"
|
||||
step["message"] = "Pytest reported no tests collected. Treating as skipped."
|
||||
return 0, step
|
||||
return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step
|
||||
|
||||
|
||||
|
||||
606
sdtconfig-DevTool-master.json
Normal file
606
sdtconfig-DevTool-master.json
Normal file
@ -0,0 +1,606 @@
|
||||
{
|
||||
"name": "DevTool-master",
|
||||
"version": "0.1.0",
|
||||
"targets": [],
|
||||
"workflows": [
|
||||
{
|
||||
"id": "web",
|
||||
"label": "Build Detected Web App",
|
||||
"description": "Build the detected web frontend only.",
|
||||
"group": "Build",
|
||||
"category": null,
|
||||
"guidedName": "build web",
|
||||
"aliases": [
|
||||
"build frontend",
|
||||
"build web app"
|
||||
],
|
||||
"tags": [
|
||||
"detected",
|
||||
"web",
|
||||
"frontend"
|
||||
],
|
||||
"dependsOn": [],
|
||||
"requireFiles": [
|
||||
"scripts\\publish-app.py",
|
||||
"src/DevTool.Host.Gui/TauriShell/package.json"
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"id": "web:run",
|
||||
"label": "python scripts\\publish-app.py --target web",
|
||||
"command": "python",
|
||||
"args": [
|
||||
"scripts\\publish-app.py",
|
||||
"--target",
|
||||
"web"
|
||||
],
|
||||
"workingDir": ".",
|
||||
"action": null,
|
||||
"actionArgs": [],
|
||||
"requires": [
|
||||
{
|
||||
"tool": "python",
|
||||
"installPolicy": "Prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tauri",
|
||||
"label": "Build Detected Tauri Desktop App",
|
||||
"description": "Build the detected Tauri desktop app. This may build frontend assets first.",
|
||||
"group": "Build",
|
||||
"category": null,
|
||||
"guidedName": "build desktop",
|
||||
"aliases": [
|
||||
"build tauri",
|
||||
"build desktop app"
|
||||
],
|
||||
"tags": [
|
||||
"detected",
|
||||
"desktop",
|
||||
"tauri"
|
||||
],
|
||||
"dependsOn": [],
|
||||
"requireFiles": [
|
||||
"scripts\\publish-app.py",
|
||||
"src/DevTool.Host.Gui/TauriShell/package.json",
|
||||
"src/DevTool.Host.Gui/TauriShell/src-tauri/tauri.conf.json"
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"id": "tauri:run",
|
||||
"label": "python scripts\\publish-app.py --target tauri --tauri-bundles none",
|
||||
"command": "python",
|
||||
"args": [
|
||||
"scripts\\publish-app.py",
|
||||
"--target",
|
||||
"tauri",
|
||||
"--tauri-bundles",
|
||||
"none"
|
||||
],
|
||||
"workingDir": ".",
|
||||
"action": null,
|
||||
"actionArgs": [],
|
||||
"requires": [
|
||||
{
|
||||
"tool": "python",
|
||||
"installPolicy": "Prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sync-output",
|
||||
"label": "Sync Newest Artifacts To output/",
|
||||
"description": "Copy the newest detected build artifacts into output/ without rebuilding.",
|
||||
"group": "Build",
|
||||
"category": null,
|
||||
"guidedName": "sync output",
|
||||
"aliases": [
|
||||
"copy artifacts",
|
||||
"refresh output"
|
||||
],
|
||||
"tags": [
|
||||
"output",
|
||||
"artifacts"
|
||||
],
|
||||
"dependsOn": [],
|
||||
"requireFiles": [
|
||||
"scripts\\sync-output.py"
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"id": "sync-output:run",
|
||||
"label": "python scripts\\sync-output.py",
|
||||
"command": "python",
|
||||
"args": [
|
||||
"scripts\\sync-output.py"
|
||||
],
|
||||
"workingDir": ".",
|
||||
"action": null,
|
||||
"actionArgs": [],
|
||||
"requires": [
|
||||
{
|
||||
"tool": "python",
|
||||
"installPolicy": "Prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "stage-output",
|
||||
"label": "Stage All Detected Outputs",
|
||||
"description": "Build and stage every detected ship target for this repo, such as web, desktop, gateway, or sidecar.",
|
||||
"group": "Build",
|
||||
"category": null,
|
||||
"guidedName": "stage all outputs",
|
||||
"aliases": [
|
||||
"stage outputs",
|
||||
"publish all outputs",
|
||||
"bundle outputs"
|
||||
],
|
||||
"tags": [
|
||||
"output",
|
||||
"detected",
|
||||
"all-targets"
|
||||
],
|
||||
"dependsOn": [],
|
||||
"requireFiles": [
|
||||
"scripts\\publish-output.py"
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"id": "stage-output:run",
|
||||
"label": "python scripts\\publish-output.py",
|
||||
"command": "python",
|
||||
"args": [
|
||||
"scripts\\publish-output.py"
|
||||
],
|
||||
"workingDir": ".",
|
||||
"action": null,
|
||||
"actionArgs": [],
|
||||
"requires": [
|
||||
{
|
||||
"tool": "python",
|
||||
"installPolicy": "Prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "publish-primary-dotnet",
|
||||
"label": "Publish sdt",
|
||||
"description": "Publish the primary .NET executable to output/ only. This does not stage every detected deliverable.",
|
||||
"group": "Build",
|
||||
"category": null,
|
||||
"guidedName": "publish cli",
|
||||
"aliases": [
|
||||
"publish exe",
|
||||
"publish tool",
|
||||
"publish main app"
|
||||
],
|
||||
"tags": [
|
||||
"dotnet",
|
||||
"publish",
|
||||
"primary"
|
||||
],
|
||||
"dependsOn": [],
|
||||
"requireFiles": [
|
||||
"DevTool.csproj"
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"id": "publish-primary-dotnet:run",
|
||||
"label": "dotnet publish DevTool.csproj -c Release -o output",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"publish",
|
||||
"DevTool.csproj",
|
||||
"-c",
|
||||
"Release",
|
||||
"-o",
|
||||
"output"
|
||||
],
|
||||
"workingDir": ".",
|
||||
"action": null,
|
||||
"actionArgs": [],
|
||||
"requires": [
|
||||
{
|
||||
"tool": "dotnet",
|
||||
"installPolicy": "Prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "build",
|
||||
"label": "Build",
|
||||
"description": "Build detected project stacks",
|
||||
"group": "Build",
|
||||
"category": null,
|
||||
"guidedName": null,
|
||||
"aliases": [],
|
||||
"tags": [],
|
||||
"dependsOn": [],
|
||||
"requireFiles": [],
|
||||
"steps": [
|
||||
{
|
||||
"id": "dotnet-build",
|
||||
"label": "dotnet build",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": ".",
|
||||
"action": "dotnet-build",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
},
|
||||
{
|
||||
"id": "npm-build",
|
||||
"label": "npm run build",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": "src\\DevTool.Host.Gui\\TauriShell",
|
||||
"action": "npm-build",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
},
|
||||
{
|
||||
"id": "cargo-build",
|
||||
"label": "cargo build",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": ".",
|
||||
"action": "cargo-build",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
},
|
||||
{
|
||||
"id": "tauri-build",
|
||||
"label": "tauri build",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": "src\\DevTool.Host.Gui\\TauriShell",
|
||||
"action": "tauri-build",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "deps-refresh",
|
||||
"label": "Refresh Dependencies",
|
||||
"description": "Restore/install dependency stacks",
|
||||
"group": "Deps",
|
||||
"category": null,
|
||||
"guidedName": null,
|
||||
"aliases": [],
|
||||
"tags": [],
|
||||
"dependsOn": [],
|
||||
"requireFiles": [],
|
||||
"steps": [
|
||||
{
|
||||
"id": "dotnet-restore",
|
||||
"label": "dotnet restore",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": ".",
|
||||
"action": "dotnet-restore",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
},
|
||||
{
|
||||
"id": "npm-ci",
|
||||
"label": "npm ci",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": "src\\DevTool.Host.Gui\\TauriShell",
|
||||
"action": "npm-ci",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test",
|
||||
"label": "Run Tests",
|
||||
"description": "Run detected test stacks",
|
||||
"group": "Test",
|
||||
"category": null,
|
||||
"guidedName": null,
|
||||
"aliases": [],
|
||||
"tags": [],
|
||||
"dependsOn": [],
|
||||
"requireFiles": [],
|
||||
"steps": [
|
||||
{
|
||||
"id": "dotnet-test",
|
||||
"label": "dotnet test",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": ".",
|
||||
"action": "dotnet-test",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
},
|
||||
{
|
||||
"id": "npm-test",
|
||||
"label": "npm test",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": "src\\DevTool.Host.Gui\\TauriShell",
|
||||
"action": "npm-test",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
},
|
||||
{
|
||||
"id": "python-pytest",
|
||||
"label": "python -m pytest",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": ".",
|
||||
"action": "python-pytest",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
},
|
||||
{
|
||||
"id": "cargo-test",
|
||||
"label": "cargo test",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": ".",
|
||||
"action": "cargo-test",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "repo-health",
|
||||
"label": "Repo Health",
|
||||
"description": "Check repo status and fetch remotes",
|
||||
"group": "Repo",
|
||||
"category": null,
|
||||
"guidedName": null,
|
||||
"aliases": [],
|
||||
"tags": [],
|
||||
"dependsOn": [],
|
||||
"requireFiles": [],
|
||||
"steps": [
|
||||
{
|
||||
"id": "git-status",
|
||||
"label": "git status",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": ".",
|
||||
"action": "git-status",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
},
|
||||
{
|
||||
"id": "git-fetch",
|
||||
"label": "git fetch",
|
||||
"command": null,
|
||||
"args": [],
|
||||
"workingDir": ".",
|
||||
"action": "git-fetch",
|
||||
"actionArgs": [],
|
||||
"requires": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"env": [
|
||||
{
|
||||
"key": "SDT_LOG_LEVEL",
|
||||
"description": "CLI log verbosity",
|
||||
"default": "information",
|
||||
"options": [
|
||||
"trace",
|
||||
"debug",
|
||||
"information",
|
||||
"warning",
|
||||
"error",
|
||||
"critical"
|
||||
]
|
||||
}
|
||||
],
|
||||
"envProfiles": {
|
||||
"active": "dev",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "dev",
|
||||
"description": "Local development defaults",
|
||||
"inherits": [],
|
||||
"values": {
|
||||
"SDT_ENV_PROFILE": "dev",
|
||||
"SDT_LOG_LEVEL": "information"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ci",
|
||||
"description": "Continuous integration defaults",
|
||||
"inherits": [
|
||||
"dev"
|
||||
],
|
||||
"values": {
|
||||
"SDT_ENV_PROFILE": "ci",
|
||||
"CI": "true",
|
||||
"SDT_LOG_LEVEL": "warning"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "release",
|
||||
"description": "Release build defaults",
|
||||
"inherits": [
|
||||
"dev"
|
||||
],
|
||||
"values": {
|
||||
"SDT_ENV_PROFILE": "release",
|
||||
"SDT_LOG_LEVEL": "warning"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"toolchains": {
|
||||
"python": {
|
||||
"executable": "python",
|
||||
"windowsExecutable": "py",
|
||||
"launcherVersion": null,
|
||||
"venvDir": ".venv",
|
||||
"profiles": [],
|
||||
"pipScript": null
|
||||
},
|
||||
"node": {
|
||||
"packageManager": "npm",
|
||||
"workingDir": "src\\DevTool.Host.Gui\\TauriShell"
|
||||
}
|
||||
},
|
||||
"tooling": {
|
||||
"defaultInstallPolicy": "Prompt",
|
||||
"tools": [
|
||||
{
|
||||
"tool": "cargo",
|
||||
"preferredInstallCommands": [],
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"tool": "dotnet",
|
||||
"preferredInstallCommands": [],
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"tool": "git",
|
||||
"preferredInstallCommands": [],
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"tool": "node",
|
||||
"preferredInstallCommands": [],
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"tool": "npm",
|
||||
"preferredInstallCommands": [],
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"tool": "python",
|
||||
"preferredInstallCommands": [],
|
||||
"executables": []
|
||||
},
|
||||
{
|
||||
"tool": "tauri",
|
||||
"preferredInstallCommands": [],
|
||||
"executables": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"project": {
|
||||
"type": "tauri",
|
||||
"rootHints": [
|
||||
"*.sln",
|
||||
".git",
|
||||
"Cargo.toml",
|
||||
"package.json",
|
||||
"scripts",
|
||||
"tauri.conf.json"
|
||||
],
|
||||
"artifacts": [
|
||||
"bin",
|
||||
"obj",
|
||||
".sdt/debug"
|
||||
]
|
||||
},
|
||||
"debug": {
|
||||
"profiles": [
|
||||
{
|
||||
"id": "dotnet-run",
|
||||
"label": "Run .NET app",
|
||||
"description": "",
|
||||
"category": null,
|
||||
"guidedName": null,
|
||||
"aliases": [],
|
||||
"tags": [],
|
||||
"type": "dotnet",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"run"
|
||||
],
|
||||
"workingDir": ".",
|
||||
"env": {},
|
||||
"requires": [
|
||||
{
|
||||
"tool": "dotnet",
|
||||
"installPolicy": "Prompt"
|
||||
}
|
||||
],
|
||||
"attach": {
|
||||
"kind": "manual",
|
||||
"port": null,
|
||||
"processName": null,
|
||||
"note": "Attach your IDE debugger to the running dotnet process."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "npm-dev",
|
||||
"label": "Run npm dev server",
|
||||
"description": "",
|
||||
"category": null,
|
||||
"guidedName": null,
|
||||
"aliases": [],
|
||||
"tags": [],
|
||||
"type": "node",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"workingDir": "src\\DevTool.Host.Gui\\TauriShell",
|
||||
"env": {},
|
||||
"requires": [
|
||||
{
|
||||
"tool": "node",
|
||||
"installPolicy": "Prompt"
|
||||
},
|
||||
{
|
||||
"tool": "npm",
|
||||
"installPolicy": "Prompt"
|
||||
}
|
||||
],
|
||||
"attach": null
|
||||
}
|
||||
],
|
||||
"diagnostics": {
|
||||
"enabled": true,
|
||||
"outputDir": ".sdt/debug",
|
||||
"includeAllEnv": false,
|
||||
"captureEnvKeys": [
|
||||
"SDT_LOG_LEVEL",
|
||||
"DOTNET_CLI_HOME",
|
||||
"NUGET_PACKAGES",
|
||||
"PIP_CACHE_DIR",
|
||||
"NVM_HOME",
|
||||
"NVM_SYMLINK"
|
||||
],
|
||||
"redactSensitive": true,
|
||||
"sensitiveKeyPatterns": [
|
||||
"TOKEN",
|
||||
"SECRET",
|
||||
"PASSWORD",
|
||||
"PWD",
|
||||
"CREDENTIAL",
|
||||
"API_KEY",
|
||||
"ACCESS_KEY",
|
||||
"PRIVATE_KEY"
|
||||
],
|
||||
"redactionAllowKeys": [],
|
||||
"bundleOnFailure": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Sdt.Core;
|
||||
|
||||
namespace Sdt.Config;
|
||||
|
||||
@ -334,10 +335,15 @@ public static class ConfigBootstrapper
|
||||
scripts.ContainsKey("sync-output.py") ||
|
||||
scripts.ContainsKey("run-webgateway.py"))
|
||||
{
|
||||
foreach (var workflow in BuildScriptDrivenWorkflows(scripts, scan.NodeWorkingDir))
|
||||
var roles = ProjectRoleDetector.Detect(scan.ProjectRoot, scan.NodeWorkingDir, scan.ProjectName);
|
||||
foreach (var workflow in BuildScriptDrivenWorkflows(scripts, roles))
|
||||
yield return workflow;
|
||||
}
|
||||
|
||||
var primaryDotnetPublish = BuildPrimaryDotnetPublishWorkflow(ProjectRoleDetector.Detect(scan.ProjectRoot, scan.NodeWorkingDir, scan.ProjectName));
|
||||
if (primaryDotnetPublish is not null)
|
||||
yield return primaryDotnetPublish;
|
||||
|
||||
var buildSteps = new List<WorkflowStep>();
|
||||
if (has("dotnet")) buildSteps.Add(StepAction("dotnet-build", "dotnet build", "dotnet-build", scan.DotnetWorkingDir));
|
||||
if (has("npm")) buildSteps.Add(StepAction("npm-build", "npm run build", "npm-build", scan.NodeWorkingDir));
|
||||
@ -617,7 +623,7 @@ public static class ConfigBootstrapper
|
||||
|
||||
private static IEnumerable<WorkflowDefinition> BuildScriptDrivenWorkflows(
|
||||
IReadOnlyDictionary<string, string> scripts,
|
||||
string? nodeWorkingDir)
|
||||
ProjectRoleSnapshot roles)
|
||||
{
|
||||
static WorkflowStep ScriptStep(string id, string label, string scriptPath, params string[] extraArgs)
|
||||
{
|
||||
@ -634,115 +640,182 @@ public static class ConfigBootstrapper
|
||||
};
|
||||
}
|
||||
|
||||
var tauriConfigRequire = ResolveTauriConfigRequirePath(nodeWorkingDir);
|
||||
var tauriConfigRequire = roles.TauriConfigRelativePath;
|
||||
var sidecarProjectRequire = roles.UniqueSidecarProject;
|
||||
var gatewayProjectRequire = roles.UniqueGatewayProject;
|
||||
var appRootRequire = roles.NodeAppPackageJsonRelativePath;
|
||||
var workflows = new List<WorkflowDefinition>();
|
||||
var generatedIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (scripts.TryGetValue("publish-sidecar.py", out var publishSidecarPath))
|
||||
if (scripts.TryGetValue("publish-sidecar.py", out var publishSidecarPath) &&
|
||||
!string.IsNullOrWhiteSpace(sidecarProjectRequire))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
workflows.Add(new WorkflowDefinition
|
||||
{
|
||||
Id = "sidecar",
|
||||
Label = "Publish Sidecar",
|
||||
Description = "Publish sidecar service",
|
||||
Label = "Publish Detected Sidecar",
|
||||
Description = "Publish the detected sidecar service only.",
|
||||
Group = "Build",
|
||||
GuidedName = "publish sidecar",
|
||||
Aliases = ["sidecar publish", "build sidecar"],
|
||||
Tags = ["detected", "sidecar", "dotnet"],
|
||||
Steps = [ScriptStep("sidecar:run", $"python {publishSidecarPath}", publishSidecarPath)],
|
||||
RequireFiles = [publishSidecarPath]
|
||||
};
|
||||
RequireFiles = [publishSidecarPath, sidecarProjectRequire!]
|
||||
});
|
||||
generatedIds.Add("sidecar");
|
||||
}
|
||||
|
||||
if (scripts.TryGetValue("publish-app.py", out var publishAppPath))
|
||||
if (scripts.TryGetValue("publish-app.py", out var publishAppPath) &&
|
||||
!string.IsNullOrWhiteSpace(appRootRequire))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
workflows.Add(new WorkflowDefinition
|
||||
{
|
||||
Id = "web",
|
||||
Label = "Build Web UI",
|
||||
Description = "Build frontend assets",
|
||||
Label = "Build Detected Web App",
|
||||
Description = "Build the detected web frontend only.",
|
||||
Group = "Build",
|
||||
GuidedName = "build web",
|
||||
Aliases = ["build frontend", "build web app"],
|
||||
Tags = ["detected", "web", "frontend"],
|
||||
Steps =
|
||||
[
|
||||
ScriptStep("web:run", $"python {publishAppPath} --target web", publishAppPath, "--target", "web")
|
||||
],
|
||||
RequireFiles = [publishAppPath]
|
||||
};
|
||||
RequireFiles = [publishAppPath, appRootRequire!]
|
||||
});
|
||||
generatedIds.Add("web");
|
||||
|
||||
yield return new WorkflowDefinition
|
||||
if (!string.IsNullOrWhiteSpace(tauriConfigRequire))
|
||||
{
|
||||
workflows.Add(new WorkflowDefinition
|
||||
{
|
||||
Id = "tauri",
|
||||
Label = "Build Tauri Desktop App",
|
||||
Description = "Build desktop binary",
|
||||
Label = "Build Detected Tauri Desktop App",
|
||||
Description = "Build the detected Tauri desktop app. This may build frontend assets first.",
|
||||
Group = "Build",
|
||||
DependsOn = scripts.ContainsKey("publish-sidecar.py") ? ["sidecar"] : [],
|
||||
GuidedName = "build desktop",
|
||||
Aliases = ["build tauri", "build desktop app"],
|
||||
Tags = ["detected", "desktop", "tauri"],
|
||||
DependsOn = generatedIds.Contains("sidecar") ? ["sidecar"] : [],
|
||||
Steps =
|
||||
[
|
||||
ScriptStep("tauri:run", $"python {publishAppPath} --target tauri --tauri-bundles none",
|
||||
publishAppPath, "--target", "tauri", "--tauri-bundles", "none")
|
||||
],
|
||||
RequireFiles = [publishAppPath, tauriConfigRequire]
|
||||
};
|
||||
RequireFiles = string.IsNullOrWhiteSpace(sidecarProjectRequire)
|
||||
? [publishAppPath, appRootRequire!, tauriConfigRequire]
|
||||
: [publishAppPath, appRootRequire!, tauriConfigRequire, sidecarProjectRequire]
|
||||
});
|
||||
generatedIds.Add("tauri");
|
||||
}
|
||||
}
|
||||
|
||||
if (scripts.TryGetValue("publish-webgateway.py", out var publishWebgatewayPath))
|
||||
if (scripts.TryGetValue("publish-webgateway.py", out var publishWebgatewayPath) &&
|
||||
!string.IsNullOrWhiteSpace(gatewayProjectRequire))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
workflows.Add(new WorkflowDefinition
|
||||
{
|
||||
Id = "webgateway",
|
||||
Label = "Publish WebGateway",
|
||||
Description = "Publish ASP.NET gateway",
|
||||
Label = "Publish Detected Web Gateway",
|
||||
Description = "Publish the detected ASP.NET gateway for this repo.",
|
||||
Group = "Build",
|
||||
DependsOn = scripts.ContainsKey("publish-app.py") ? ["web"] : [],
|
||||
GuidedName = "publish gateway",
|
||||
Aliases = ["publish webgateway", "build gateway"],
|
||||
Tags = ["detected", "gateway", "dotnet"],
|
||||
DependsOn = generatedIds.Contains("web") ? ["web"] : [],
|
||||
Steps = [ScriptStep("webgateway:run", $"python {publishWebgatewayPath}", publishWebgatewayPath)],
|
||||
RequireFiles = [publishWebgatewayPath]
|
||||
};
|
||||
RequireFiles = [publishWebgatewayPath, gatewayProjectRequire!]
|
||||
});
|
||||
generatedIds.Add("webgateway");
|
||||
}
|
||||
|
||||
if (scripts.TryGetValue("sync-output.py", out var syncOutputPath))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
workflows.Add(new WorkflowDefinition
|
||||
{
|
||||
Id = "sync-output",
|
||||
Label = "Sync Output",
|
||||
Description = "Sync newest artifacts to output",
|
||||
Label = "Sync Newest Artifacts To output/",
|
||||
Description = "Copy the newest detected build artifacts into output/ without rebuilding.",
|
||||
Group = "Build",
|
||||
GuidedName = "sync output",
|
||||
Aliases = ["copy artifacts", "refresh output"],
|
||||
Tags = ["output", "artifacts"],
|
||||
Steps = [ScriptStep("sync-output:run", $"python {syncOutputPath}", syncOutputPath)],
|
||||
RequireFiles = [syncOutputPath]
|
||||
};
|
||||
});
|
||||
generatedIds.Add("sync-output");
|
||||
}
|
||||
|
||||
if (scripts.TryGetValue("publish-output.py", out var publishOutputPath))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
workflows.Add(new WorkflowDefinition
|
||||
{
|
||||
Id = "stage-output",
|
||||
Label = "Stage Output Bundle",
|
||||
Description = "Publish and stage distributable output",
|
||||
Label = "Stage All Detected Outputs",
|
||||
Description = "Build and stage every detected ship target for this repo, such as web, desktop, gateway, or sidecar.",
|
||||
Group = "Build",
|
||||
GuidedName = "stage all outputs",
|
||||
Aliases = ["stage outputs", "publish all outputs", "bundle outputs"],
|
||||
Tags = ["output", "detected", "all-targets"],
|
||||
Steps = [ScriptStep("stage-output:run", $"python {publishOutputPath}", publishOutputPath)],
|
||||
RequireFiles = [publishOutputPath]
|
||||
};
|
||||
});
|
||||
generatedIds.Add("stage-output");
|
||||
}
|
||||
|
||||
if (scripts.TryGetValue("run-webgateway.py", out var runWebgatewayPath))
|
||||
if (scripts.TryGetValue("run-webgateway.py", out var runWebgatewayPath) &&
|
||||
!string.IsNullOrWhiteSpace(gatewayProjectRequire))
|
||||
{
|
||||
yield return new WorkflowDefinition
|
||||
workflows.Add(new WorkflowDefinition
|
||||
{
|
||||
Id = "run-gateway-dev",
|
||||
Label = "Run WebGateway Server (Dev)",
|
||||
Description = "Run gateway in development mode",
|
||||
Label = "Run Detected Web Gateway (Dev)",
|
||||
Description = "Run the detected gateway in development mode.",
|
||||
Group = "Dev",
|
||||
GuidedName = "run gateway dev",
|
||||
Aliases = ["start gateway", "gateway dev"],
|
||||
Tags = ["detected", "gateway", "dev"],
|
||||
Steps =
|
||||
[
|
||||
ScriptStep("run-gateway-dev:run", $"python {runWebgatewayPath} --mode Dev",
|
||||
runWebgatewayPath, "--mode", "Dev")
|
||||
],
|
||||
RequireFiles = [runWebgatewayPath]
|
||||
};
|
||||
}
|
||||
RequireFiles = [runWebgatewayPath, gatewayProjectRequire!]
|
||||
});
|
||||
}
|
||||
|
||||
private static string ResolveTauriConfigRequirePath(string? nodeWorkingDir)
|
||||
return workflows;
|
||||
}
|
||||
|
||||
private static WorkflowDefinition? BuildPrimaryDotnetPublishWorkflow(ProjectRoleSnapshot roles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nodeWorkingDir) || nodeWorkingDir == ".")
|
||||
return "src-tauri/tauri.conf.json";
|
||||
return Path.Combine(nodeWorkingDir, "src-tauri", "tauri.conf.json").Replace('\\', '/');
|
||||
var project = roles.PrimaryExecutableDotnetProject;
|
||||
if (project is null)
|
||||
return null;
|
||||
|
||||
return new WorkflowDefinition
|
||||
{
|
||||
Id = "publish-primary-dotnet",
|
||||
Label = $"Publish {project.DisplayName}",
|
||||
Description = "Publish the primary .NET executable to output/ only. This does not stage every detected deliverable.",
|
||||
Group = "Build",
|
||||
GuidedName = "publish cli",
|
||||
Aliases = ["publish exe", "publish tool", "publish main app"],
|
||||
Tags = ["dotnet", "publish", "primary"],
|
||||
RequireFiles = [project.RelativePath],
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep
|
||||
{
|
||||
Id = "publish-primary-dotnet:run",
|
||||
Label = $"dotnet publish {project.RelativePath} -c Release -o output",
|
||||
Command = "dotnet",
|
||||
Args = ["publish", project.RelativePath, "-c", "Release", "-o", "output"],
|
||||
WorkingDir = ".",
|
||||
Requires = [new ToolRequirement { Tool = "dotnet", InstallPolicy = InstallPolicy.Prompt }]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static string FindProjectRoot(string startDir)
|
||||
|
||||
@ -66,23 +66,10 @@ public static class ConfigLoader
|
||||
|
||||
var legacyMode = ResolveLegacyMode();
|
||||
if (legacyMode == LegacyMode.Strict && effectiveConfig.Workflows.Count == 0 && effectiveConfig.Targets.Count > 0)
|
||||
{
|
||||
var previewPath = Path.Combine(projectRoot, "devtool.generated.workflows.json");
|
||||
try
|
||||
{
|
||||
var previewConfig = WorkflowModelBuilder.BuildMigrationPreviewConfig(effectiveConfig, new RequirementResolver());
|
||||
File.WriteAllText(previewPath, ConfigBootstrapper.ToJson(previewConfig));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep strict failure even if preview generation fails.
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Legacy targets-only config detected at {configPath}. Strict mode requires workflows. " +
|
||||
"Use migration preview file 'devtool.generated.workflows.json' and migrate your config. " +
|
||||
"Temporary rollback: set SDT_LEGACY_MODE=compat.");
|
||||
}
|
||||
|
||||
var normalized = WorkflowModelBuilder.Normalize(effectiveConfig, legacyMode, new RequirementResolver());
|
||||
warnings.AddRange(normalized.Warnings);
|
||||
@ -132,6 +119,39 @@ public static class ConfigLoader
|
||||
}
|
||||
}
|
||||
|
||||
public static LegacyMigrationApplyResult WriteLegacyMigrationPreview(
|
||||
string configPath,
|
||||
string? outputPath = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(configPath))
|
||||
return new LegacyMigrationApplyResult(false, $"Config file not found: {configPath}");
|
||||
|
||||
var json = File.ReadAllText(configPath);
|
||||
var config = JsonSerializer.Deserialize<DevToolConfig>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException("Config deserialized to null.");
|
||||
|
||||
if (config.Targets.Count == 0)
|
||||
return new LegacyMigrationApplyResult(false, "No legacy targets found to preview.", ConfigPath: configPath);
|
||||
|
||||
var preview = WorkflowModelBuilder.BuildMigrationPreviewConfig(config, new RequirementResolver());
|
||||
var previewPath = string.IsNullOrWhiteSpace(outputPath)
|
||||
? Path.Combine(Path.GetDirectoryName(configPath) ?? Directory.GetCurrentDirectory(), "devtool.generated.workflows.json")
|
||||
: outputPath;
|
||||
|
||||
File.WriteAllText(previewPath, ConfigBootstrapper.ToJson(preview));
|
||||
return new LegacyMigrationApplyResult(
|
||||
true,
|
||||
"Legacy migration preview written.",
|
||||
ConfigPath: previewPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LegacyMigrationApplyResult(false, ex.Message, ConfigPath: configPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static LegacyMode ResolveLegacyMode()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
|
||||
|
||||
@ -66,6 +66,10 @@ public sealed class WorkflowDefinition
|
||||
public string Label { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
public string Group { get; init; } = "General";
|
||||
public string? Category { get; init; }
|
||||
public string? GuidedName { get; init; }
|
||||
public List<string> Aliases { get; init; } = [];
|
||||
public List<string> Tags { get; init; } = [];
|
||||
public List<string> DependsOn { get; init; } = [];
|
||||
public List<string> RequireFiles { get; init; } = [];
|
||||
public List<WorkflowStep> Steps { get; init; } = [];
|
||||
@ -131,6 +135,11 @@ public sealed class DebugProfileDefinition
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Label { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
public string? Category { get; init; }
|
||||
public string? GuidedName { get; init; }
|
||||
public List<string> Aliases { get; init; } = [];
|
||||
public List<string> Tags { get; init; } = [];
|
||||
public string Type { get; init; } = "generic";
|
||||
public string Command { get; init; } = "";
|
||||
public List<string> Args { get; init; } = [];
|
||||
|
||||
23
src/DevTool.Engine/Config/ProjectConfigFileOperations.cs
Normal file
23
src/DevTool.Engine/Config/ProjectConfigFileOperations.cs
Normal file
@ -0,0 +1,23 @@
|
||||
namespace Sdt.Config;
|
||||
|
||||
public static class ProjectConfigFileOperations
|
||||
{
|
||||
public static LegacyMigrationApplyResult SaveWithBackup(string projectRoot, DevToolConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = ConfigLoader.FindConfigPath(projectRoot);
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return new LegacyMigrationApplyResult(false, "Could not find project config for saving.");
|
||||
|
||||
var backup = path + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}";
|
||||
File.Copy(path, backup, overwrite: false);
|
||||
File.WriteAllText(path, ConfigBootstrapper.ToJson(config));
|
||||
return new LegacyMigrationApplyResult(true, "Saved updated project config.", BackupPath: backup, ConfigPath: path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new LegacyMigrationApplyResult(false, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -58,6 +58,7 @@ public static class WorkflowModelBuilder
|
||||
Targets = [],
|
||||
Workflows = ConvertLegacyTargets(config.Targets, requirementResolver),
|
||||
Env = config.Env,
|
||||
EnvProfiles = config.EnvProfiles,
|
||||
Toolchains = config.Toolchains,
|
||||
Tooling = config.Tooling,
|
||||
Project = config.Project,
|
||||
@ -90,6 +91,7 @@ public static class WorkflowModelBuilder
|
||||
Label = target.Label,
|
||||
Description = target.Description,
|
||||
Group = target.Group,
|
||||
Category = target.Group,
|
||||
DependsOn = target.DependsOn,
|
||||
Steps = step is null ? [] : [step],
|
||||
});
|
||||
|
||||
@ -27,6 +27,11 @@ public sealed class WorkspaceProject
|
||||
|
||||
public sealed class WorkspaceFavorite
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable ID for guided task invocation, for example "build" or "dev".
|
||||
/// </summary>
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative or absolute path to the project root.
|
||||
/// </summary>
|
||||
|
||||
@ -173,10 +173,10 @@ public sealed class ConfigDoctorService(
|
||||
|
||||
private static void AddPathChecks(DevToolConfig config, string projectRoot, List<DoctorCheck> checks)
|
||||
{
|
||||
var configPath = Path.Combine(projectRoot, "devtool.json");
|
||||
checks.Add(File.Exists(configPath)
|
||||
var configPath = ConfigLoader.FindConfigPath(projectRoot);
|
||||
checks.Add(!string.IsNullOrWhiteSpace(configPath)
|
||||
? new DoctorCheck("Project root", DoctorStatus.Pass, $"Config found at {configPath}")
|
||||
: new DoctorCheck("Project root", DoctorStatus.Fail, $"devtool.json not found at {configPath}", "Run SDT init/bootstrap."));
|
||||
: new DoctorCheck("Project root", DoctorStatus.Fail, $"No SDT config found under {projectRoot}", "Run SDT init/bootstrap."));
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
|
||||
@ -35,9 +35,9 @@ public static class FailureCardBuilder
|
||||
|
||||
var exactFix = reason switch
|
||||
{
|
||||
ExecutionStopReason.MissingPrereq => "sdt --init",
|
||||
ExecutionStopReason.InstallFailed => "sdt --init",
|
||||
ExecutionStopReason.UserDeclined => "sdt --init",
|
||||
ExecutionStopReason.MissingPrereq => $"sdt setup apply --safe --auto-install --json{projectArg}{envArg}",
|
||||
ExecutionStopReason.InstallFailed => $"sdt setup apply --safe --auto-install --json{projectArg}{envArg}",
|
||||
ExecutionStopReason.UserDeclined => $"sdt setup apply --safe --auto-install --json{projectArg}{envArg}",
|
||||
ExecutionStopReason.CommandFailed => $"sdt {targetKind} {targetId} --json{projectArg}{envArg}",
|
||||
_ => $"sdt {targetKind} {targetId} --json{projectArg}{envArg}",
|
||||
};
|
||||
|
||||
111
src/DevTool.Engine/Core/GuidedTaskCatalog.cs
Normal file
111
src/DevTool.Engine/Core/GuidedTaskCatalog.cs
Normal file
@ -0,0 +1,111 @@
|
||||
using Sdt.Config;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public static class GuidedTaskCatalog
|
||||
{
|
||||
public static string GetWorkflowCategory(WorkflowDefinition workflow)
|
||||
=> string.IsNullOrWhiteSpace(workflow.Category) ? workflow.Group : workflow.Category!;
|
||||
|
||||
public static string GetWorkflowDisplayLabel(WorkflowDefinition workflow)
|
||||
=> FirstNonEmpty(workflow.GuidedName, workflow.Label, workflow.Id);
|
||||
|
||||
public static string GetDebugDisplayLabel(DebugProfileDefinition profile)
|
||||
=> FirstNonEmpty(profile.GuidedName, profile.Label, profile.Id);
|
||||
|
||||
public static string GetDebugCategory(DebugProfileDefinition profile)
|
||||
=> FirstNonEmpty(profile.Category, "Debug");
|
||||
|
||||
public static WorkflowDefinition? FindWorkflow(
|
||||
IReadOnlyCollection<WorkflowDefinition> workflows,
|
||||
string? selector)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selector))
|
||||
return null;
|
||||
|
||||
return workflows.FirstOrDefault(workflow => Matches(selector, workflow.Id, workflow.Label, workflow.GuidedName, workflow.Aliases));
|
||||
}
|
||||
|
||||
public static DebugProfileDefinition? FindDebugProfile(
|
||||
IReadOnlyCollection<DebugProfileDefinition> profiles,
|
||||
string? selector)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selector))
|
||||
return null;
|
||||
|
||||
return profiles.FirstOrDefault(profile => Matches(selector, profile.Id, profile.Label, profile.GuidedName, profile.Aliases));
|
||||
}
|
||||
|
||||
public static string EnsureFavoriteId(
|
||||
WorkspaceFavorite favorite,
|
||||
string fallbackLabel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(favorite.Id))
|
||||
return favorite.Id!;
|
||||
|
||||
var baseValue = FirstNonEmpty(favorite.Label, fallbackLabel, favorite.WorkflowId);
|
||||
return Slugify(baseValue);
|
||||
}
|
||||
|
||||
public static string Slugify(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return "task";
|
||||
|
||||
var chars = new List<char>(value.Length);
|
||||
var pendingDash = false;
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
if (pendingDash && chars.Count > 0)
|
||||
chars.Add('-');
|
||||
chars.Add(char.ToLowerInvariant(ch));
|
||||
pendingDash = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
pendingDash = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (chars.Count == 0)
|
||||
return "task";
|
||||
|
||||
return new string(chars.ToArray());
|
||||
}
|
||||
|
||||
private static bool Matches(
|
||||
string selector,
|
||||
string id,
|
||||
string label,
|
||||
string? guidedName,
|
||||
IReadOnlyCollection<string> aliases)
|
||||
{
|
||||
if (string.Equals(selector, id, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(selector, label, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(selector, guidedName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
if (string.Equals(selector, alias, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value!;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Linq;
|
||||
using Sdt.Config;
|
||||
@ -11,6 +12,7 @@ public sealed record HeadlessRunRequest(
|
||||
string ProjectRoot,
|
||||
string? EnvProfile,
|
||||
bool NonInteractive,
|
||||
bool AutoInstall,
|
||||
bool JsonOutput);
|
||||
|
||||
public sealed record HeadlessDebugRequest(
|
||||
@ -18,10 +20,13 @@ public sealed record HeadlessDebugRequest(
|
||||
string ProjectRoot,
|
||||
string? EnvProfile,
|
||||
bool NonInteractive,
|
||||
bool AutoInstall,
|
||||
bool JsonOutput);
|
||||
|
||||
public sealed class HeadlessExecutionService
|
||||
{
|
||||
public const string ContractVersion = "1.1";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
@ -33,16 +38,24 @@ public sealed class HeadlessExecutionService
|
||||
JsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
}
|
||||
|
||||
private readonly WorkflowExecutor _executor = new(
|
||||
private readonly WorkflowExecutor _executor;
|
||||
private readonly IDebugProfileRunner _debugRunner;
|
||||
|
||||
public HeadlessExecutionService(
|
||||
WorkflowExecutor? executor = null,
|
||||
IDebugProfileRunner? debugRunner = null)
|
||||
{
|
||||
_executor = executor ?? new WorkflowExecutor(
|
||||
new WorkflowPlanner(),
|
||||
new ToolProbeService(),
|
||||
new PrereqInstallerService(),
|
||||
new ActionRunner(),
|
||||
new RequirementResolver());
|
||||
|
||||
private readonly IDebugProfileRunner _debugRunner = new DebugProfileRunner(
|
||||
_debugRunner = debugRunner ?? new DebugProfileRunner(
|
||||
new ToolProbeService(),
|
||||
new PrereqInstallerService());
|
||||
}
|
||||
|
||||
public async Task<int> RunWorkflowAsync(LoadedProjectConfig loaded, HeadlessRunRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@ -52,6 +65,7 @@ public sealed class HeadlessExecutionService
|
||||
{
|
||||
WriteSummary(new
|
||||
{
|
||||
contractVersion = ContractVersion,
|
||||
category = "workflow",
|
||||
success = false,
|
||||
stopReason = ExecutionStopReason.ValidationFailed,
|
||||
@ -64,7 +78,7 @@ public sealed class HeadlessExecutionService
|
||||
request.WorkflowId,
|
||||
loaded.ProjectRoot,
|
||||
request.EnvProfile),
|
||||
});
|
||||
}, jsonOutput: true);
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
@ -79,7 +93,7 @@ public sealed class HeadlessExecutionService
|
||||
RunId = runId,
|
||||
ProjectRoot = loaded.ProjectRoot,
|
||||
EnvProfile = request.EnvProfile,
|
||||
RunEventVersion = "1.0",
|
||||
RunEventVersion = ContractVersion,
|
||||
};
|
||||
eventTypes.Add(enriched.Type);
|
||||
recorder.Write(enriched);
|
||||
@ -93,7 +107,7 @@ public sealed class HeadlessExecutionService
|
||||
workflows,
|
||||
loaded.Config,
|
||||
loaded.ProjectRoot,
|
||||
confirmInstallAsync: (tool, plan) => ConfirmInstallAsync(tool, plan, request.NonInteractive),
|
||||
confirmInstallAsync: (tool, plan) => ConfirmInstallAsync(tool, plan, request.NonInteractive, request.AutoInstall),
|
||||
onOutput: (line, isErr) =>
|
||||
{
|
||||
outputLines.Add((isErr ? "ERR: " : "OUT: ") + line);
|
||||
@ -105,9 +119,10 @@ public sealed class HeadlessExecutionService
|
||||
var exit = ExitCodeMapper.FromResult(result.Success, result.StopReason);
|
||||
var summary = new
|
||||
{
|
||||
contractVersion = ContractVersion,
|
||||
category = "workflow",
|
||||
runId,
|
||||
runEventVersion = "1.0",
|
||||
runEventVersion = ContractVersion,
|
||||
success = result.Success,
|
||||
stopReason = result.StopReason,
|
||||
message = result.Message,
|
||||
@ -118,7 +133,7 @@ public sealed class HeadlessExecutionService
|
||||
? null
|
||||
: FailureCardBuilder.Build(result.StopReason, result.Message, "run", request.WorkflowId, loaded.ProjectRoot, request.EnvProfile),
|
||||
};
|
||||
WriteSummary(summary);
|
||||
WriteSummary(summary, request.JsonOutput);
|
||||
return exit;
|
||||
}
|
||||
|
||||
@ -130,6 +145,7 @@ public sealed class HeadlessExecutionService
|
||||
{
|
||||
WriteSummary(new
|
||||
{
|
||||
contractVersion = ContractVersion,
|
||||
category = "debug",
|
||||
success = false,
|
||||
stopReason = ExecutionStopReason.ValidationFailed,
|
||||
@ -142,7 +158,7 @@ public sealed class HeadlessExecutionService
|
||||
request.ProfileId,
|
||||
loaded.ProjectRoot,
|
||||
request.EnvProfile),
|
||||
});
|
||||
}, jsonOutput: true);
|
||||
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
|
||||
}
|
||||
|
||||
@ -157,7 +173,7 @@ public sealed class HeadlessExecutionService
|
||||
RunId = runId,
|
||||
ProjectRoot = loaded.ProjectRoot,
|
||||
EnvProfile = request.EnvProfile,
|
||||
RunEventVersion = "1.0",
|
||||
RunEventVersion = ContractVersion,
|
||||
};
|
||||
eventTypes.Add(enriched.Type);
|
||||
recorder.Write(enriched);
|
||||
@ -170,7 +186,7 @@ public sealed class HeadlessExecutionService
|
||||
loaded.Config,
|
||||
loaded.ProjectRoot,
|
||||
verbose: false,
|
||||
confirmInstallAsync: (tool, plan) => ConfirmInstallAsync(tool, plan, request.NonInteractive),
|
||||
confirmInstallAsync: (tool, plan) => ConfirmInstallAsync(tool, plan, request.NonInteractive, request.AutoInstall),
|
||||
onOutput: (_, _) => { },
|
||||
onEvent: Emit,
|
||||
envOverrides: envOverrides.Count == 0 ? null : envOverrides,
|
||||
@ -179,9 +195,10 @@ public sealed class HeadlessExecutionService
|
||||
var exit = ExitCodeMapper.FromResult(result.Success, result.StopReason);
|
||||
var summary = new
|
||||
{
|
||||
contractVersion = ContractVersion,
|
||||
category = "debug",
|
||||
runId,
|
||||
runEventVersion = "1.0",
|
||||
runEventVersion = ContractVersion,
|
||||
success = result.Success,
|
||||
stopReason = result.StopReason,
|
||||
message = result.Message,
|
||||
@ -192,7 +209,7 @@ public sealed class HeadlessExecutionService
|
||||
? null
|
||||
: FailureCardBuilder.Build(result.StopReason, result.Message, "debug", request.ProfileId, loaded.ProjectRoot, request.EnvProfile),
|
||||
};
|
||||
WriteSummary(summary);
|
||||
WriteSummary(summary, request.JsonOutput);
|
||||
return exit;
|
||||
}
|
||||
|
||||
@ -220,8 +237,11 @@ public sealed class HeadlessExecutionService
|
||||
};
|
||||
}
|
||||
|
||||
private static Task<bool> ConfirmInstallAsync(string tool, InstallPlan plan, bool nonInteractive)
|
||||
private static Task<bool> ConfirmInstallAsync(string tool, InstallPlan plan, bool nonInteractive, bool autoInstall)
|
||||
{
|
||||
if (autoInstall)
|
||||
return Task.FromResult(true);
|
||||
|
||||
if (nonInteractive)
|
||||
return Task.FromResult(false);
|
||||
|
||||
@ -240,6 +260,30 @@ public sealed class HeadlessExecutionService
|
||||
: LegacyMode.Strict;
|
||||
}
|
||||
|
||||
private static void WriteSummary(object payload)
|
||||
=> Console.WriteLine(JsonSerializer.Serialize(payload, JsonOptions));
|
||||
private static void WriteSummary(object payload, bool jsonOutput)
|
||||
{
|
||||
if (jsonOutput)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(payload, JsonOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
var node = JsonSerializer.SerializeToNode(payload, JsonOptions)?.AsObject();
|
||||
if (node is null)
|
||||
return;
|
||||
|
||||
var category = node["category"]?.GetValue<string>() ?? "task";
|
||||
var success = node["success"]?.GetValue<bool>() ?? false;
|
||||
var message = node["message"]?.GetValue<string>() ?? string.Empty;
|
||||
var stopReason = node["stopReason"]?.GetValue<string?>();
|
||||
var exitCode = node["exitCode"]?.GetValue<int?>();
|
||||
|
||||
Console.WriteLine($"[{category}] {(success ? "ok" : "failed")}");
|
||||
if (!string.IsNullOrWhiteSpace(message))
|
||||
Console.WriteLine(message);
|
||||
if (!string.IsNullOrWhiteSpace(stopReason))
|
||||
Console.WriteLine($"stop reason: {stopReason}");
|
||||
if (exitCode.HasValue)
|
||||
Console.WriteLine($"exit code: {exitCode.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
416
src/DevTool.Engine/Core/ProjectRoleDetector.cs
Normal file
416
src/DevTool.Engine/Core/ProjectRoleDetector.cs
Normal file
@ -0,0 +1,416 @@
|
||||
using System.Xml.Linq;
|
||||
using Sdt.Config;
|
||||
|
||||
namespace Sdt.Core;
|
||||
|
||||
public sealed record PublishableDotnetProjectInfo(string RelativePath, string DisplayName);
|
||||
|
||||
public sealed record ProjectRoleSnapshot(
|
||||
string ProjectRoot,
|
||||
string? UniqueSidecarProject,
|
||||
string? UniqueGatewayProject,
|
||||
string? NodeAppPackageJsonRelativePath,
|
||||
string? NodeAppWorkingDir,
|
||||
string? TauriConfigRelativePath,
|
||||
PublishableDotnetProjectInfo? PrimaryExecutableDotnetProject)
|
||||
{
|
||||
public bool HasNodeAppRoot => !string.IsNullOrWhiteSpace(NodeAppPackageJsonRelativePath);
|
||||
public bool HasTauriApp => !string.IsNullOrWhiteSpace(TauriConfigRelativePath);
|
||||
}
|
||||
|
||||
public static class ProjectRoleDetector
|
||||
{
|
||||
private const int MaxScanDepth = 4;
|
||||
|
||||
private static readonly HashSet<string> ExcludedDirectories = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".git",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"bin",
|
||||
"obj",
|
||||
".idea",
|
||||
".vscode",
|
||||
"dist",
|
||||
"build",
|
||||
".sdt",
|
||||
};
|
||||
|
||||
public static ProjectRoleSnapshot Detect(string projectRoot, string? preferredNodeWorkingDir = null, string? projectName = null)
|
||||
{
|
||||
var root = Path.GetFullPath(projectRoot);
|
||||
var sidecarProject = FindUniqueCsprojByKeyword(root, "sidecar");
|
||||
var gatewayProject = FindUniqueCsprojByKeyword(root, "webgateway", "gateway");
|
||||
var packageJson = ResolveNodePackageJson(root, preferredNodeWorkingDir);
|
||||
var nodeWorkingDir = string.IsNullOrWhiteSpace(packageJson)
|
||||
? null
|
||||
: Path.GetRelativePath(root, Path.GetDirectoryName(Path.Combine(root, packageJson))!);
|
||||
var tauriConfig = ResolveTauriConfig(root, nodeWorkingDir);
|
||||
var primaryExecutable = FindPrimaryExecutableDotnetProject(root, projectName);
|
||||
|
||||
return new ProjectRoleSnapshot(
|
||||
ProjectRoot: root,
|
||||
UniqueSidecarProject: sidecarProject,
|
||||
UniqueGatewayProject: gatewayProject,
|
||||
NodeAppPackageJsonRelativePath: packageJson,
|
||||
NodeAppWorkingDir: string.IsNullOrWhiteSpace(nodeWorkingDir) ? null : nodeWorkingDir,
|
||||
TauriConfigRelativePath: tauriConfig,
|
||||
PrimaryExecutableDotnetProject: primaryExecutable);
|
||||
}
|
||||
|
||||
public static bool IsWorkflowApplicable(
|
||||
string projectRoot,
|
||||
WorkflowDefinition workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinition> workflowMap,
|
||||
ProjectRoleSnapshot roles,
|
||||
HashSet<string>? visiting = null)
|
||||
{
|
||||
visiting ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!visiting.Add(workflow.Id))
|
||||
return true;
|
||||
|
||||
if (workflow.RequireFiles is not null && workflow.RequireFiles.Count > 0)
|
||||
{
|
||||
foreach (var req in workflow.RequireFiles)
|
||||
{
|
||||
var path = Path.GetFullPath(Path.Combine(projectRoot, req));
|
||||
if (!File.Exists(path) && !Directory.Exists(path))
|
||||
{
|
||||
if (req.Contains('*', StringComparison.Ordinal))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
dir = projectRoot;
|
||||
var file = Path.GetFileName(req);
|
||||
if (Directory.Exists(dir) && Directory.EnumerateFileSystemEntries(dir, file).Any())
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!HasDynamicWorkflowScope(workflow, roles))
|
||||
return false;
|
||||
|
||||
foreach (var dep in workflow.DependsOn)
|
||||
{
|
||||
if (!workflowMap.TryGetValue(dep, out var dependency))
|
||||
return false;
|
||||
if (!IsWorkflowApplicable(projectRoot, dependency, workflowMap, roles, visiting))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasDynamicWorkflowScope(WorkflowDefinition workflow, ProjectRoleSnapshot roles)
|
||||
{
|
||||
foreach (var step in workflow.Steps)
|
||||
{
|
||||
var scriptName = TryGetPythonScriptFileName(step);
|
||||
if (scriptName is null)
|
||||
continue;
|
||||
|
||||
if (string.Equals(scriptName, "publish-sidecar.py", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.IsNullOrWhiteSpace(roles.UniqueSidecarProject))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((string.Equals(scriptName, "publish-webgateway.py", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(scriptName, "run-webgateway.py", StringComparison.OrdinalIgnoreCase)) &&
|
||||
string.IsNullOrWhiteSpace(roles.UniqueGatewayProject))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(scriptName, "publish-app.py", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var target = ResolveScriptTarget(step.Args);
|
||||
if (string.Equals(target, "web", StringComparison.OrdinalIgnoreCase) && !roles.HasNodeAppRoot)
|
||||
return false;
|
||||
if (string.Equals(target, "tauri", StringComparison.OrdinalIgnoreCase) && !roles.HasTauriApp)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? TryGetPythonScriptFileName(WorkflowStep step)
|
||||
{
|
||||
if (!string.Equals(step.Command, "python", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(step.Command, "python3", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(step.Command, "py", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (step.Args.Count == 0)
|
||||
return null;
|
||||
|
||||
var first = step.Args[0];
|
||||
return first.EndsWith(".py", StringComparison.OrdinalIgnoreCase)
|
||||
? Path.GetFileName(first)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? ResolveScriptTarget(IReadOnlyList<string> args)
|
||||
{
|
||||
for (var i = 0; i < args.Count - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], "--target", StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ResolveNodePackageJson(string projectRoot, string? preferredNodeWorkingDir)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(preferredNodeWorkingDir))
|
||||
{
|
||||
var preferred = Path.Combine(projectRoot, preferredNodeWorkingDir, "package.json");
|
||||
if (File.Exists(preferred) && HasNodeScripts(preferred))
|
||||
return Path.GetRelativePath(projectRoot, preferred).Replace('\\', '/');
|
||||
}
|
||||
|
||||
var selected = SelectNodePackageJson(projectRoot);
|
||||
return selected is null
|
||||
? null
|
||||
: Path.GetRelativePath(projectRoot, selected).Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string? ResolveTauriConfig(string projectRoot, string? nodeWorkingDir)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nodeWorkingDir))
|
||||
return null;
|
||||
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(projectRoot, nodeWorkingDir, "src-tauri", "tauri.conf.json"),
|
||||
Path.Combine(projectRoot, nodeWorkingDir, "tauri.conf.json")
|
||||
};
|
||||
|
||||
var match = candidates.FirstOrDefault(File.Exists);
|
||||
return match is null
|
||||
? null
|
||||
: Path.GetRelativePath(projectRoot, match).Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string? FindUniqueCsprojByKeyword(string projectRoot, params string[] keywords)
|
||||
{
|
||||
var hits = EnumerateFilesBounded(projectRoot, "*.csproj", MaxScanDepth).ToList();
|
||||
|
||||
foreach (var keyword in keywords)
|
||||
{
|
||||
var matches = hits
|
||||
.Where(path => Path.GetFileName(path).Contains(keyword, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (matches.Count == 1)
|
||||
return Path.GetRelativePath(projectRoot, matches[0]).Replace('\\', '/');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PublishableDotnetProjectInfo? FindPrimaryExecutableDotnetProject(string projectRoot, string? projectName)
|
||||
{
|
||||
var allProjects = EnumerateFilesBounded(projectRoot, "*.csproj", MaxScanDepth).ToList();
|
||||
if (allProjects.Count == 0)
|
||||
return null;
|
||||
|
||||
PublishableDotnetProjectInfo? TryCreate(string path)
|
||||
{
|
||||
var metadata = ReadCsprojMetadata(path);
|
||||
if (!metadata.IsExecutable)
|
||||
return null;
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(metadata.AssemblyName)
|
||||
? Path.GetFileNameWithoutExtension(path)
|
||||
: metadata.AssemblyName!;
|
||||
|
||||
return new PublishableDotnetProjectInfo(
|
||||
RelativePath: Path.GetRelativePath(projectRoot, path).Replace('\\', '/'),
|
||||
DisplayName: displayName);
|
||||
}
|
||||
|
||||
var rootLevelProjects = allProjects
|
||||
.Where(path => string.Equals(Path.GetDirectoryName(path), projectRoot, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(projectName))
|
||||
{
|
||||
var directNameMatch = rootLevelProjects
|
||||
.FirstOrDefault(path => string.Equals(
|
||||
Path.GetFileNameWithoutExtension(path),
|
||||
projectName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(directNameMatch))
|
||||
{
|
||||
var project = TryCreate(directNameMatch);
|
||||
if (project is not null)
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
var rootExecutableProjects = rootLevelProjects
|
||||
.Select(TryCreate)
|
||||
.Where(project => project is not null)
|
||||
.Cast<PublishableDotnetProjectInfo>()
|
||||
.ToList();
|
||||
if (rootExecutableProjects.Count == 1)
|
||||
return rootExecutableProjects[0];
|
||||
|
||||
var executableProjects = allProjects
|
||||
.Select(TryCreate)
|
||||
.Where(project => project is not null)
|
||||
.Cast<PublishableDotnetProjectInfo>()
|
||||
.ToList();
|
||||
if (executableProjects.Count == 1)
|
||||
return executableProjects[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static CsprojMetadata ReadCsprojMetadata(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Load(path);
|
||||
var props = doc.Descendants()
|
||||
.Where(element => string.Equals(element.Name.LocalName, "PropertyGroup", StringComparison.OrdinalIgnoreCase))
|
||||
.Elements()
|
||||
.ToList();
|
||||
|
||||
string? FindValue(string name)
|
||||
{
|
||||
return props
|
||||
.FirstOrDefault(element => string.Equals(element.Name.LocalName, name, StringComparison.OrdinalIgnoreCase))
|
||||
?.Value
|
||||
?.Trim();
|
||||
}
|
||||
|
||||
var outputType = FindValue("OutputType");
|
||||
var assemblyName = FindValue("AssemblyName");
|
||||
var isExecutable = string.Equals(outputType, "Exe", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(outputType, "WinExe", StringComparison.OrdinalIgnoreCase);
|
||||
return new CsprojMetadata(isExecutable, assemblyName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new CsprojMetadata(false, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? SelectNodePackageJson(string root)
|
||||
{
|
||||
var candidates = EnumerateFilesBounded(root, "package.json", MaxScanDepth)
|
||||
.OrderBy(p => p.Length)
|
||||
.ThenBy(p => p, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return null;
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!HasNodeScripts(candidate))
|
||||
continue;
|
||||
var dir = Path.GetDirectoryName(candidate);
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
continue;
|
||||
if (IsTauriNodeRoot(dir))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (HasNodeScripts(candidate))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasNodeScripts(string packageJsonPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath));
|
||||
if (!doc.RootElement.TryGetProperty("scripts", out var scripts) ||
|
||||
scripts.ValueKind != System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var prop in scripts.EnumerateObject())
|
||||
keys.Add(prop.Name);
|
||||
|
||||
return keys.Contains("build") ||
|
||||
keys.Contains("test") ||
|
||||
keys.Contains("dev") ||
|
||||
keys.Contains("start") ||
|
||||
keys.Contains("tauri");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTauriNodeRoot(string nodeDir)
|
||||
{
|
||||
return File.Exists(Path.Combine(nodeDir, "src-tauri", "tauri.conf.json")) ||
|
||||
File.Exists(Path.Combine(nodeDir, "tauri.conf.json"));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateFilesBounded(string root, string pattern, int maxDepth)
|
||||
{
|
||||
var queue = new Queue<(string Dir, int Depth)>();
|
||||
queue.Enqueue((root, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (dir, depth) = queue.Dequeue();
|
||||
IEnumerable<string> files = [];
|
||||
try
|
||||
{
|
||||
files = Directory.EnumerateFiles(dir, pattern, SearchOption.TopDirectoryOnly);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
foreach (var file in files)
|
||||
yield return file;
|
||||
|
||||
if (depth >= maxDepth)
|
||||
continue;
|
||||
|
||||
IEnumerable<string> subdirs = [];
|
||||
try
|
||||
{
|
||||
subdirs = Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
foreach (var subdir in subdirs)
|
||||
{
|
||||
var name = Path.GetFileName(subdir);
|
||||
if (ExcludedDirectories.Contains(name))
|
||||
continue;
|
||||
|
||||
queue.Enqueue((subdir, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CsprojMetadata(bool IsExecutable, string? AssemblyName);
|
||||
}
|
||||
@ -127,6 +127,7 @@ public sealed class BridgeStdioServer
|
||||
}),
|
||||
favorites = workspace.Favorites.Select(f => new
|
||||
{
|
||||
id = GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId),
|
||||
projectPath = f.ProjectPath,
|
||||
workflowId = f.WorkflowId,
|
||||
label = f.Label,
|
||||
@ -189,6 +190,7 @@ public sealed class BridgeStdioServer
|
||||
var (workspace, workspaceRoot) = loaded;
|
||||
return workspace.Favorites.Select(f => new
|
||||
{
|
||||
id = GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId),
|
||||
projectPath = f.ProjectPath,
|
||||
workflowId = f.WorkflowId,
|
||||
label = f.Label,
|
||||
@ -202,6 +204,7 @@ public sealed class BridgeStdioServer
|
||||
var projectPath = GetRequiredString(@params, "favoriteProjectPath");
|
||||
var workflowId = GetRequiredString(@params, "workflowId");
|
||||
var label = GetString(@params, "label");
|
||||
var id = GetString(@params, "id");
|
||||
|
||||
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
|
||||
var (workspace, workspaceRoot) = loaded;
|
||||
@ -220,6 +223,7 @@ public sealed class BridgeStdioServer
|
||||
{
|
||||
workspace.Favorites.Add(new WorkspaceFavorite
|
||||
{
|
||||
Id = string.IsNullOrWhiteSpace(id) ? GuidedTaskCatalog.Slugify(label ?? workflowId) : id,
|
||||
ProjectPath = relativeProject,
|
||||
WorkflowId = workflowId,
|
||||
Label = string.IsNullOrWhiteSpace(label) ? null : label
|
||||
|
||||
@ -25,6 +25,7 @@ public sealed class App
|
||||
private bool _firstRunPromptShown;
|
||||
private string? _activeEnvProfile;
|
||||
private string? _startupWorkflowId;
|
||||
private ProjectRoleSnapshot _projectRoles;
|
||||
|
||||
private readonly WorkflowExecutor _executor = new(
|
||||
new WorkflowPlanner(),
|
||||
@ -50,8 +51,13 @@ public sealed class App
|
||||
_workspaceRoot = workspaceRoot;
|
||||
_startupWorkflowId = startupWorkflowId;
|
||||
_activeEnvProfile = config.EnvProfiles?.Active;
|
||||
_projectRoles = ProjectRoleDetector.Detect(projectRoot, config.Toolchains?.Node?.WorkingDir, config.Name);
|
||||
var normalized = WorkflowModelBuilder.Normalize(config, ResolveLegacyMode(), _requirementResolver);
|
||||
_workflows = normalized.Workflows.Where(IsWorkflowApplicable).ToList();
|
||||
var normalizedWorkflows = normalized.Workflows.ToList();
|
||||
var normalizedMap = normalizedWorkflows.ToDictionary(w => w.Id, StringComparer.OrdinalIgnoreCase);
|
||||
_workflows = normalizedWorkflows
|
||||
.Where(w => ProjectRoleDetector.IsWorkflowApplicable(_projectRoot, w, normalizedMap, _projectRoles, new HashSet<string>(StringComparer.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
_warnings = [];
|
||||
_debugRunner = new DebugProfileRunner(new ToolProbeService(), new PrereqInstallerService());
|
||||
_diagnostics = new DiagnosticsBundleService();
|
||||
@ -60,30 +66,6 @@ public sealed class App
|
||||
_warnings.AddRange(normalized.Warnings);
|
||||
}
|
||||
|
||||
private bool IsWorkflowApplicable(WorkflowDefinition workflow)
|
||||
{
|
||||
if (workflow.RequireFiles is null || workflow.RequireFiles.Count == 0)
|
||||
return true;
|
||||
|
||||
foreach (var req in workflow.RequireFiles)
|
||||
{
|
||||
var path = Path.GetFullPath(Path.Combine(_projectRoot, req));
|
||||
if (!File.Exists(path) && !Directory.Exists(path))
|
||||
{
|
||||
if (req.Contains('*'))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (string.IsNullOrWhiteSpace(dir)) dir = _projectRoot;
|
||||
var file = Path.GetFileName(req);
|
||||
if (Directory.Exists(dir) && Directory.EnumerateFileSystemEntries(dir, file).Any())
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static LegacyMode ResolveLegacyMode()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
|
||||
@ -107,6 +89,8 @@ public sealed class App
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
RenderQuickStartPanel();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_startupWorkflowId))
|
||||
{
|
||||
var requestedWorkflow = _startupWorkflowId;
|
||||
@ -121,7 +105,7 @@ public sealed class App
|
||||
continue;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine(Theme.Warn($"Requested quick action '{requestedWorkflow}' was not found in this project."));
|
||||
AnsiConsole.MarkupLine(Theme.Warn($"Requested favorite '{requestedWorkflow}' was not found in this project."));
|
||||
AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to continue..."));
|
||||
Console.ReadKey(intercept: true);
|
||||
}
|
||||
@ -301,10 +285,28 @@ public sealed class App
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"root: {_projectRoot}") + "\n");
|
||||
}
|
||||
|
||||
private void RenderQuickStartPanel()
|
||||
{
|
||||
var activeProfile = _activeEnvProfile ?? _config.EnvProfiles?.Active ?? "dev";
|
||||
var lines = new List<string>
|
||||
{
|
||||
"Quick start",
|
||||
$"- Favorites: run your saved tasks without remembering workflow IDs",
|
||||
$"- Setup: bootstrap tools, env, and recommended config",
|
||||
$"- Doctor: validate project config and missing tools",
|
||||
$"- Environment: current profile is {activeProfile}",
|
||||
};
|
||||
|
||||
AnsiConsole.Write(new Panel(string.Join(Environment.NewLine, lines))
|
||||
.Header("What do you want to do?")
|
||||
.BorderStyle(Theme.DimStyle));
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
private string ShowMainMenu()
|
||||
{
|
||||
var prompt = new SelectionPrompt<MenuItem>()
|
||||
.Title($"[{Theme.Green}]What would you like to do?[/]")
|
||||
.Title($"[{Theme.Green}]What do you want to do today?[/]")
|
||||
.PageSize(28)
|
||||
.MoreChoicesText(Theme.Faint("(scroll to see more)"))
|
||||
.UseConverter(m => m.Display);
|
||||
@ -313,13 +315,13 @@ public sealed class App
|
||||
if (favoriteItems.Count > 0)
|
||||
{
|
||||
prompt.AddChoiceGroup(
|
||||
new MenuItem($"[bold {Theme.Amber}]QUICK ACTIONS[/]", "__group__"),
|
||||
new MenuItem($"[bold {Theme.Amber}]FAVORITES[/]", "__group__"),
|
||||
favoriteItems);
|
||||
}
|
||||
|
||||
var groups = _workflows
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t.Label))
|
||||
.GroupBy(t => string.IsNullOrWhiteSpace(t.Group) ? "General" : t.Group);
|
||||
.GroupBy(GuidedTaskCatalog.GetWorkflowCategory);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
@ -328,7 +330,7 @@ public sealed class App
|
||||
"__group__");
|
||||
|
||||
var items = group.Select(t => new MenuItem(
|
||||
$"[{Theme.Green}]{Markup.Escape(t.Label)}[/] [{Theme.GreenDim}]{Markup.Escape(t.Description)}[/]",
|
||||
$"[{Theme.Green}]{Markup.Escape(GuidedTaskCatalog.GetWorkflowDisplayLabel(t))}[/] [{Theme.GreenDim}]{Markup.Escape(t.Description)}[/]",
|
||||
t.Id)).ToList();
|
||||
|
||||
prompt.AddChoiceGroup(header, items);
|
||||
@ -341,15 +343,15 @@ public sealed class App
|
||||
foreach (var profile in debugProfiles)
|
||||
{
|
||||
debugItems.Add(new MenuItem(
|
||||
$"[{Theme.Green}]Run {Markup.Escape(profile.Label)}[/] [{Theme.GreenDim}]debug profile[/]",
|
||||
$"[{Theme.Green}]Run {Markup.Escape(GuidedTaskCatalog.GetDebugDisplayLabel(profile))}[/] [{Theme.GreenDim}]{Markup.Escape(string.IsNullOrWhiteSpace(profile.Description) ? "debug profile" : profile.Description)}[/]",
|
||||
$"__debug__:{profile.Id}:normal"));
|
||||
debugItems.Add(new MenuItem(
|
||||
$"[{Theme.Green}]Run {Markup.Escape(profile.Label)} (verbose)[/] [{Theme.GreenDim}]stream full output[/]",
|
||||
$"[{Theme.Green}]Run {Markup.Escape(GuidedTaskCatalog.GetDebugDisplayLabel(profile))} (verbose)[/] [{Theme.GreenDim}]stream full output[/]",
|
||||
$"__debug__:{profile.Id}:verbose"));
|
||||
if (profile.Attach is not null)
|
||||
{
|
||||
debugItems.Add(new MenuItem(
|
||||
$"[{Theme.Green}]Attach instructions: {Markup.Escape(profile.Label)}[/]",
|
||||
$"[{Theme.Green}]Attach instructions: {Markup.Escape(GuidedTaskCatalog.GetDebugDisplayLabel(profile))}[/]",
|
||||
$"__debugattach__:{profile.Id}"));
|
||||
}
|
||||
}
|
||||
@ -373,7 +375,7 @@ public sealed class App
|
||||
|
||||
if (_workspace is not null)
|
||||
systemItems.Insert(0, new MenuItem(
|
||||
$"[{Theme.Green}]★ Manage quick actions[/] [{Theme.GreenDim}]favorite workflows across projects[/]",
|
||||
$"[{Theme.Green}]★ Manage favorites[/] [{Theme.GreenDim}]saved tasks across projects[/]",
|
||||
"__favorites_manage__"));
|
||||
|
||||
if (_config.Toolchains is not null)
|
||||
@ -764,15 +766,21 @@ public sealed class App
|
||||
|
||||
foreach (var workflow in _workflows)
|
||||
{
|
||||
var aliases = workflow.Aliases.Count == 0
|
||||
? string.Empty
|
||||
: $" [{Theme.GreenDim}]aliases: {Markup.Escape(string.Join(", ", workflow.Aliases))}[/]";
|
||||
items.Add(new PaletteItem(
|
||||
$"[{Theme.Green}]Run workflow:[/] {Markup.Escape(workflow.Label)} [{Theme.GreenDim}]({Markup.Escape(workflow.Id)})[/]",
|
||||
$"[{Theme.Green}]Run workflow:[/] {Markup.Escape(GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow))} [{Theme.GreenDim}]({Markup.Escape(workflow.Id)})[/]{aliases}",
|
||||
$"workflow:{workflow.Id}"));
|
||||
}
|
||||
|
||||
foreach (var profile in _config.Debug?.Profiles ?? [])
|
||||
{
|
||||
var aliases = profile.Aliases.Count == 0
|
||||
? string.Empty
|
||||
: $" [{Theme.GreenDim}]aliases: {Markup.Escape(string.Join(", ", profile.Aliases))}[/]";
|
||||
items.Add(new PaletteItem(
|
||||
$"[{Theme.Green}]Run debug:[/] {Markup.Escape(profile.Label)} [{Theme.GreenDim}]({Markup.Escape(profile.Id)})[/]",
|
||||
$"[{Theme.Green}]Run debug:[/] {Markup.Escape(GuidedTaskCatalog.GetDebugDisplayLabel(profile))} [{Theme.GreenDim}]({Markup.Escape(profile.Id)})[/]{aliases}",
|
||||
$"debug:{profile.Id}"));
|
||||
}
|
||||
|
||||
@ -853,7 +861,7 @@ public sealed class App
|
||||
return;
|
||||
|
||||
var pin = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Pin '{Markup.Escape(workflow.Label)}' as a workspace quick action?[/]",
|
||||
$"[{Theme.Amber}]Pin '{Markup.Escape(GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow))}' as a workspace favorite?[/]",
|
||||
defaultValue: false);
|
||||
if (!pin)
|
||||
return;
|
||||
@ -867,12 +875,13 @@ public sealed class App
|
||||
|
||||
_workspace.Favorites.Add(new WorkspaceFavorite
|
||||
{
|
||||
Id = GuidedTaskCatalog.Slugify(GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow)),
|
||||
ProjectPath = projectPath,
|
||||
WorkflowId = workflow.Id,
|
||||
Label = workflow.Label
|
||||
Label = GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow)
|
||||
});
|
||||
WorkspaceLoader.Save(_workspaceRoot, _workspace);
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Pinned quick action: {workflow.Id}"));
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Pinned favorite: {workflow.Id}"));
|
||||
}
|
||||
|
||||
private List<MenuItem> BuildFavoriteMenuItems()
|
||||
@ -891,7 +900,9 @@ public sealed class App
|
||||
: null;
|
||||
|
||||
var projectName = ResolveWorkspaceProjectName(projectRoot);
|
||||
var workflowLabel = workflow?.Label ?? favorite.WorkflowId;
|
||||
var workflowLabel = workflow is null
|
||||
? favorite.WorkflowId
|
||||
: GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow);
|
||||
var detail = isCurrent
|
||||
? "current project"
|
||||
: projectName;
|
||||
@ -899,8 +910,9 @@ public sealed class App
|
||||
var displayLabel = !string.IsNullOrWhiteSpace(favorite.Label)
|
||||
? favorite.Label!
|
||||
: workflowLabel;
|
||||
var favoriteId = GuidedTaskCatalog.EnsureFavoriteId(favorite, workflowLabel);
|
||||
|
||||
var display = $"[{Theme.Green}]▶ {Markup.Escape(displayLabel)}[/] [{Theme.GreenDim}]{Markup.Escape(detail)}[/]";
|
||||
var display = $"[{Theme.Green}]▶ {Markup.Escape(displayLabel)}[/] [{Theme.GreenDim}]{Markup.Escape(favoriteId)} • {Markup.Escape(detail)}[/]";
|
||||
items.Add(new MenuItem(display, $"__favorite__:{index}"));
|
||||
index++;
|
||||
}
|
||||
@ -966,8 +978,13 @@ public sealed class App
|
||||
if (reloaded is null)
|
||||
return;
|
||||
_config = reloaded.Config;
|
||||
_projectRoles = ProjectRoleDetector.Detect(_projectRoot, _config.Toolchains?.Node?.WorkingDir, _config.Name);
|
||||
var normalized = WorkflowModelBuilder.Normalize(_config, ResolveLegacyMode(), _requirementResolver);
|
||||
_workflows = normalized.Workflows.ToList();
|
||||
var normalizedWorkflows = normalized.Workflows.ToList();
|
||||
var normalizedMap = normalizedWorkflows.ToDictionary(w => w.Id, StringComparer.OrdinalIgnoreCase);
|
||||
_workflows = normalizedWorkflows
|
||||
.Where(w => ProjectRoleDetector.IsWorkflowApplicable(_projectRoot, w, normalizedMap, _projectRoles, new HashSet<string>(StringComparer.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
_warnings.Clear();
|
||||
_warnings.AddRange(reloaded.Warnings);
|
||||
_warnings.AddRange(normalized.Warnings);
|
||||
@ -1233,9 +1250,9 @@ public sealed class App
|
||||
while (true)
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
AnsiConsole.Write(Theme.SectionRule("QUICK ACTIONS"));
|
||||
AnsiConsole.Write(Theme.SectionRule("FAVORITES"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint($"Project: {_config.Name}"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Select a workflow to toggle as a workspace quick action.\n"));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("Select a workflow to save or remove as a workspace favorite.\n"));
|
||||
|
||||
var choices = _workflows
|
||||
.Select(w =>
|
||||
@ -1245,16 +1262,16 @@ public sealed class App
|
||||
string.Equals(f.WorkflowId, w.Id, StringComparison.OrdinalIgnoreCase));
|
||||
var marker = isFavorite ? "★" : " ";
|
||||
return new MenuItem(
|
||||
$"[{Theme.Green}]{marker} {Markup.Escape(w.Label)}[/] [{Theme.GreenDim}]{Markup.Escape(w.Description)}[/]",
|
||||
$"[{Theme.Green}]{marker} {Markup.Escape(GuidedTaskCatalog.GetWorkflowDisplayLabel(w))}[/] [{Theme.GreenDim}]{Markup.Escape(w.Description)}[/]",
|
||||
w.Id);
|
||||
})
|
||||
.Append(new MenuItem($"[{Theme.Green}]🧹 Prune invalid quick actions[/]", "__prune__"))
|
||||
.Append(new MenuItem($"[{Theme.Green}]🧹 Prune invalid favorites[/]", "__prune__"))
|
||||
.Append(new MenuItem(Theme.Faint("← Back"), "__back__"))
|
||||
.ToList();
|
||||
|
||||
var selected = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<MenuItem>()
|
||||
.Title($"[{Theme.Green}]Quick action manager:[/]")
|
||||
.Title($"[{Theme.Green}]Favorite manager:[/]")
|
||||
.PageSize(24)
|
||||
.UseConverter(m => m.Display)
|
||||
.AddChoices(choices));
|
||||
@ -1268,11 +1285,11 @@ public sealed class App
|
||||
if (removed > 0)
|
||||
{
|
||||
WorkspaceLoader.Save(_workspaceRoot, _workspace);
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Removed {removed} invalid quick action(s)."));
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Removed {removed} invalid favorite(s)."));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine(Theme.Faint("No invalid quick actions found."));
|
||||
AnsiConsole.MarkupLine(Theme.Faint("No invalid favorites found."));
|
||||
}
|
||||
|
||||
Thread.Sleep(700);
|
||||
@ -1288,18 +1305,19 @@ public sealed class App
|
||||
{
|
||||
_workspace.Favorites.Remove(existing);
|
||||
WorkspaceLoader.Save(_workspaceRoot, _workspace);
|
||||
AnsiConsole.MarkupLine(Theme.Warn($"Removed quick action: {workflowId}"));
|
||||
AnsiConsole.MarkupLine(Theme.Warn($"Removed favorite: {workflowId}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
_workspace.Favorites.Add(new WorkspaceFavorite
|
||||
{
|
||||
Id = GuidedTaskCatalog.Slugify(_workflows.First(w => string.Equals(w.Id, workflowId, StringComparison.OrdinalIgnoreCase)).Label),
|
||||
ProjectPath = projectPath,
|
||||
WorkflowId = workflowId,
|
||||
Label = _workflows.First(w => string.Equals(w.Id, workflowId, StringComparison.OrdinalIgnoreCase)).Label
|
||||
});
|
||||
WorkspaceLoader.Save(_workspaceRoot, _workspace);
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Added quick action: {workflowId}"));
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Added favorite: {workflowId}"));
|
||||
}
|
||||
|
||||
Thread.Sleep(700);
|
||||
|
||||
@ -125,7 +125,7 @@ public sealed class EventsScreen(
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
var pin = AnsiConsole.Confirm(
|
||||
$"[{Theme.Amber}]Pin workflow '{Markup.Escape(workflowId!)}' as a quick action?[/]",
|
||||
$"[{Theme.Amber}]Pin workflow '{Markup.Escape(workflowId!)}' as a favorite?[/]",
|
||||
defaultValue: false);
|
||||
if (pin)
|
||||
{
|
||||
@ -160,12 +160,13 @@ public sealed class EventsScreen(
|
||||
|
||||
_workspace.Favorites.Add(new WorkspaceFavorite
|
||||
{
|
||||
Id = GuidedTaskCatalog.Slugify(workflowId),
|
||||
ProjectPath = projectPath,
|
||||
WorkflowId = workflowId,
|
||||
Label = workflowId
|
||||
});
|
||||
WorkspaceLoader.Save(_workspaceRoot, _workspace);
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Pinned quick action: {workflowId}"));
|
||||
AnsiConsole.MarkupLine(Theme.Ok($"Pinned favorite: {workflowId}"));
|
||||
}
|
||||
|
||||
private void RenderHeader(string section)
|
||||
|
||||
31
tests/DevTool.Tests/CliParserTests.cs
Normal file
31
tests/DevTool.Tests/CliParserTests.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using Sdt.Cli;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class CliParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_SetupApply_RecognizesSubcommandAndFlags()
|
||||
{
|
||||
var invocation = CliParser.Parse(["setup", "apply", "--safe", "--json", "--project-root", "C:\\repo"]);
|
||||
|
||||
Assert.Equal("setup", invocation.Command);
|
||||
Assert.Equal("apply", invocation.Subcommand);
|
||||
Assert.True(invocation.HasOption("--safe"));
|
||||
Assert.True(invocation.HasOption("--json"));
|
||||
Assert.Equal("C:\\repo", invocation.GetOption("--project-root"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Run_PreservesPositionalWorkflowSelector()
|
||||
{
|
||||
var invocation = CliParser.Parse(["run", "build-app", "--env-profile", "release"]);
|
||||
|
||||
Assert.Equal("run", invocation.Command);
|
||||
Assert.Null(invocation.Subcommand);
|
||||
Assert.Single(invocation.Positionals);
|
||||
Assert.Equal("build-app", invocation.Positionals[0]);
|
||||
Assert.Equal("release", invocation.GetOption("--env-profile"));
|
||||
}
|
||||
}
|
||||
@ -46,8 +46,9 @@ public sealed class ConfigBootstrapperTests
|
||||
[Fact]
|
||||
public void BuildDefaultConfig_ProducesWorkflowsAndDebugSection()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
var scan = new BootstrapScanResult(
|
||||
ProjectRoot: Path.GetTempPath(),
|
||||
ProjectRoot: root,
|
||||
ProjectName: "demo",
|
||||
ProjectType: "dotnet",
|
||||
ToolFamilies: ["dotnet", "git"],
|
||||
@ -76,14 +77,98 @@ public sealed class ConfigBootstrapperTests
|
||||
var root = CreateTempDir();
|
||||
var scripts = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(scripts);
|
||||
Directory.CreateDirectory(Path.Combine(root, "ui", "src-tauri"));
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-sidecar.py"), "print('ok')");
|
||||
File.WriteAllText(Path.Combine(root, "SidecarService.csproj"), "<Project Sdk=\"Microsoft.NET.Sdk\" />");
|
||||
File.WriteAllText(Path.Combine(root, "ui", "package.json"), """{ "name": "demo-ui", "scripts": { "build": "echo build", "tauri": "echo tauri" } }""");
|
||||
File.WriteAllText(Path.Combine(root, "ui", "src-tauri", "tauri.conf.json"), "{}");
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(root);
|
||||
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
|
||||
Assert.Contains(cfg.Workflows, w => w.Id == "web");
|
||||
Assert.Contains(cfg.Workflows, w => w.Id == "sidecar");
|
||||
Assert.Contains(cfg.Workflows, w => w.Id == "tauri");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDefaultConfig_DoesNotReferenceMissingSidecarDependency()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
var scripts = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(scripts);
|
||||
Directory.CreateDirectory(Path.Combine(root, "ui", "src-tauri"));
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-sidecar.py"), "print('ok')");
|
||||
File.WriteAllText(Path.Combine(root, "ui", "package.json"), """{ "name": "demo-ui", "scripts": { "build": "echo build", "tauri": "echo tauri" } }""");
|
||||
File.WriteAllText(Path.Combine(root, "ui", "src-tauri", "tauri.conf.json"), "{}");
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(root);
|
||||
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
|
||||
Assert.DoesNotContain(cfg.Workflows, w => w.Id == "sidecar");
|
||||
var tauri = Assert.Single(cfg.Workflows, w => w.Id == "tauri");
|
||||
Assert.Empty(tauri.DependsOn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDefaultConfig_DoesNotGenerateTauriWorkflow_WithoutTauriConfig()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
var scripts = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(scripts);
|
||||
Directory.CreateDirectory(Path.Combine(root, "ui"));
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
|
||||
File.WriteAllText(Path.Combine(root, "ui", "package.json"), """{ "name": "demo-ui", "scripts": { "build": "echo build" } }""");
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(root);
|
||||
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
|
||||
Assert.Contains(cfg.Workflows, w => w.Id == "web");
|
||||
Assert.DoesNotContain(cfg.Workflows, w => w.Id == "tauri");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDefaultConfig_AddsPrimaryDotnetPublishWorkflow_ForRootExecutableProject()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
File.WriteAllText(Path.Combine(root, "demo.csproj"), """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>sdt</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
""");
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(root);
|
||||
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
|
||||
var publish = Assert.Single(cfg.Workflows, w => w.Id == "publish-primary-dotnet");
|
||||
Assert.Equal("Publish sdt", publish.Label, ignoreCase: true);
|
||||
Assert.Contains("output/", publish.Description, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("publish cli", publish.GuidedName, ignoreCase: true);
|
||||
Assert.Contains("publish tool", publish.Aliases, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Equal("dotnet", Assert.Single(publish.Steps[0].Requires).Tool, ignoreCase: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDefaultConfig_ClarifiesStageOutputScope()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
var scripts = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(scripts);
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-output.py"), "print('ok')");
|
||||
|
||||
var scan = ConfigBootstrapper.Scan(root);
|
||||
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
|
||||
|
||||
var stage = Assert.Single(cfg.Workflows, w => w.Id == "stage-output");
|
||||
Assert.Equal("Stage All Detected Outputs", stage.Label, ignoreCase: true);
|
||||
Assert.Contains("every detected ship target", stage.Description, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("stage all outputs", stage.GuidedName, ignoreCase: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -37,7 +37,7 @@ public sealed class HeadlessExecutionTests
|
||||
var result = await RunSdtAsync(["run", "build", "--json", "--project-root", root]);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.Contains("\"run_event_version\":\"1.0\"", result.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains($"\"run_event_version\":\"{Sdt.Core.HeadlessExecutionService.ContractVersion}\"", result.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("\"event_type\":\"WorkflowStarted\"", result.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("\"category\":\"workflow\"", result.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("\"success\":true", result.StdOut, StringComparison.Ordinal);
|
||||
@ -150,6 +150,43 @@ public sealed class HeadlessExecutionTests
|
||||
Assert.Contains("\"retryInstruction\"", result.StdOut, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HeadlessRun_AllowsGuidedAliasSelection()
|
||||
{
|
||||
var root = CreateTempProject("""
|
||||
{
|
||||
"name": "headless-demo",
|
||||
"version": "0.1.0",
|
||||
"workflows": [
|
||||
{
|
||||
"id": "build",
|
||||
"label": "Build project",
|
||||
"guidedName": "build app",
|
||||
"aliases": ["app", "compile"],
|
||||
"description": "Build",
|
||||
"group": "Build",
|
||||
"dependsOn": [],
|
||||
"steps": [
|
||||
{
|
||||
"id": "s1",
|
||||
"label": "dotnet --version",
|
||||
"command": "dotnet",
|
||||
"args": ["--version"],
|
||||
"workingDir": ".",
|
||||
"requires": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await RunSdtAsync(["run", "app", "--json", "--project-root", root]);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.Contains("\"success\":true", result.StdOut, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string StdOut, string StdErr)> RunSdtAsync(IReadOnlyList<string> args)
|
||||
{
|
||||
var exe = Path.Combine(AppContext.BaseDirectory, OperatingSystem.IsWindows() ? "sdt.exe" : "sdt");
|
||||
|
||||
@ -7,7 +7,7 @@ namespace DevTool.Tests;
|
||||
public sealed class LegacyModeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConfigLoader_StrictMode_TargetsOnly_FailsAndWritesPreview()
|
||||
public void ConfigLoader_StrictMode_TargetsOnly_FailsWithoutWritingPreview()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
WriteLegacyTargetsOnlyConfig(root);
|
||||
@ -15,7 +15,7 @@ public sealed class LegacyModeTests
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => ConfigLoader.FindAndLoad(root));
|
||||
Assert.Contains("Strict mode requires workflows", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(File.Exists(Path.Combine(root, "devtool.generated.workflows.json")));
|
||||
Assert.False(File.Exists(Path.Combine(root, "devtool.generated.workflows.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -56,6 +56,19 @@ public sealed class LegacyModeTests
|
||||
Assert.Empty(loaded.Config.Targets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteLegacyMigrationPreview_WritesPreviewOnlyWhenExplicitlyRequested()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
WriteLegacyTargetsOnlyConfig(root);
|
||||
var path = Path.Combine(root, "devtool.json");
|
||||
|
||||
var result = ConfigLoader.WriteLegacyMigrationPreview(path);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(File.Exists(Path.Combine(root, "devtool.generated.workflows.json")));
|
||||
}
|
||||
|
||||
private static void WriteLegacyTargetsOnlyConfig(string root)
|
||||
{
|
||||
var cfg = new DevToolConfig
|
||||
|
||||
98
tests/DevTool.Tests/ProjectRoleDetectorTests.cs
Normal file
98
tests/DevTool.Tests/ProjectRoleDetectorTests.cs
Normal file
@ -0,0 +1,98 @@
|
||||
using Sdt.Config;
|
||||
using Sdt.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace DevTool.Tests;
|
||||
|
||||
public sealed class ProjectRoleDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Detect_FindsSharedProjectRoles()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
Directory.CreateDirectory(Path.Combine(root, "ui", "src-tauri"));
|
||||
File.WriteAllText(Path.Combine(root, "SidecarService.csproj"), "<Project Sdk=\"Microsoft.NET.Sdk\" />");
|
||||
File.WriteAllText(Path.Combine(root, "GatewayService.csproj"), "<Project Sdk=\"Microsoft.NET.Sdk\" />");
|
||||
File.WriteAllText(Path.Combine(root, "ui", "package.json"), """{ "name": "demo-ui", "scripts": { "build": "echo build", "tauri": "echo tauri" } }""");
|
||||
File.WriteAllText(Path.Combine(root, "ui", "src-tauri", "tauri.conf.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(root, "demo.csproj"), """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>sdt</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
""");
|
||||
|
||||
var roles = ProjectRoleDetector.Detect(root, Path.Combine("ui"), "demo");
|
||||
|
||||
Assert.Equal("SidecarService.csproj", roles.UniqueSidecarProject, ignoreCase: true);
|
||||
Assert.Equal("GatewayService.csproj", roles.UniqueGatewayProject, ignoreCase: true);
|
||||
Assert.Equal("ui/package.json", roles.NodeAppPackageJsonRelativePath, ignoreCase: true);
|
||||
Assert.Equal("ui/src-tauri/tauri.conf.json", roles.TauriConfigRelativePath, ignoreCase: true);
|
||||
Assert.NotNull(roles.PrimaryExecutableDotnetProject);
|
||||
Assert.Equal("sdt", roles.PrimaryExecutableDotnetProject!.DisplayName, ignoreCase: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWorkflowApplicable_HidesTauriWorkflow_WhenNoTauriAppExists()
|
||||
{
|
||||
var root = CreateTempDir();
|
||||
var scripts = Path.Combine(root, "scripts");
|
||||
Directory.CreateDirectory(scripts);
|
||||
Directory.CreateDirectory(Path.Combine(root, "ui"));
|
||||
File.WriteAllText(Path.Combine(scripts, "publish-app.py"), "print('ok')");
|
||||
File.WriteAllText(Path.Combine(root, "ui", "package.json"), """{ "name": "demo-ui", "scripts": { "build": "echo build" } }""");
|
||||
|
||||
var web = new WorkflowDefinition
|
||||
{
|
||||
Id = "web",
|
||||
Label = "Build Web",
|
||||
RequireFiles = ["scripts/publish-app.py", "ui/package.json"],
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep
|
||||
{
|
||||
Id = "web:run",
|
||||
Command = "python",
|
||||
Args = ["scripts/publish-app.py", "--target", "web"],
|
||||
WorkingDir = "."
|
||||
}
|
||||
]
|
||||
};
|
||||
var tauri = new WorkflowDefinition
|
||||
{
|
||||
Id = "tauri",
|
||||
Label = "Build Tauri",
|
||||
RequireFiles = ["scripts/publish-app.py", "ui/package.json", "ui/src-tauri/tauri.conf.json"],
|
||||
Steps =
|
||||
[
|
||||
new WorkflowStep
|
||||
{
|
||||
Id = "tauri:run",
|
||||
Command = "python",
|
||||
Args = ["scripts/publish-app.py", "--target", "tauri"],
|
||||
WorkingDir = "."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var map = new Dictionary<string, WorkflowDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[web.Id] = web,
|
||||
[tauri.Id] = tauri,
|
||||
};
|
||||
var roles = ProjectRoleDetector.Detect(root, "ui", "demo");
|
||||
|
||||
Assert.True(ProjectRoleDetector.IsWorkflowApplicable(root, web, map, roles));
|
||||
Assert.False(ProjectRoleDetector.IsWorkflowApplicable(root, tauri, map, roles));
|
||||
}
|
||||
|
||||
private static string CreateTempDir()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "sdt-role-detector-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@ -269,6 +269,33 @@ public sealed class ScriptSmokeTests
|
||||
Assert.Contains("Publish output workflow complete.", result.StdOut, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAction_PythonPytest_Skips_WhenNoPythonTestsExist()
|
||||
{
|
||||
var python = ResolvePython();
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "sdt-pytest-target-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
await File.WriteAllTextAsync(Path.Combine(tempRoot, "helper.py"), "print('helper')");
|
||||
|
||||
var result = await RunAsync(
|
||||
python,
|
||||
[
|
||||
"scripts/build.py",
|
||||
"python-pytest",
|
||||
"--project-root",
|
||||
tempRoot,
|
||||
"--working-dir",
|
||||
".",
|
||||
"--json"
|
||||
]);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
var jsonText = ExtractLastJsonObject(result.StdOut);
|
||||
using var doc = JsonDocument.Parse(jsonText);
|
||||
Assert.Equal("skipped", doc.RootElement.GetProperty("status").GetString());
|
||||
Assert.Equal("not_applicable_no_python_tests", doc.RootElement.GetProperty("skip_reason").GetString());
|
||||
}
|
||||
|
||||
private static string ResolvePython()
|
||||
{
|
||||
var candidates = OperatingSystem.IsWindows()
|
||||
|
||||
@ -24,6 +24,7 @@ public sealed class WorkspaceFavoritesTests
|
||||
[
|
||||
new WorkspaceFavorite
|
||||
{
|
||||
Id = "build-a",
|
||||
ProjectPath = "proj-a",
|
||||
WorkflowId = "build",
|
||||
Label = "Build A"
|
||||
@ -36,6 +37,7 @@ public sealed class WorkspaceFavoritesTests
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Single(loaded!.Value.Config.Favorites);
|
||||
Assert.Equal("build-a", loaded.Value.Config.Favorites[0].Id);
|
||||
Assert.Equal("build", loaded.Value.Config.Favorites[0].WorkflowId);
|
||||
Assert.Equal("Build A", loaded.Value.Config.Favorites[0].Label);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user