Skip to content

Commit 129c2dc

Browse files
authored
Fall back to simple text output when stdout is not a TTY (#1392)
- Detect when stdout is not a tty and fall back to simple line-by-line status output. - Fixes silent commands when piping to a file. - Closes #113.
1 parent 1c37384 commit 129c2dc

3 files changed

Lines changed: 91 additions & 5 deletions

File tree

Sources/ContainerCommands/Flags+ProgressConfig.swift

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,27 @@
1515
//===----------------------------------------------------------------------===//
1616

1717
import ContainerAPIClient
18+
import Foundation
1819
import TerminalProgress
1920

2021
extension Flags.Progress {
22+
/// Resolves `.auto` into `.ansi` or `.plain` based on whether stderr is a TTY.
23+
private func resolvedProgress() -> ProgressType {
24+
switch progress {
25+
case .auto:
26+
return isatty(FileHandle.standardError.fileDescriptor) == 1 ? .ansi : .plain
27+
case .none, .ansi, .plain, .color:
28+
return progress
29+
}
30+
}
31+
2132
/// Creates a `ProgressConfig` based on the selected progress type.
2233
///
2334
/// For `.none`, progress updates are disabled. For `.ansi`, the given parameters
2435
/// are used as-is. For `.plain`, ANSI-incompatible features (spinner, clear on finish)
2536
/// are disabled and the output mode is set to `.plain`. For `.color`, behavior matches
2637
/// `.ansi` but the output mode is set to `.color` to enable color-coded output.
38+
/// For `.auto`, the type is resolved by checking whether stderr is a TTY.
2739
func makeConfig(
2840
description: String = "",
2941
itemsName: String = "it",
@@ -33,13 +45,14 @@ extension Flags.Progress {
3345
ignoreSmallSize: Bool = false,
3446
totalTasks: Int? = nil
3547
) throws -> ProgressConfig {
36-
switch progress {
48+
let resolved = resolvedProgress()
49+
switch resolved {
3750
case .none:
3851
return try ProgressConfig(disableProgressUpdates: true)
3952
case .ansi, .plain, .color:
40-
let isPlain = progress == .plain
53+
let isPlain = resolved == .plain
4154
let outputMode: ProgressConfig.OutputMode
42-
switch progress {
55+
switch resolved {
4356
case .plain: outputMode = .plain
4457
case .color: outputMode = .color
4558
default: outputMode = .ansi
@@ -56,6 +69,8 @@ extension Flags.Progress {
5669
clearOnFinish: !isPlain,
5770
outputMode: outputMode
5871
)
72+
case .auto:
73+
fatalError("unreachable: .auto should have been resolved")
5974
}
6075
}
6176
}

Sources/Services/ContainerAPIService/Client/Flags.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,14 +351,15 @@ public struct Flags {
351351
}
352352

353353
public enum ProgressType: String, ExpressibleByArgument {
354+
case auto
354355
case none
355356
case ansi
356357
case plain
357358
case color
358359
}
359360

360-
@Option(name: .long, help: ArgumentHelp("Progress type (format: none|ansi|plain|color)", valueName: "type"))
361-
public var progress: ProgressType = .ansi
361+
@Option(name: .long, help: ArgumentHelp("Progress type (format: auto|none|ansi|plain|color)", valueName: "type"))
362+
public var progress: ProgressType = .auto
362363
}
363364

364365
public struct ImageFetch: ParsableArguments {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import Testing
19+
20+
class TestCLIProgressAuto: CLITest {
21+
@Test func testAutoProgressFallsBackToPlainWhenPiped() throws {
22+
let (_, _, error, status) = try run(arguments: [
23+
"image", "pull",
24+
"--progress", "auto",
25+
alpine,
26+
])
27+
#expect(status == 0, "image pull should succeed, stderr: \(error)")
28+
let lines = error.components(separatedBy: .newlines)
29+
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
30+
#expect(!lines.isEmpty, "expected plain progress output on stderr when piped")
31+
#expect(!error.contains("\u{1B}["), "expected no ANSI escapes in piped output")
32+
}
33+
34+
@Test func testExplicitPlainProgress() throws {
35+
let (_, _, error, status) = try run(arguments: [
36+
"image", "pull",
37+
"--progress", "plain",
38+
alpine,
39+
])
40+
#expect(status == 0, "image pull --progress plain should succeed, stderr: \(error)")
41+
let lines = error.components(separatedBy: .newlines)
42+
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
43+
#expect(!lines.isEmpty, "expected plain progress output on stderr")
44+
#expect(!error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain")
45+
}
46+
47+
@Test func testExplicitAnsiProgress() throws {
48+
let (_, _, error, status) = try run(arguments: [
49+
"image", "pull",
50+
"--progress", "ansi",
51+
alpine,
52+
])
53+
#expect(status == 0, "image pull --progress ansi should succeed, stderr: \(error)")
54+
let lines = error.components(separatedBy: .newlines)
55+
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
56+
#expect(!lines.isEmpty, "expected ansi progress output on stderr")
57+
}
58+
59+
@Test func testNoneProgressSuppressesOutput() throws {
60+
let (_, _, error, status) = try run(arguments: [
61+
"image", "pull",
62+
"--progress", "none",
63+
alpine,
64+
])
65+
#expect(status == 0, "image pull --progress none should succeed, stderr: \(error)")
66+
let lines = error.components(separatedBy: .newlines)
67+
.filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty }
68+
#expect(lines.isEmpty, "expected no progress output on stderr with --progress none")
69+
}
70+
}

0 commit comments

Comments
 (0)