using System.CommandLine; using System.CommandLine.Parsing; using System.Text.Json; using ScadaLink.Commons.Messages.Management; namespace ScadaLink.CLI.Commands; /// /// Transport (#24) bundle export / preview / import. The bundle bytes travel /// through the management endpoint as base64 inside the standard JSON envelope /// so no transport plumbing diverges from the other commands; the CLI handles /// file I/O at the edges. /// public static class BundleCommands { private static readonly TimeSpan BundleCommandTimeout = TimeSpan.FromMinutes(5); public static Command Build( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("bundle") { Description = "Export, preview, and import Transport bundles", }; command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildPreview(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildImport(urlOption, formatOption, usernameOption, passwordOption)); return command; } // ==================================================================== // bundle export // ==================================================================== private static Command BuildExport( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var outputOption = new Option("--output") { Description = "Output file path (.scadabundle)", Required = true, }; var passphraseOption = new Option("--passphrase") { Description = "Encryption passphrase. Omit to produce an unencrypted bundle.", }; var allOption = new Option("--all") { Description = "Export every entity of every supported type (ignores per-type name flags).", }; var templatesOption = NameListOption("--templates", "Comma-separated template names"); var sharedScriptsOption = NameListOption("--shared-scripts", "Comma-separated shared-script names"); var externalSystemsOption = NameListOption("--external-systems", "Comma-separated external-system names"); var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names"); var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names"); var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names"); var apiKeysOption = NameListOption("--api-keys", "Comma-separated API-key names"); var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names"); var includeDepsOption = new Option("--include-dependencies") { Description = "Pull transitive dependencies (referenced shared scripts, parents, composed members) into the bundle.", }; var sourceEnvOption = new Option("--source-environment") { Description = "SourceEnvironment value stamped into the bundle manifest. Defaults to 'cli'.", }; var cmd = new Command("export") { Description = "Export a bundle to a file", }; cmd.Add(outputOption); cmd.Add(passphraseOption); cmd.Add(allOption); cmd.Add(templatesOption); cmd.Add(sharedScriptsOption); cmd.Add(externalSystemsOption); cmd.Add(dbConnectionsOption); cmd.Add(notificationListsOption); cmd.Add(smtpConfigsOption); cmd.Add(apiKeysOption); cmd.Add(apiMethodsOption); cmd.Add(includeDepsOption); cmd.Add(sourceEnvOption); cmd.SetAction(async (ParseResult result) => { var output = result.GetValue(outputOption)!; var passphrase = result.GetValue(passphraseOption); var all = result.GetValue(allOption); var includeDeps = result.GetValue(includeDepsOption); var sourceEnv = result.GetValue(sourceEnvOption) ?? "cli"; var payload = new ExportBundleCommand( All: all, TemplateNames: result.GetValue(templatesOption), SharedScriptNames: result.GetValue(sharedScriptsOption), ExternalSystemNames: result.GetValue(externalSystemsOption), DatabaseConnectionNames: result.GetValue(dbConnectionsOption), NotificationListNames: result.GetValue(notificationListsOption), SmtpConfigurationNames: result.GetValue(smtpConfigsOption), ApiKeyNames: result.GetValue(apiKeysOption), ApiMethodNames: result.GetValue(apiMethodsOption), IncludeDependencies: includeDeps, Passphrase: passphrase, SourceEnvironment: sourceEnv); return await RunBundleCommandAsync( result, urlOption, usernameOption, passwordOption, payload, jsonOk => { using var doc = JsonDocument.Parse(jsonOk); var base64 = doc.RootElement.GetProperty("base64Bundle").GetString()!; var byteCount = doc.RootElement.GetProperty("byteCount").GetInt32(); var bytes = Convert.FromBase64String(base64); File.WriteAllBytes(output, bytes); Console.WriteLine($"Wrote {bytes.Length:N0} bytes to {output} (server reported {byteCount:N0})."); return 0; }); }); return cmd; } // ==================================================================== // bundle preview // ==================================================================== private static Command BuildPreview( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var inputOption = new Option("--input") { Description = "Bundle file path (.scadabundle)", Required = true, }; var passphraseOption = new Option("--passphrase") { Description = "Passphrase for encrypted bundles.", }; var cmd = new Command("preview") { Description = "Load a bundle and print the diff preview without applying", }; cmd.Add(inputOption); cmd.Add(passphraseOption); cmd.SetAction(async (ParseResult result) => { var input = result.GetValue(inputOption)!; if (!File.Exists(input)) { OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND"); return 1; } var bytes = await File.ReadAllBytesAsync(input); var payload = new PreviewBundleCommand( Base64Bundle: Convert.ToBase64String(bytes), Passphrase: result.GetValue(passphraseOption)); return await RunBundleCommandAsync( result, urlOption, usernameOption, passwordOption, payload, jsonOk => { Console.WriteLine(jsonOk); return 0; }); }); return cmd; } // ==================================================================== // bundle import // ==================================================================== private static Command BuildImport( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var inputOption = new Option("--input") { Description = "Bundle file path (.scadabundle)", Required = true, }; var passphraseOption = new Option("--passphrase") { Description = "Passphrase for encrypted bundles.", }; var onConflictOption = new Option("--on-conflict") { Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.", DefaultValueFactory = _ => "overwrite", }; var cmd = new Command("import") { Description = "Load + apply a bundle with a single global conflict policy", }; cmd.Add(inputOption); cmd.Add(passphraseOption); cmd.Add(onConflictOption); cmd.SetAction(async (ParseResult result) => { var input = result.GetValue(inputOption)!; if (!File.Exists(input)) { OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND"); return 1; } var bytes = await File.ReadAllBytesAsync(input); var payload = new ImportBundleCommand( Base64Bundle: Convert.ToBase64String(bytes), Passphrase: result.GetValue(passphraseOption), DefaultConflictPolicy: result.GetValue(onConflictOption)!); return await RunBundleCommandAsync( result, urlOption, usernameOption, passwordOption, payload, jsonOk => { Console.WriteLine(jsonOk); return 0; }); }); return cmd; } // ==================================================================== // Shared HTTP plumbing // ==================================================================== /// /// Same shape as but with /// a longer per-command timeout (bundles are big) and a caller-supplied /// success handler so export can capture the base64 payload into a file /// rather than print the whole envelope to stdout. /// private static async Task RunBundleCommandAsync( ParseResult result, Option urlOption, Option usernameOption, Option passwordOption, object payload, Func onSuccess) { var config = CliConfig.Load(); var url = result.GetValue(urlOption); if (string.IsNullOrWhiteSpace(url)) url = config.ManagementUrl; if (string.IsNullOrWhiteSpace(url)) { OutputFormatter.WriteError( "No management URL specified. Use --url or set 'managementUrl' in ~/.scadalink/config.json.", "NO_URL"); return 1; } if (!CommandHelpers.IsValidManagementUrl(url)) { OutputFormatter.WriteError( $"Invalid management URL '{url}'.", "INVALID_URL"); return 1; } var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username); var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { OutputFormatter.WriteError( "Credentials required. Use --username/--password or SCADALINK_USERNAME/SCADALINK_PASSWORD.", "NO_CREDENTIALS"); return 1; } var commandName = ManagementCommandRegistry.GetCommandName(payload.GetType()); using var client = new ManagementHttpClient(url, username, password); var response = await client.SendCommandAsync(commandName, payload, BundleCommandTimeout); if (response.JsonData is not null) { return onSuccess(response.JsonData); } OutputFormatter.WriteError(response.Error ?? "Unknown error", response.ErrorCode ?? "ERROR"); if (response.StatusCode == 403) return 2; return 1; } private static Option?> NameListOption(string name, string description) { var opt = new Option?>(name) { Description = description, CustomParser = arg => { var token = arg.Tokens.Count == 0 ? null : arg.Tokens[0].Value; if (string.IsNullOrWhiteSpace(token)) return null; return token .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToArray(); }, }; return opt; } }