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:
stan44 2026-03-29 22:22:48 -05:00
parent 104c8eab91
commit d5a74be368
29 changed files with 2852 additions and 514 deletions

2
.gitignore vendored
View File

@ -17,4 +17,4 @@ publish-test/
/node_modules/ /node_modules/
/src/DevTool.Host.Gui/TauriShell/node_modules/ /src/DevTool.Host.Gui/TauriShell/node_modules/
output/ output/
DevTool/

908
Cli/CliApplication.cs Normal file
View 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
View 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
View 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");
}
}

View File

@ -1,365 +1,15 @@
using Sdt.Config; using Sdt.Cli;
using Sdt.Core;
using Sdt.Bridge;
using Sdt.Tui; using Sdt.Tui;
using Spectre.Console; using Spectre.Console;
try try
{ {
var cliArgs = Environment.GetCommandLineArgs().Skip(1).ToArray(); var app = new CliApplication();
if (TryGetWorkspaceCommand(cliArgs, out var workspaceCommand)) return await app.RunAsync(Environment.GetCommandLineArgs().Skip(1).ToArray());
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);
}
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) catch (Exception ex)
{ {
var message = ex.Message; AnsiConsole.MarkupLine(Theme.Fail($"Fatal: {ex.Message}"));
var isExpectedMigrationError = AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
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.WriteException(ex, ExceptionFormats.ShortenEverything);
return 1; 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;
}
}

View File

@ -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 .
python scripts/verify-workflow-routes.py --project-root . --workflow build --workflow tauri --execute --env-profile dev 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)

View File

@ -109,6 +109,41 @@ def bounded_find_files(root: str, extension: str, max_depth: int) -> list[str]:
return sorted(results) 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]: def run_dotnet_action(project_root: str, working_dir: str, verb: str) -> tuple[int, StepResult]:
cwd = resolve_cwd(project_root, working_dir) cwd = resolve_cwd(project_root, working_dir)
target = discover_dotnet_target(project_root, cwd) 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]: def action_python_pytest(args: argparse.Namespace) -> tuple[int, StepResult]:
cwd = resolve_cwd(args.project_root, args.working_dir) 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) 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 return 0 if step["exit_code"] == 0 else int(step["exit_code"]), step

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

View File

@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Sdt.Core;
namespace Sdt.Config; namespace Sdt.Config;
@ -334,10 +335,15 @@ public static class ConfigBootstrapper
scripts.ContainsKey("sync-output.py") || scripts.ContainsKey("sync-output.py") ||
scripts.ContainsKey("run-webgateway.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; 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>(); var buildSteps = new List<WorkflowStep>();
if (has("dotnet")) buildSteps.Add(StepAction("dotnet-build", "dotnet build", "dotnet-build", scan.DotnetWorkingDir)); 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)); 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( private static IEnumerable<WorkflowDefinition> BuildScriptDrivenWorkflows(
IReadOnlyDictionary<string, string> scripts, IReadOnlyDictionary<string, string> scripts,
string? nodeWorkingDir) ProjectRoleSnapshot roles)
{ {
static WorkflowStep ScriptStep(string id, string label, string scriptPath, params string[] extraArgs) 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", Id = "sidecar",
Label = "Publish Sidecar", Label = "Publish Detected Sidecar",
Description = "Publish sidecar service", Description = "Publish the detected sidecar service only.",
Group = "Build", Group = "Build",
GuidedName = "publish sidecar",
Aliases = ["sidecar publish", "build sidecar"],
Tags = ["detected", "sidecar", "dotnet"],
Steps = [ScriptStep("sidecar:run", $"python {publishSidecarPath}", publishSidecarPath)], 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", Id = "web",
Label = "Build Web UI", Label = "Build Detected Web App",
Description = "Build frontend assets", Description = "Build the detected web frontend only.",
Group = "Build", Group = "Build",
GuidedName = "build web",
Aliases = ["build frontend", "build web app"],
Tags = ["detected", "web", "frontend"],
Steps = Steps =
[ [
ScriptStep("web:run", $"python {publishAppPath} --target web", publishAppPath, "--target", "web") 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))
{ {
Id = "tauri", workflows.Add(new WorkflowDefinition
Label = "Build Tauri Desktop App", {
Description = "Build desktop binary", Id = "tauri",
Group = "Build", Label = "Build Detected Tauri Desktop App",
DependsOn = scripts.ContainsKey("publish-sidecar.py") ? ["sidecar"] : [], Description = "Build the detected Tauri desktop app. This may build frontend assets first.",
Steps = Group = "Build",
[ GuidedName = "build desktop",
ScriptStep("tauri:run", $"python {publishAppPath} --target tauri --tauri-bundles none", Aliases = ["build tauri", "build desktop app"],
publishAppPath, "--target", "tauri", "--tauri-bundles", "none") Tags = ["detected", "desktop", "tauri"],
], DependsOn = generatedIds.Contains("sidecar") ? ["sidecar"] : [],
RequireFiles = [publishAppPath, tauriConfigRequire] Steps =
}; [
ScriptStep("tauri:run", $"python {publishAppPath} --target tauri --tauri-bundles none",
publishAppPath, "--target", "tauri", "--tauri-bundles", "none")
],
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", Id = "webgateway",
Label = "Publish WebGateway", Label = "Publish Detected Web Gateway",
Description = "Publish ASP.NET gateway", Description = "Publish the detected ASP.NET gateway for this repo.",
Group = "Build", 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)], Steps = [ScriptStep("webgateway:run", $"python {publishWebgatewayPath}", publishWebgatewayPath)],
RequireFiles = [publishWebgatewayPath] RequireFiles = [publishWebgatewayPath, gatewayProjectRequire!]
}; });
generatedIds.Add("webgateway");
} }
if (scripts.TryGetValue("sync-output.py", out var syncOutputPath)) if (scripts.TryGetValue("sync-output.py", out var syncOutputPath))
{ {
yield return new WorkflowDefinition workflows.Add(new WorkflowDefinition
{ {
Id = "sync-output", Id = "sync-output",
Label = "Sync Output", Label = "Sync Newest Artifacts To output/",
Description = "Sync newest artifacts to output", Description = "Copy the newest detected build artifacts into output/ without rebuilding.",
Group = "Build", Group = "Build",
GuidedName = "sync output",
Aliases = ["copy artifacts", "refresh output"],
Tags = ["output", "artifacts"],
Steps = [ScriptStep("sync-output:run", $"python {syncOutputPath}", syncOutputPath)], Steps = [ScriptStep("sync-output:run", $"python {syncOutputPath}", syncOutputPath)],
RequireFiles = [syncOutputPath] RequireFiles = [syncOutputPath]
}; });
generatedIds.Add("sync-output");
} }
if (scripts.TryGetValue("publish-output.py", out var publishOutputPath)) if (scripts.TryGetValue("publish-output.py", out var publishOutputPath))
{ {
yield return new WorkflowDefinition workflows.Add(new WorkflowDefinition
{ {
Id = "stage-output", Id = "stage-output",
Label = "Stage Output Bundle", Label = "Stage All Detected Outputs",
Description = "Publish and stage distributable output", Description = "Build and stage every detected ship target for this repo, such as web, desktop, gateway, or sidecar.",
Group = "Build", 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)], Steps = [ScriptStep("stage-output:run", $"python {publishOutputPath}", publishOutputPath)],
RequireFiles = [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", Id = "run-gateway-dev",
Label = "Run WebGateway Server (Dev)", Label = "Run Detected Web Gateway (Dev)",
Description = "Run gateway in development mode", Description = "Run the detected gateway in development mode.",
Group = "Dev", Group = "Dev",
GuidedName = "run gateway dev",
Aliases = ["start gateway", "gateway dev"],
Tags = ["detected", "gateway", "dev"],
Steps = Steps =
[ [
ScriptStep("run-gateway-dev:run", $"python {runWebgatewayPath} --mode Dev", ScriptStep("run-gateway-dev:run", $"python {runWebgatewayPath} --mode Dev",
runWebgatewayPath, "--mode", "Dev") runWebgatewayPath, "--mode", "Dev")
], ],
RequireFiles = [runWebgatewayPath] RequireFiles = [runWebgatewayPath, gatewayProjectRequire!]
}; });
} }
return workflows;
} }
private static string ResolveTauriConfigRequirePath(string? nodeWorkingDir) private static WorkflowDefinition? BuildPrimaryDotnetPublishWorkflow(ProjectRoleSnapshot roles)
{ {
if (string.IsNullOrWhiteSpace(nodeWorkingDir) || nodeWorkingDir == ".") var project = roles.PrimaryExecutableDotnetProject;
return "src-tauri/tauri.conf.json"; if (project is null)
return Path.Combine(nodeWorkingDir, "src-tauri", "tauri.conf.json").Replace('\\', '/'); 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) private static string FindProjectRoot(string startDir)

View File

@ -66,23 +66,10 @@ public static class ConfigLoader
var legacyMode = ResolveLegacyMode(); var legacyMode = ResolveLegacyMode();
if (legacyMode == LegacyMode.Strict && effectiveConfig.Workflows.Count == 0 && effectiveConfig.Targets.Count > 0) 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( throw new InvalidOperationException(
$"Legacy targets-only config detected at {configPath}. Strict mode requires workflows. " + $"Legacy targets-only config detected at {configPath}. Strict mode requires workflows. " +
"Use migration preview file 'devtool.generated.workflows.json' and migrate your config. " + "Use migration preview file 'devtool.generated.workflows.json' and migrate your config. " +
"Temporary rollback: set SDT_LEGACY_MODE=compat."); "Temporary rollback: set SDT_LEGACY_MODE=compat.");
}
var normalized = WorkflowModelBuilder.Normalize(effectiveConfig, legacyMode, new RequirementResolver()); var normalized = WorkflowModelBuilder.Normalize(effectiveConfig, legacyMode, new RequirementResolver());
warnings.AddRange(normalized.Warnings); 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() private static LegacyMode ResolveLegacyMode()
{ {
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE"); var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");

View File

@ -66,6 +66,10 @@ public sealed class WorkflowDefinition
public string Label { get; init; } = ""; public string Label { get; init; } = "";
public string Description { get; init; } = ""; public string Description { get; init; } = "";
public string Group { get; init; } = "General"; 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> DependsOn { get; init; } = [];
public List<string> RequireFiles { get; init; } = []; public List<string> RequireFiles { get; init; } = [];
public List<WorkflowStep> Steps { get; init; } = []; public List<WorkflowStep> Steps { get; init; } = [];
@ -131,6 +135,11 @@ public sealed class DebugProfileDefinition
{ {
public string Id { get; init; } = ""; public string Id { get; init; } = "";
public string Label { 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 Type { get; init; } = "generic";
public string Command { get; init; } = ""; public string Command { get; init; } = "";
public List<string> Args { get; init; } = []; public List<string> Args { get; init; } = [];

View 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);
}
}
}

View File

@ -58,6 +58,7 @@ public static class WorkflowModelBuilder
Targets = [], Targets = [],
Workflows = ConvertLegacyTargets(config.Targets, requirementResolver), Workflows = ConvertLegacyTargets(config.Targets, requirementResolver),
Env = config.Env, Env = config.Env,
EnvProfiles = config.EnvProfiles,
Toolchains = config.Toolchains, Toolchains = config.Toolchains,
Tooling = config.Tooling, Tooling = config.Tooling,
Project = config.Project, Project = config.Project,
@ -90,6 +91,7 @@ public static class WorkflowModelBuilder
Label = target.Label, Label = target.Label,
Description = target.Description, Description = target.Description,
Group = target.Group, Group = target.Group,
Category = target.Group,
DependsOn = target.DependsOn, DependsOn = target.DependsOn,
Steps = step is null ? [] : [step], Steps = step is null ? [] : [step],
}); });

View File

@ -27,6 +27,11 @@ public sealed class WorkspaceProject
public sealed class WorkspaceFavorite public sealed class WorkspaceFavorite
{ {
/// <summary>
/// Stable ID for guided task invocation, for example "build" or "dev".
/// </summary>
public string? Id { get; init; }
/// <summary> /// <summary>
/// Relative or absolute path to the project root. /// Relative or absolute path to the project root.
/// </summary> /// </summary>

View File

@ -173,10 +173,10 @@ public sealed class ConfigDoctorService(
private static void AddPathChecks(DevToolConfig config, string projectRoot, List<DoctorCheck> checks) private static void AddPathChecks(DevToolConfig config, string projectRoot, List<DoctorCheck> checks)
{ {
var configPath = Path.Combine(projectRoot, "devtool.json"); var configPath = ConfigLoader.FindConfigPath(projectRoot);
checks.Add(File.Exists(configPath) checks.Add(!string.IsNullOrWhiteSpace(configPath)
? new DoctorCheck("Project root", DoctorStatus.Pass, $"Config found at {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()) if (OperatingSystem.IsWindows())
{ {

View File

@ -35,9 +35,9 @@ public static class FailureCardBuilder
var exactFix = reason switch var exactFix = reason switch
{ {
ExecutionStopReason.MissingPrereq => "sdt --init", ExecutionStopReason.MissingPrereq => $"sdt setup apply --safe --auto-install --json{projectArg}{envArg}",
ExecutionStopReason.InstallFailed => "sdt --init", ExecutionStopReason.InstallFailed => $"sdt setup apply --safe --auto-install --json{projectArg}{envArg}",
ExecutionStopReason.UserDeclined => "sdt --init", ExecutionStopReason.UserDeclined => $"sdt setup apply --safe --auto-install --json{projectArg}{envArg}",
ExecutionStopReason.CommandFailed => $"sdt {targetKind} {targetId} --json{projectArg}{envArg}", ExecutionStopReason.CommandFailed => $"sdt {targetKind} {targetId} --json{projectArg}{envArg}",
_ => $"sdt {targetKind} {targetId} --json{projectArg}{envArg}", _ => $"sdt {targetKind} {targetId} --json{projectArg}{envArg}",
}; };

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

View File

@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Linq; using System.Linq;
using Sdt.Config; using Sdt.Config;
@ -11,6 +12,7 @@ public sealed record HeadlessRunRequest(
string ProjectRoot, string ProjectRoot,
string? EnvProfile, string? EnvProfile,
bool NonInteractive, bool NonInteractive,
bool AutoInstall,
bool JsonOutput); bool JsonOutput);
public sealed record HeadlessDebugRequest( public sealed record HeadlessDebugRequest(
@ -18,10 +20,13 @@ public sealed record HeadlessDebugRequest(
string ProjectRoot, string ProjectRoot,
string? EnvProfile, string? EnvProfile,
bool NonInteractive, bool NonInteractive,
bool AutoInstall,
bool JsonOutput); bool JsonOutput);
public sealed class HeadlessExecutionService public sealed class HeadlessExecutionService
{ {
public const string ContractVersion = "1.1";
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@ -33,16 +38,24 @@ public sealed class HeadlessExecutionService
JsonOptions.Converters.Add(new JsonStringEnumConverter()); JsonOptions.Converters.Add(new JsonStringEnumConverter());
} }
private readonly WorkflowExecutor _executor = new( private readonly WorkflowExecutor _executor;
new WorkflowPlanner(), private readonly IDebugProfileRunner _debugRunner;
new ToolProbeService(),
new PrereqInstallerService(),
new ActionRunner(),
new RequirementResolver());
private readonly IDebugProfileRunner _debugRunner = new DebugProfileRunner( public HeadlessExecutionService(
new ToolProbeService(), WorkflowExecutor? executor = null,
new PrereqInstallerService()); IDebugProfileRunner? debugRunner = null)
{
_executor = executor ?? new WorkflowExecutor(
new WorkflowPlanner(),
new ToolProbeService(),
new PrereqInstallerService(),
new ActionRunner(),
new RequirementResolver());
_debugRunner = debugRunner ?? new DebugProfileRunner(
new ToolProbeService(),
new PrereqInstallerService());
}
public async Task<int> RunWorkflowAsync(LoadedProjectConfig loaded, HeadlessRunRequest request, CancellationToken cancellationToken = default) public async Task<int> RunWorkflowAsync(LoadedProjectConfig loaded, HeadlessRunRequest request, CancellationToken cancellationToken = default)
{ {
@ -52,6 +65,7 @@ public sealed class HeadlessExecutionService
{ {
WriteSummary(new WriteSummary(new
{ {
contractVersion = ContractVersion,
category = "workflow", category = "workflow",
success = false, success = false,
stopReason = ExecutionStopReason.ValidationFailed, stopReason = ExecutionStopReason.ValidationFailed,
@ -64,7 +78,7 @@ public sealed class HeadlessExecutionService
request.WorkflowId, request.WorkflowId,
loaded.ProjectRoot, loaded.ProjectRoot,
request.EnvProfile), request.EnvProfile),
}); }, jsonOutput: true);
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
} }
@ -79,7 +93,7 @@ public sealed class HeadlessExecutionService
RunId = runId, RunId = runId,
ProjectRoot = loaded.ProjectRoot, ProjectRoot = loaded.ProjectRoot,
EnvProfile = request.EnvProfile, EnvProfile = request.EnvProfile,
RunEventVersion = "1.0", RunEventVersion = ContractVersion,
}; };
eventTypes.Add(enriched.Type); eventTypes.Add(enriched.Type);
recorder.Write(enriched); recorder.Write(enriched);
@ -93,7 +107,7 @@ public sealed class HeadlessExecutionService
workflows, workflows,
loaded.Config, loaded.Config,
loaded.ProjectRoot, loaded.ProjectRoot,
confirmInstallAsync: (tool, plan) => ConfirmInstallAsync(tool, plan, request.NonInteractive), confirmInstallAsync: (tool, plan) => ConfirmInstallAsync(tool, plan, request.NonInteractive, request.AutoInstall),
onOutput: (line, isErr) => onOutput: (line, isErr) =>
{ {
outputLines.Add((isErr ? "ERR: " : "OUT: ") + line); outputLines.Add((isErr ? "ERR: " : "OUT: ") + line);
@ -105,9 +119,10 @@ public sealed class HeadlessExecutionService
var exit = ExitCodeMapper.FromResult(result.Success, result.StopReason); var exit = ExitCodeMapper.FromResult(result.Success, result.StopReason);
var summary = new var summary = new
{ {
contractVersion = ContractVersion,
category = "workflow", category = "workflow",
runId, runId,
runEventVersion = "1.0", runEventVersion = ContractVersion,
success = result.Success, success = result.Success,
stopReason = result.StopReason, stopReason = result.StopReason,
message = result.Message, message = result.Message,
@ -118,7 +133,7 @@ public sealed class HeadlessExecutionService
? null ? null
: FailureCardBuilder.Build(result.StopReason, result.Message, "run", request.WorkflowId, loaded.ProjectRoot, request.EnvProfile), : FailureCardBuilder.Build(result.StopReason, result.Message, "run", request.WorkflowId, loaded.ProjectRoot, request.EnvProfile),
}; };
WriteSummary(summary); WriteSummary(summary, request.JsonOutput);
return exit; return exit;
} }
@ -130,6 +145,7 @@ public sealed class HeadlessExecutionService
{ {
WriteSummary(new WriteSummary(new
{ {
contractVersion = ContractVersion,
category = "debug", category = "debug",
success = false, success = false,
stopReason = ExecutionStopReason.ValidationFailed, stopReason = ExecutionStopReason.ValidationFailed,
@ -142,7 +158,7 @@ public sealed class HeadlessExecutionService
request.ProfileId, request.ProfileId,
loaded.ProjectRoot, loaded.ProjectRoot,
request.EnvProfile), request.EnvProfile),
}); }, jsonOutput: true);
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
} }
@ -157,7 +173,7 @@ public sealed class HeadlessExecutionService
RunId = runId, RunId = runId,
ProjectRoot = loaded.ProjectRoot, ProjectRoot = loaded.ProjectRoot,
EnvProfile = request.EnvProfile, EnvProfile = request.EnvProfile,
RunEventVersion = "1.0", RunEventVersion = ContractVersion,
}; };
eventTypes.Add(enriched.Type); eventTypes.Add(enriched.Type);
recorder.Write(enriched); recorder.Write(enriched);
@ -170,7 +186,7 @@ public sealed class HeadlessExecutionService
loaded.Config, loaded.Config,
loaded.ProjectRoot, loaded.ProjectRoot,
verbose: false, verbose: false,
confirmInstallAsync: (tool, plan) => ConfirmInstallAsync(tool, plan, request.NonInteractive), confirmInstallAsync: (tool, plan) => ConfirmInstallAsync(tool, plan, request.NonInteractive, request.AutoInstall),
onOutput: (_, _) => { }, onOutput: (_, _) => { },
onEvent: Emit, onEvent: Emit,
envOverrides: envOverrides.Count == 0 ? null : envOverrides, envOverrides: envOverrides.Count == 0 ? null : envOverrides,
@ -179,9 +195,10 @@ public sealed class HeadlessExecutionService
var exit = ExitCodeMapper.FromResult(result.Success, result.StopReason); var exit = ExitCodeMapper.FromResult(result.Success, result.StopReason);
var summary = new var summary = new
{ {
contractVersion = ContractVersion,
category = "debug", category = "debug",
runId, runId,
runEventVersion = "1.0", runEventVersion = ContractVersion,
success = result.Success, success = result.Success,
stopReason = result.StopReason, stopReason = result.StopReason,
message = result.Message, message = result.Message,
@ -192,7 +209,7 @@ public sealed class HeadlessExecutionService
? null ? null
: FailureCardBuilder.Build(result.StopReason, result.Message, "debug", request.ProfileId, loaded.ProjectRoot, request.EnvProfile), : FailureCardBuilder.Build(result.StopReason, result.Message, "debug", request.ProfileId, loaded.ProjectRoot, request.EnvProfile),
}; };
WriteSummary(summary); WriteSummary(summary, request.JsonOutput);
return exit; 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) if (nonInteractive)
return Task.FromResult(false); return Task.FromResult(false);
@ -240,6 +260,30 @@ public sealed class HeadlessExecutionService
: LegacyMode.Strict; : LegacyMode.Strict;
} }
private static void WriteSummary(object payload) private static void WriteSummary(object payload, bool jsonOutput)
=> Console.WriteLine(JsonSerializer.Serialize(payload, JsonOptions)); {
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}");
}
} }

View 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);
}

View File

@ -127,6 +127,7 @@ public sealed class BridgeStdioServer
}), }),
favorites = workspace.Favorites.Select(f => new favorites = workspace.Favorites.Select(f => new
{ {
id = GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId),
projectPath = f.ProjectPath, projectPath = f.ProjectPath,
workflowId = f.WorkflowId, workflowId = f.WorkflowId,
label = f.Label, label = f.Label,
@ -189,6 +190,7 @@ public sealed class BridgeStdioServer
var (workspace, workspaceRoot) = loaded; var (workspace, workspaceRoot) = loaded;
return workspace.Favorites.Select(f => new return workspace.Favorites.Select(f => new
{ {
id = GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId),
projectPath = f.ProjectPath, projectPath = f.ProjectPath,
workflowId = f.WorkflowId, workflowId = f.WorkflowId,
label = f.Label, label = f.Label,
@ -202,6 +204,7 @@ public sealed class BridgeStdioServer
var projectPath = GetRequiredString(@params, "favoriteProjectPath"); var projectPath = GetRequiredString(@params, "favoriteProjectPath");
var workflowId = GetRequiredString(@params, "workflowId"); var workflowId = GetRequiredString(@params, "workflowId");
var label = GetString(@params, "label"); var label = GetString(@params, "label");
var id = GetString(@params, "id");
var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found.");
var (workspace, workspaceRoot) = loaded; var (workspace, workspaceRoot) = loaded;
@ -220,6 +223,7 @@ public sealed class BridgeStdioServer
{ {
workspace.Favorites.Add(new WorkspaceFavorite workspace.Favorites.Add(new WorkspaceFavorite
{ {
Id = string.IsNullOrWhiteSpace(id) ? GuidedTaskCatalog.Slugify(label ?? workflowId) : id,
ProjectPath = relativeProject, ProjectPath = relativeProject,
WorkflowId = workflowId, WorkflowId = workflowId,
Label = string.IsNullOrWhiteSpace(label) ? null : label Label = string.IsNullOrWhiteSpace(label) ? null : label

View File

@ -25,6 +25,7 @@ public sealed class App
private bool _firstRunPromptShown; private bool _firstRunPromptShown;
private string? _activeEnvProfile; private string? _activeEnvProfile;
private string? _startupWorkflowId; private string? _startupWorkflowId;
private ProjectRoleSnapshot _projectRoles;
private readonly WorkflowExecutor _executor = new( private readonly WorkflowExecutor _executor = new(
new WorkflowPlanner(), new WorkflowPlanner(),
@ -50,8 +51,13 @@ public sealed class App
_workspaceRoot = workspaceRoot; _workspaceRoot = workspaceRoot;
_startupWorkflowId = startupWorkflowId; _startupWorkflowId = startupWorkflowId;
_activeEnvProfile = config.EnvProfiles?.Active; _activeEnvProfile = config.EnvProfiles?.Active;
_projectRoles = ProjectRoleDetector.Detect(projectRoot, config.Toolchains?.Node?.WorkingDir, config.Name);
var normalized = WorkflowModelBuilder.Normalize(config, ResolveLegacyMode(), _requirementResolver); 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 = []; _warnings = [];
_debugRunner = new DebugProfileRunner(new ToolProbeService(), new PrereqInstallerService()); _debugRunner = new DebugProfileRunner(new ToolProbeService(), new PrereqInstallerService());
_diagnostics = new DiagnosticsBundleService(); _diagnostics = new DiagnosticsBundleService();
@ -60,30 +66,6 @@ public sealed class App
_warnings.AddRange(normalized.Warnings); _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() private static LegacyMode ResolveLegacyMode()
{ {
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE"); var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
@ -107,6 +89,8 @@ public sealed class App
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
} }
RenderQuickStartPanel();
if (!string.IsNullOrWhiteSpace(_startupWorkflowId)) if (!string.IsNullOrWhiteSpace(_startupWorkflowId))
{ {
var requestedWorkflow = _startupWorkflowId; var requestedWorkflow = _startupWorkflowId;
@ -121,7 +105,7 @@ public sealed class App
continue; 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...")); AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to continue..."));
Console.ReadKey(intercept: true); Console.ReadKey(intercept: true);
} }
@ -301,10 +285,28 @@ public sealed class App
AnsiConsole.MarkupLine(Theme.Faint($"root: {_projectRoot}") + "\n"); 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() private string ShowMainMenu()
{ {
var prompt = new SelectionPrompt<MenuItem>() 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) .PageSize(28)
.MoreChoicesText(Theme.Faint("(scroll to see more)")) .MoreChoicesText(Theme.Faint("(scroll to see more)"))
.UseConverter(m => m.Display); .UseConverter(m => m.Display);
@ -313,13 +315,13 @@ public sealed class App
if (favoriteItems.Count > 0) if (favoriteItems.Count > 0)
{ {
prompt.AddChoiceGroup( prompt.AddChoiceGroup(
new MenuItem($"[bold {Theme.Amber}]QUICK ACTIONS[/]", "__group__"), new MenuItem($"[bold {Theme.Amber}]FAVORITES[/]", "__group__"),
favoriteItems); favoriteItems);
} }
var groups = _workflows var groups = _workflows
.Where(t => !string.IsNullOrWhiteSpace(t.Label)) .Where(t => !string.IsNullOrWhiteSpace(t.Label))
.GroupBy(t => string.IsNullOrWhiteSpace(t.Group) ? "General" : t.Group); .GroupBy(GuidedTaskCatalog.GetWorkflowCategory);
foreach (var group in groups) foreach (var group in groups)
{ {
@ -328,7 +330,7 @@ public sealed class App
"__group__"); "__group__");
var items = group.Select(t => new MenuItem( 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(); t.Id)).ToList();
prompt.AddChoiceGroup(header, items); prompt.AddChoiceGroup(header, items);
@ -341,15 +343,15 @@ public sealed class App
foreach (var profile in debugProfiles) foreach (var profile in debugProfiles)
{ {
debugItems.Add(new MenuItem( 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")); $"__debug__:{profile.Id}:normal"));
debugItems.Add(new MenuItem( 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")); $"__debug__:{profile.Id}:verbose"));
if (profile.Attach is not null) if (profile.Attach is not null)
{ {
debugItems.Add(new MenuItem( debugItems.Add(new MenuItem(
$"[{Theme.Green}]Attach instructions: {Markup.Escape(profile.Label)}[/]", $"[{Theme.Green}]Attach instructions: {Markup.Escape(GuidedTaskCatalog.GetDebugDisplayLabel(profile))}[/]",
$"__debugattach__:{profile.Id}")); $"__debugattach__:{profile.Id}"));
} }
} }
@ -373,7 +375,7 @@ public sealed class App
if (_workspace is not null) if (_workspace is not null)
systemItems.Insert(0, new MenuItem( 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__")); "__favorites_manage__"));
if (_config.Toolchains is not null) if (_config.Toolchains is not null)
@ -764,15 +766,21 @@ public sealed class App
foreach (var workflow in _workflows) 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( 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}")); $"workflow:{workflow.Id}"));
} }
foreach (var profile in _config.Debug?.Profiles ?? []) 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( 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}")); $"debug:{profile.Id}"));
} }
@ -853,7 +861,7 @@ public sealed class App
return; return;
var pin = AnsiConsole.Confirm( 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); defaultValue: false);
if (!pin) if (!pin)
return; return;
@ -867,12 +875,13 @@ public sealed class App
_workspace.Favorites.Add(new WorkspaceFavorite _workspace.Favorites.Add(new WorkspaceFavorite
{ {
Id = GuidedTaskCatalog.Slugify(GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow)),
ProjectPath = projectPath, ProjectPath = projectPath,
WorkflowId = workflow.Id, WorkflowId = workflow.Id,
Label = workflow.Label Label = GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow)
}); });
WorkspaceLoader.Save(_workspaceRoot, _workspace); 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() private List<MenuItem> BuildFavoriteMenuItems()
@ -891,7 +900,9 @@ public sealed class App
: null; : null;
var projectName = ResolveWorkspaceProjectName(projectRoot); var projectName = ResolveWorkspaceProjectName(projectRoot);
var workflowLabel = workflow?.Label ?? favorite.WorkflowId; var workflowLabel = workflow is null
? favorite.WorkflowId
: GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow);
var detail = isCurrent var detail = isCurrent
? "current project" ? "current project"
: projectName; : projectName;
@ -899,8 +910,9 @@ public sealed class App
var displayLabel = !string.IsNullOrWhiteSpace(favorite.Label) var displayLabel = !string.IsNullOrWhiteSpace(favorite.Label)
? favorite.Label! ? favorite.Label!
: workflowLabel; : 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}")); items.Add(new MenuItem(display, $"__favorite__:{index}"));
index++; index++;
} }
@ -966,8 +978,13 @@ public sealed class App
if (reloaded is null) if (reloaded is null)
return; return;
_config = reloaded.Config; _config = reloaded.Config;
_projectRoles = ProjectRoleDetector.Detect(_projectRoot, _config.Toolchains?.Node?.WorkingDir, _config.Name);
var normalized = WorkflowModelBuilder.Normalize(_config, ResolveLegacyMode(), _requirementResolver); 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.Clear();
_warnings.AddRange(reloaded.Warnings); _warnings.AddRange(reloaded.Warnings);
_warnings.AddRange(normalized.Warnings); _warnings.AddRange(normalized.Warnings);
@ -1233,9 +1250,9 @@ public sealed class App
while (true) while (true)
{ {
AnsiConsole.Clear(); AnsiConsole.Clear();
AnsiConsole.Write(Theme.SectionRule("QUICK ACTIONS")); AnsiConsole.Write(Theme.SectionRule("FAVORITES"));
AnsiConsole.MarkupLine(Theme.Faint($"Project: {_config.Name}")); 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 var choices = _workflows
.Select(w => .Select(w =>
@ -1245,16 +1262,16 @@ public sealed class App
string.Equals(f.WorkflowId, w.Id, StringComparison.OrdinalIgnoreCase)); string.Equals(f.WorkflowId, w.Id, StringComparison.OrdinalIgnoreCase));
var marker = isFavorite ? "★" : " "; var marker = isFavorite ? "★" : " ";
return new MenuItem( 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); 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__")) .Append(new MenuItem(Theme.Faint("← Back"), "__back__"))
.ToList(); .ToList();
var selected = AnsiConsole.Prompt( var selected = AnsiConsole.Prompt(
new SelectionPrompt<MenuItem>() new SelectionPrompt<MenuItem>()
.Title($"[{Theme.Green}]Quick action manager:[/]") .Title($"[{Theme.Green}]Favorite manager:[/]")
.PageSize(24) .PageSize(24)
.UseConverter(m => m.Display) .UseConverter(m => m.Display)
.AddChoices(choices)); .AddChoices(choices));
@ -1268,11 +1285,11 @@ public sealed class App
if (removed > 0) if (removed > 0)
{ {
WorkspaceLoader.Save(_workspaceRoot, _workspace); WorkspaceLoader.Save(_workspaceRoot, _workspace);
AnsiConsole.MarkupLine(Theme.Ok($"Removed {removed} invalid quick action(s).")); AnsiConsole.MarkupLine(Theme.Ok($"Removed {removed} invalid favorite(s)."));
} }
else else
{ {
AnsiConsole.MarkupLine(Theme.Faint("No invalid quick actions found.")); AnsiConsole.MarkupLine(Theme.Faint("No invalid favorites found."));
} }
Thread.Sleep(700); Thread.Sleep(700);
@ -1288,18 +1305,19 @@ public sealed class App
{ {
_workspace.Favorites.Remove(existing); _workspace.Favorites.Remove(existing);
WorkspaceLoader.Save(_workspaceRoot, _workspace); WorkspaceLoader.Save(_workspaceRoot, _workspace);
AnsiConsole.MarkupLine(Theme.Warn($"Removed quick action: {workflowId}")); AnsiConsole.MarkupLine(Theme.Warn($"Removed favorite: {workflowId}"));
} }
else else
{ {
_workspace.Favorites.Add(new WorkspaceFavorite _workspace.Favorites.Add(new WorkspaceFavorite
{ {
Id = GuidedTaskCatalog.Slugify(_workflows.First(w => string.Equals(w.Id, workflowId, StringComparison.OrdinalIgnoreCase)).Label),
ProjectPath = projectPath, ProjectPath = projectPath,
WorkflowId = workflowId, WorkflowId = workflowId,
Label = _workflows.First(w => string.Equals(w.Id, workflowId, StringComparison.OrdinalIgnoreCase)).Label Label = _workflows.First(w => string.Equals(w.Id, workflowId, StringComparison.OrdinalIgnoreCase)).Label
}); });
WorkspaceLoader.Save(_workspaceRoot, _workspace); WorkspaceLoader.Save(_workspaceRoot, _workspace);
AnsiConsole.MarkupLine(Theme.Ok($"Added quick action: {workflowId}")); AnsiConsole.MarkupLine(Theme.Ok($"Added favorite: {workflowId}"));
} }
Thread.Sleep(700); Thread.Sleep(700);

View File

@ -125,7 +125,7 @@ public sealed class EventsScreen(
{ {
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
var pin = AnsiConsole.Confirm( 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); defaultValue: false);
if (pin) if (pin)
{ {
@ -160,12 +160,13 @@ public sealed class EventsScreen(
_workspace.Favorites.Add(new WorkspaceFavorite _workspace.Favorites.Add(new WorkspaceFavorite
{ {
Id = GuidedTaskCatalog.Slugify(workflowId),
ProjectPath = projectPath, ProjectPath = projectPath,
WorkflowId = workflowId, WorkflowId = workflowId,
Label = workflowId Label = workflowId
}); });
WorkspaceLoader.Save(_workspaceRoot, _workspace); WorkspaceLoader.Save(_workspaceRoot, _workspace);
AnsiConsole.MarkupLine(Theme.Ok($"Pinned quick action: {workflowId}")); AnsiConsole.MarkupLine(Theme.Ok($"Pinned favorite: {workflowId}"));
} }
private void RenderHeader(string section) private void RenderHeader(string section)

View 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"));
}
}

View File

@ -46,8 +46,9 @@ public sealed class ConfigBootstrapperTests
[Fact] [Fact]
public void BuildDefaultConfig_ProducesWorkflowsAndDebugSection() public void BuildDefaultConfig_ProducesWorkflowsAndDebugSection()
{ {
var root = CreateTempDir();
var scan = new BootstrapScanResult( var scan = new BootstrapScanResult(
ProjectRoot: Path.GetTempPath(), ProjectRoot: root,
ProjectName: "demo", ProjectName: "demo",
ProjectType: "dotnet", ProjectType: "dotnet",
ToolFamilies: ["dotnet", "git"], ToolFamilies: ["dotnet", "git"],
@ -76,14 +77,98 @@ public sealed class ConfigBootstrapperTests
var root = CreateTempDir(); var root = CreateTempDir();
var scripts = Path.Combine(root, "scripts"); var scripts = Path.Combine(root, "scripts");
Directory.CreateDirectory(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-app.py"), "print('ok')");
File.WriteAllText(Path.Combine(scripts, "publish-sidecar.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 scan = ConfigBootstrapper.Scan(root);
var cfg = ConfigBootstrapper.BuildDefaultConfig(scan); var cfg = ConfigBootstrapper.BuildDefaultConfig(scan);
Assert.Contains(cfg.Workflows, w => w.Id == "web"); Assert.Contains(cfg.Workflows, w => w.Id == "web");
Assert.Contains(cfg.Workflows, w => w.Id == "sidecar"); 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] [Fact]

View File

@ -37,7 +37,7 @@ public sealed class HeadlessExecutionTests
var result = await RunSdtAsync(["run", "build", "--json", "--project-root", root]); var result = await RunSdtAsync(["run", "build", "--json", "--project-root", root]);
Assert.Equal(0, result.ExitCode); 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("\"event_type\":\"WorkflowStarted\"", result.StdOut, StringComparison.Ordinal);
Assert.Contains("\"category\":\"workflow\"", result.StdOut, StringComparison.Ordinal); Assert.Contains("\"category\":\"workflow\"", result.StdOut, StringComparison.Ordinal);
Assert.Contains("\"success\":true", 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); 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) 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"); var exe = Path.Combine(AppContext.BaseDirectory, OperatingSystem.IsWindows() ? "sdt.exe" : "sdt");

View File

@ -7,7 +7,7 @@ namespace DevTool.Tests;
public sealed class LegacyModeTests public sealed class LegacyModeTests
{ {
[Fact] [Fact]
public void ConfigLoader_StrictMode_TargetsOnly_FailsAndWritesPreview() public void ConfigLoader_StrictMode_TargetsOnly_FailsWithoutWritingPreview()
{ {
var root = CreateTempDir(); var root = CreateTempDir();
WriteLegacyTargetsOnlyConfig(root); WriteLegacyTargetsOnlyConfig(root);
@ -15,7 +15,7 @@ public sealed class LegacyModeTests
var ex = Assert.Throws<InvalidOperationException>(() => ConfigLoader.FindAndLoad(root)); var ex = Assert.Throws<InvalidOperationException>(() => ConfigLoader.FindAndLoad(root));
Assert.Contains("Strict mode requires workflows", ex.Message, StringComparison.OrdinalIgnoreCase); 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] [Fact]
@ -56,6 +56,19 @@ public sealed class LegacyModeTests
Assert.Empty(loaded.Config.Targets); 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) private static void WriteLegacyTargetsOnlyConfig(string root)
{ {
var cfg = new DevToolConfig var cfg = new DevToolConfig

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

View File

@ -269,6 +269,33 @@ public sealed class ScriptSmokeTests
Assert.Contains("Publish output workflow complete.", result.StdOut, StringComparison.OrdinalIgnoreCase); 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() private static string ResolvePython()
{ {
var candidates = OperatingSystem.IsWindows() var candidates = OperatingSystem.IsWindows()

View File

@ -24,6 +24,7 @@ public sealed class WorkspaceFavoritesTests
[ [
new WorkspaceFavorite new WorkspaceFavorite
{ {
Id = "build-a",
ProjectPath = "proj-a", ProjectPath = "proj-a",
WorkflowId = "build", WorkflowId = "build",
Label = "Build A" Label = "Build A"
@ -36,6 +37,7 @@ public sealed class WorkspaceFavoritesTests
Assert.NotNull(loaded); Assert.NotNull(loaded);
Assert.Single(loaded!.Value.Config.Favorites); 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", loaded.Value.Config.Favorites[0].WorkflowId);
Assert.Equal("Build A", loaded.Value.Config.Favorites[0].Label); Assert.Equal("Build A", loaded.Value.Config.Favorites[0].Label);
} }