Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
identical structs on every redraw are eliminated. Reduces GC pressure
noticeably on low-power machines and slow terminals.

### Fixed
- Running `layerx <image>` with no reachable container engine (or with a
Podman connection that is unconfigured, missing, or malformed) now prints the
same friendly, actionable message the other commands already showed —
previously the interactive command printed the raw internal error string with
no recovery hint.
- The archive "could not …" error in the interactive viewer no longer suggests
freeing disk space or setting `TMPDIR` for failures that are not disk-full
(for example a seek or I/O error on a network mount), matching the
command-line behaviour. The disk-space hint now appears only when the
underlying cause is a full disk.
- JSON export now reports a clean "no space left to write &lt;path&gt;" message on a
full disk instead of leaking the internal temporary spool file path into the
error shown to the user.

## [v1.6.0] - 2026-07-28

Eight built-in colour themes, transparent-background mode, and TUI visual
Expand Down
28 changes: 22 additions & 6 deletions cmd/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package cmd
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"syscall"

"github.com/deveshctl/layerx/image"
)
Expand Down Expand Up @@ -95,7 +97,10 @@ func runJSONExportFromAnalysis(analysis *image.Analysis, outputPath string) erro
}

if err := writeJSONAtomic(outputPath, data); err != nil {
return fmt.Errorf("writing %s: %w", outputPath, err)
// writeJSONAtomic already names outputPath in its disk-full message;
// wrap the generic path with %w so callers can still errors.Is/As it
// without prefixing a second, redundant "writing <path>:".
return fmt.Errorf("could not write JSON: %w", err)
}

fmt.Fprintf(os.Stderr, "layerx: wrote analysis to %s\n", outputPath)
Expand Down Expand Up @@ -174,26 +179,37 @@ func writeJSONAtomic(targetPath string, data []byte) error {
dir := filepath.Dir(targetPath)
f, err := os.CreateTemp(dir, ".layerx-json-*.tmp")
if err != nil {
return err
return cleanDiskWriteErr(targetPath, err)
}
tmp := f.Name()
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
return cleanDiskWriteErr(targetPath, err)
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
return cleanDiskWriteErr(targetPath, err)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
return cleanDiskWriteErr(targetPath, err)
}
if err := os.Rename(tmp, targetPath); err != nil {
os.Remove(tmp)
return err
return cleanDiskWriteErr(targetPath, err)
}
return nil
}

// cleanDiskWriteErr rewrites a disk-full failure so the user-facing message
// names the output path they supplied instead of the internal .tmp spool file
// (which the raw syscall error carries). Non-ENOSPC errors pass through
// unchanged — their paths are already the target or a directory the user chose.
func cleanDiskWriteErr(targetPath string, err error) error {
if errors.Is(err, syscall.ENOSPC) {
return fmt.Errorf("no space left to write %s (free up disk space and try again)", targetPath)
}
return err
}
20 changes: 20 additions & 0 deletions cmd/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"

"github.com/deveshctl/layerx/image"
Expand Down Expand Up @@ -308,3 +309,22 @@ func TestRunJSONExport_ContextCancelled(t *testing.T) {
_, statErr := os.Stat(outPath)
assert.True(t, os.IsNotExist(statErr), "no output file must exist when resolve was cancelled (statErr = %v)", statErr)
}

func TestCleanDiskWriteErr_ENOSPCNamesTargetNotTmp(t *testing.T) {
// The raw write error carries the internal .tmp spool path; on ENOSPC the
// user-facing message must name the output path they supplied instead.
target := "/home/user/out.json"
raw := &os.PathError{Op: "write", Path: "/home/user/.layerx-json-123.tmp", Err: syscall.ENOSPC}
got := cleanDiskWriteErr(target, raw)
assert.Contains(t, got.Error(), target)
assert.NotContains(t, got.Error(), ".tmp")
assert.Contains(t, got.Error(), "no space left")
}

func TestCleanDiskWriteErr_NonENOSPCPassesThrough(t *testing.T) {
// Non-disk-full errors are returned unchanged — their paths are the target
// or a directory the user chose, so there is nothing internal to hide.
raw := errors.New("permission denied")
got := cleanDiskWriteErr("/home/user/out.json", raw)
assert.Same(t, raw, got)
}
19 changes: 15 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,24 +256,35 @@ func runInspect(cmd *cobra.Command, args []string) error {
return runJSONExport(cmd.Context(), imageRef, flagJSON, noCache)
}

// selectResolver produces the typed engine/resolver errors (no engine
// found, Podman connection not configured, malformed connection config)
// synchronously, before the TUI starts. Route them through presentCLIError
// so the user sees the friendly, actionable message rather than the raw
// Error() string cobra would otherwise print — and silence cobra's own
// printer so it does not double-print. Mirrors the --json branch above.
cmd.SilenceErrors = true

resolver, err := selectResolver(imageRef)
if err != nil {
return err
return presentCLIError(os.Stderr, err)
}

theme, err := resolveTheme(cfg.Theme, flagTheme)
if err != nil {
return err
return presentCLIError(os.Stderr, err)
}

return tui.Run(tui.Config{
if err := tui.Run(tui.Config{
ImageRef: imageRef,
Resolver: resolver,
NoCache: noCache,
Platform: activePlatformDisplay(),
Theme: theme,
TransparentBg: cfg.TransparentBackground,
})
}); err != nil {
return presentCLIError(os.Stderr, err)
}
return nil
}

// warnCIThresholdFlagsIgnored prints a warning to w when CI=true is active
Expand Down
4 changes: 4 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ func main() {
// `layerx build` exactly like `docker build` / `podman build`.
os.Exit(e.ExitCode)
}
// Exit 2 covers ErrCIUsage, ErrCompareUsage, and any unrecognised error.
// Usage errors are deliberately distinct from rule failures (ErrCIFailed →
// 1) and build failures (ErrBuildFailed → engine code): do NOT remap
// ErrCIUsage/ErrCompareUsage to exit 1.
os.Exit(2)
}

Expand Down
10 changes: 9 additions & 1 deletion tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"syscall"
"time"

tea "charm.land/bubbletea/v2"
Expand Down Expand Up @@ -2438,7 +2439,14 @@ func friendlyError(err error) string {
return fmt.Sprintf("Not a valid image archive: %q. Expected a docker-save or OCI layout tarball.", invalidErr.Path)
}
if infraErr, ok := errors.AsType[*image.ErrArchiveInfra](err); ok {
return fmt.Sprintf("Could not %s: %v. Free up disk space or set TMPDIR to a writable location and try again.", infraErr.Op, infraErr.Cause)
// The disk-space hint is only trustworthy when the cause is actually a
// full disk. ErrArchiveInfra also covers seek/temp-file/I/O failures
// (e.g. on a network mount), where telling the user to free disk space
// misdirects them. Show the hint only on ENOSPC, matching the CLI.
if errors.Is(infraErr.Cause, syscall.ENOSPC) {
return fmt.Sprintf("Could not %s: %v. Free up disk space or set TMPDIR to a writable location and try again.", infraErr.Op, infraErr.Cause)
}
return fmt.Sprintf("Could not %s: %v.", infraErr.Op, infraErr.Cause)
}
// Platform errors are already user-readable; pass them through verbatim
// so the multi-line "Available platforms:" list keeps its formatting.
Expand Down
20 changes: 20 additions & 0 deletions tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"

tea "charm.land/bubbletea/v2"
Expand Down Expand Up @@ -758,6 +759,25 @@ func TestFriendlyErrorGenericReturnsMessage(t *testing.T) {
assert.Equal(t, "unexpected failure", friendlyError(err))
}

func TestFriendlyErrorArchiveInfra_ENOSPCKeepsHint(t *testing.T) {
// A genuine disk-full cause should keep the free-up-space hint.
err := &image.ErrArchiveInfra{Op: "spool layer to temp file", Cause: syscall.ENOSPC}
msg := friendlyError(err)
assert.Contains(t, msg, "spool layer to temp file")
assert.Contains(t, msg, "Free up disk space")
}

func TestFriendlyErrorArchiveInfra_NonENOSPCDropsHint(t *testing.T) {
// A seek/I/O failure that is not ENOSPC must NOT tell the user to free
// disk space — that misdirects on network-mount and permission errors.
err := &image.ErrArchiveInfra{Op: "seek in spooled archive", Cause: errors.New("input/output error")}
msg := friendlyError(err)
assert.Contains(t, msg, "seek in spooled archive")
assert.Contains(t, msg, "input/output error")
assert.NotContains(t, msg, "Free up disk space")
assert.NotContains(t, msg, "TMPDIR")
}

// --- renderLayers ------------------------------------------------------------

func TestRenderLayersDoesNotPanic(t *testing.T) {
Expand Down
Loading