diff --git a/Sources/ContainerCommands/Flags+ProgressConfig.swift b/Sources/ContainerCommands/Flags+ProgressConfig.swift index e876c94cf..2e64963c2 100644 --- a/Sources/ContainerCommands/Flags+ProgressConfig.swift +++ b/Sources/ContainerCommands/Flags+ProgressConfig.swift @@ -15,15 +15,27 @@ //===----------------------------------------------------------------------===// import ContainerAPIClient +import Foundation import TerminalProgress extension Flags.Progress { + /// Resolves `.auto` into `.ansi` or `.plain` based on whether stderr is a TTY. + private func resolvedProgress() -> ProgressType { + switch progress { + case .auto: + return isatty(FileHandle.standardError.fileDescriptor) == 1 ? .ansi : .plain + case .none, .ansi, .plain, .color: + return progress + } + } + /// Creates a `ProgressConfig` based on the selected progress type. /// /// For `.none`, progress updates are disabled. For `.ansi`, the given parameters /// are used as-is. For `.plain`, ANSI-incompatible features (spinner, clear on finish) /// are disabled and the output mode is set to `.plain`. For `.color`, behavior matches /// `.ansi` but the output mode is set to `.color` to enable color-coded output. + /// For `.auto`, the type is resolved by checking whether stderr is a TTY. func makeConfig( description: String = "", itemsName: String = "it", @@ -33,13 +45,14 @@ extension Flags.Progress { ignoreSmallSize: Bool = false, totalTasks: Int? = nil ) throws -> ProgressConfig { - switch progress { + let resolved = resolvedProgress() + switch resolved { case .none: return try ProgressConfig(disableProgressUpdates: true) case .ansi, .plain, .color: - let isPlain = progress == .plain + let isPlain = resolved == .plain let outputMode: ProgressConfig.OutputMode - switch progress { + switch resolved { case .plain: outputMode = .plain case .color: outputMode = .color default: outputMode = .ansi @@ -56,6 +69,8 @@ extension Flags.Progress { clearOnFinish: !isPlain, outputMode: outputMode ) + case .auto: + fatalError("unreachable: .auto should have been resolved") } } } diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index 04e5467c9..6a964e8cb 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -351,14 +351,15 @@ public struct Flags { } public enum ProgressType: String, ExpressibleByArgument { + case auto case none case ansi case plain case color } - @Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi|plain|color)", valueName: "type")) - public var progress: ProgressType = .ansi + @Option(name: .long, help: ArgumentHelp("Progress type (format: auto|none|ansi|plain|color)", valueName: "type")) + public var progress: ProgressType = .auto } public struct ImageFetch: ParsableArguments { diff --git a/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift b/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift new file mode 100644 index 000000000..fe326faef --- /dev/null +++ b/Tests/CLITests/Subcommands/Images/TestCLIProgressAuto.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +class TestCLIProgressAuto: CLITest { + @Test func testAutoProgressFallsBackToPlainWhenPiped() throws { + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "auto", + alpine, + ]) + #expect(status == 0, "image pull should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected plain progress output on stderr when piped") + #expect(!error.contains("\u{1B}["), "expected no ANSI escapes in piped output") + } + + @Test func testExplicitPlainProgress() throws { + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "plain", + alpine, + ]) + #expect(status == 0, "image pull --progress plain should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected plain progress output on stderr") + #expect(!error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain") + } + + @Test func testExplicitAnsiProgress() throws { + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "ansi", + alpine, + ]) + #expect(status == 0, "image pull --progress ansi should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected ansi progress output on stderr") + } + + @Test func testNoneProgressSuppressesOutput() throws { + let (_, _, error, status) = try run(arguments: [ + "image", "pull", + "--progress", "none", + alpine, + ]) + #expect(status == 0, "image pull --progress none should succeed, stderr: \(error)") + let lines = error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(lines.isEmpty, "expected no progress output on stderr with --progress none") + } +}