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
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/githubnext/apm

go 1.24.13
156 changes: 156 additions & 0 deletions internal/utils/console/console.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Package console provides console utility functions for formatted CLI output.
//
// All output is within printable ASCII (U+0020-U+007E). Color codes use ANSI
// escape sequences, disabled automatically when NO_COLOR is set or TERM=dumb.
package console

import (
"fmt"
"io"
"os"
"strings"
)

// StatusSymbols maps semantic names to ASCII bracket notation.
var StatusSymbols = map[string]string{
"success": "[*]",
"sparkles": "[*]",
"running": "[>]",
"gear": "[*]",
"info": "[i]",
"warning": "[!]",
"error": "[x]",
"check": "[+]",
"cross": "[x]",
"list": "[#]",
"preview": "[>]",
"robot": "[>]",
"metrics": "[#]",
"default": "[>]",
"eyes": "[>]",
"folder": "[>]",
"cogs": "[*]",
"plugin": "[>]",
"search": "[>]",
"download": "[>]",
"update": "[~]",
"remove": "[-]",
"equal": "[=]",
}

// ANSI color codes.
const (
ansiReset = "\033[0m"
ansiRed = "\033[31m"
ansiGreen = "\033[32m"
ansiYellow = "\033[33m"
ansiBlue = "\033[34m"
ansiCyan = "\033[36m"
ansiBold = "\033[1m"
)

// colorEnabled returns true when ANSI color output is supported.
func colorEnabled() bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
if os.Getenv("TERM") == "dumb" {
return false
}
return true
}

// Echo writes a message to w (defaults to os.Stdout) with optional color and
// symbol prefix. color may be "red", "green", "yellow", "blue", "cyan", or
// empty for default terminal color.
func Echo(w io.Writer, message, color, symbol string, bold bool) {
if w == nil {
w = os.Stdout
}
if sym, ok := StatusSymbols[symbol]; ok && symbol != "" {
message = sym + " " + message
}
if colorEnabled() && color != "" {
code := colorCode(color)
if bold {
fmt.Fprintf(w, "%s%s%s%s\n", ansiBold, code, message, ansiReset)
} else {
fmt.Fprintf(w, "%s%s%s\n", code, message, ansiReset)
}
} else {
fmt.Fprintln(w, message)
}
}

func colorCode(color string) string {
switch strings.ToLower(color) {
case "red":
return ansiRed
case "green":
return ansiGreen
case "yellow":
return ansiYellow
case "blue":
return ansiBlue
case "cyan":
return ansiCyan
default:
return ""
}
}

// Success prints a success message (green, bold).
func Success(message, symbol string) {
Echo(os.Stdout, message, "green", symbol, true)
}

// Error prints an error message (red).
func Error(message, symbol string) {
Echo(os.Stderr, message, "red", symbol, false)
}

// Warning prints a warning message (yellow).
func Warning(message, symbol string) {
Echo(os.Stdout, message, "yellow", symbol, false)
}

// Info prints an info message (blue).
func Info(message, symbol string) {
Echo(os.Stdout, message, "blue", symbol, false)
}

// Panel prints content framed by a simple ASCII border with an optional title.
func Panel(content, title, style string) {
if title != "" {
fmt.Printf("\n--- %s ---\n", title)
}
fmt.Println(content)
if title != "" {
fmt.Println(strings.Repeat("-", len(title)+8))
}
}

// PrintFilesTable prints a simple two-column table of file name + description.
func PrintFilesTable(files [][]string, tableTitle string) {
if tableTitle != "" {
fmt.Println(tableTitle)
}
for _, row := range files {
name := ""
desc := ""
if len(row) > 0 {
name = row[0]
}
if len(row) > 1 {
desc = row[1]
}
fmt.Printf(" %-40s %s\n", name, desc)
}
}

// DownloadSpinner prints a simple download-in-progress message and calls fn.
// Unlike Python's context-manager spinner, this is a function-based helper.
func DownloadSpinner(repoName string, fn func()) {
fmt.Printf("[>] Downloading %s...\n", repoName)
fn()
}
47 changes: 47 additions & 0 deletions internal/utils/console/console_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package console_test

import (
"bytes"
"strings"
"testing"

"github.com/githubnext/apm/internal/utils/console"
)

func TestStatusSymbols(t *testing.T) {
cases := map[string]string{
"success": "[*]",
"error": "[x]",
"warning": "[!]",
"info": "[i]",
"check": "[+]",
}
for k, want := range cases {
if got := console.StatusSymbols[k]; got != want {
t.Errorf("StatusSymbols[%q] = %q, want %q", k, got, want)
}
}
}

func TestEcho_noColor(t *testing.T) {
t.Setenv("NO_COLOR", "1")
var buf bytes.Buffer
console.Echo(&buf, "hello", "green", "", false)
if !strings.Contains(buf.String(), "hello") {
t.Errorf("expected 'hello' in output, got %q", buf.String())
}
}

func TestEcho_withSymbol(t *testing.T) {
t.Setenv("NO_COLOR", "1")
var buf bytes.Buffer
console.Echo(&buf, "done", "", "check", false)
if !strings.Contains(buf.String(), "[+]") {
t.Errorf("expected symbol [+] in output, got %q", buf.String())
}
}

func TestPrintFilesTable_smoke(t *testing.T) {
// Just ensure no panic.
console.PrintFilesTable([][]string{{"file.go", "main source"}}, "Files")
}
151 changes: 151 additions & 0 deletions internal/utils/contenthash/contenthash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Package contenthash provides deterministic SHA-256 content hashing for
// package integrity verification.
package contenthash

import (
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"sort"
)

const (
// MarkerFilename is the cache-pin marker excluded from package hashes.
MarkerFilename = ".apm-pin"
)

var excludedDirs = map[string]bool{
".git": true,
"__pycache__": true,
}

// emptyHash is the well-known hash for an empty or missing package.
var emptyHash = "sha256:" + func() string {
h := sha256.Sum256([]byte{})
return fmt.Sprintf("%x", h)
}()

// ComputePackageHash computes a deterministic SHA-256 hash of a package's
// file tree. The hash is computed over sorted file paths and their contents,
// making it independent of filesystem ordering and metadata.
//
// Returns a hash string in format "sha256:<hex_digest>".
func ComputePackageHash(packagePath string) (string, error) {
info, err := os.Lstat(packagePath)
if err != nil || !info.IsDir() {
return emptyHash, nil
}

var relFiles []string
err = filepath.WalkDir(packagePath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
// Skip symlinks
if d.Type()&os.ModeSymlink != 0 {
return nil
}
rel, relErr := filepath.Rel(packagePath, path)
if relErr != nil {
return relErr
}
if rel == "." {
return nil
}
// Skip excluded directories
parts := splitPath(rel)
for _, part := range parts {
if excludedDirs[part] {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
}
if d.IsDir() {
return nil
}
// Exclude root-level marker files
if len(parts) == 1 && parts[0] == MarkerFilename {
return nil
}
relFiles = append(relFiles, filepath.ToSlash(rel))
return nil
})
if err != nil {
return "", fmt.Errorf("contenthash: walking %s: %w", packagePath, err)
}

if len(relFiles) == 0 {
return emptyHash, nil
}

sort.Strings(relFiles)

h := sha256.New()
for _, rel := range relFiles {
h.Write([]byte(rel))
f, openErr := os.Open(filepath.Join(packagePath, filepath.FromSlash(rel)))
if openErr != nil {
return "", fmt.Errorf("contenthash: opening %s: %w", rel, openErr)
}
_, copyErr := io.Copy(h, f)
f.Close()
if copyErr != nil {
return "", fmt.Errorf("contenthash: reading %s: %w", rel, copyErr)
}
}

return fmt.Sprintf("sha256:%x", h.Sum(nil)), nil
}

// ComputeFileHash computes SHA-256 of a single file's contents.
// Returns "sha256:<hex_digest>". Returns the empty-content hash when the
// path does not exist or is not a regular file.
func ComputeFileHash(filePath string) (string, error) {
info, err := os.Lstat(filePath)
if err != nil {
return emptyHash, nil
}
if !info.Mode().IsRegular() {
return emptyHash, nil
}
f, err := os.Open(filePath)
if err != nil {
return emptyHash, nil
}
defer f.Close()
h := sha256.New()
if _, err = io.Copy(h, f); err != nil {
return "", fmt.Errorf("contenthash: reading %s: %w", filePath, err)
}
return fmt.Sprintf("sha256:%x", h.Sum(nil)), nil
}

// VerifyPackageHash verifies a package's content matches the expected hash.
// Returns true if hash matches.
func VerifyPackageHash(packagePath, expectedHash string) (bool, error) {
actual, err := ComputePackageHash(packagePath)
if err != nil {
return false, err
}
return actual == expectedHash, nil
}

// splitPath splits a slash-separated relative path into its components.
func splitPath(p string) []string {
s := filepath.ToSlash(p)
var parts []string
start := 0
for i := 0; i <= len(s); i++ {
if i == len(s) || s[i] == '/' {
if seg := s[start:i]; seg != "" && seg != "." {
parts = append(parts, seg)
}
start = i + 1
}
}
return parts
}
Loading
Loading