diff --git a/src/ScadaLink.CLI/Commands/BundleCommands.cs b/src/ScadaLink.CLI/Commands/BundleCommands.cs
new file mode 100644
index 0000000..0eb595a
--- /dev/null
+++ b/src/ScadaLink.CLI/Commands/BundleCommands.cs
@@ -0,0 +1,301 @@
+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;
+ }
+}
diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs
index 240b410..d474833 100644
--- a/src/ScadaLink.CLI/Program.cs
+++ b/src/ScadaLink.CLI/Program.cs
@@ -33,6 +33,7 @@ rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, pas
rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
+rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ =>
{
diff --git a/src/ScadaLink.Commons/Messages/Management/TransportCommands.cs b/src/ScadaLink.Commons/Messages/Management/TransportCommands.cs
new file mode 100644
index 0000000..355277f
--- /dev/null
+++ b/src/ScadaLink.Commons/Messages/Management/TransportCommands.cs
@@ -0,0 +1,62 @@
+using ScadaLink.Commons.Types.Transport;
+
+namespace ScadaLink.Commons.Messages.Management;
+
+///
+/// Exports a bundle. Names rather than IDs in the selection so test scripts can
+/// be written without an ID lookup step. All=true overrides the per-type
+/// name lists and exports every entity of every supported type.
+///
+public sealed record ExportBundleCommand(
+ bool All,
+ IReadOnlyList? TemplateNames,
+ IReadOnlyList? SharedScriptNames,
+ IReadOnlyList? ExternalSystemNames,
+ IReadOnlyList? DatabaseConnectionNames,
+ IReadOnlyList? NotificationListNames,
+ IReadOnlyList? SmtpConfigurationNames,
+ IReadOnlyList? ApiKeyNames,
+ IReadOnlyList? ApiMethodNames,
+ bool IncludeDependencies,
+ string? Passphrase,
+ string SourceEnvironment);
+
+///
+/// Bundle body returned as base64-encoded ZIP. is the
+/// pre-encoded size for sanity checks against the configured bundle cap.
+///
+public sealed record ExportBundleResult(
+ string Base64Bundle,
+ int ByteCount);
+
+///
+/// Loads a bundle and returns its preview without applying anything. Useful
+/// for tests that want to assert on the diff shape before committing.
+///
+public sealed record PreviewBundleCommand(
+ string Base64Bundle,
+ string? Passphrase);
+
+public sealed record PreviewBundleResult(
+ IReadOnlyList Items,
+ int AddCount,
+ int ModifiedCount,
+ int IdenticalCount,
+ int BlockerCount);
+
+///
+/// Loads, previews, and applies a bundle in a single call. The diff is built
+/// internally; every row is resolved with
+/// , every
+/// row gets , every
+/// row gets ,
+/// and any row fails the call.
+///
+/// Valid values: "skip",
+/// "overwrite", "rename". Rename mints a unique suffix per row.
+///
+///
+public sealed record ImportBundleCommand(
+ string Base64Bundle,
+ string? Passphrase,
+ string DefaultConflictPolicy);
diff --git a/src/ScadaLink.ManagementService/ManagementActor.cs b/src/ScadaLink.ManagementService/ManagementActor.cs
index 10e79d7..8b69783 100644
--- a/src/ScadaLink.ManagementService/ManagementActor.cs
+++ b/src/ScadaLink.ManagementService/ManagementActor.cs
@@ -16,9 +16,11 @@ using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.DebugView;
+using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Messages.Management;
using ScadaLink.Commons.Messages.RemoteQuery;
using ScadaLink.Commons.Types.InboundApi;
+using ScadaLink.Commons.Types.Transport;
using ScadaLink.DeploymentManager;
using ScadaLink.HealthMonitoring;
using ScadaLink.Communication;
@@ -176,7 +178,13 @@ public class ManagementActor : ReceiveActor
or UpdateAreaCommand
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
or MoveTemplateFolderCommand or DeleteTemplateFolderCommand
- or MoveTemplateToFolderCommand => "Design",
+ or MoveTemplateToFolderCommand
+ or ExportBundleCommand => "Design",
+
+ // Transport import operations (mirror the Central UI gating: Admin
+ // for inbound bundle handling because they mutate cross-cutting
+ // configuration; Export stays Design because it only reads).
+ PreviewBundleCommand or ImportBundleCommand => "Admin",
// Deployment operations
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
@@ -333,6 +341,11 @@ public class ManagementActor : ReceiveActor
DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user),
DebugSnapshotCommand cmd => await HandleDebugSnapshot(sp, cmd, user),
+ // Transport (#24) bundle operations
+ ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
+ PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd),
+ ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username),
+
// NOTE: ResolveRolesCommand is intentionally NOT dispatched. The two-step
// "ResolveRoles + command" flow is retired — the HTTP endpoint performs LDAP
// auth and role resolution itself before sending a single envelope. Leaving a
@@ -1686,6 +1699,190 @@ public class ManagementActor : ReceiveActor
var request = new DebugSnapshotRequest(instance.UniqueName, Guid.NewGuid().ToString("N"));
return await commService.RequestDebugSnapshotAsync(site.SiteIdentifier, request);
}
+
+ // ========================================================================
+ // Transport (#24) bundle operations
+ // ========================================================================
+
+ ///
+ /// Resolves the per-type name lists in against the
+ /// repositories, builds an , exports the bundle,
+ /// and returns the encrypted ZIP as base64.
+ ///
+ private static async Task