Skip to content

Commit 89fe442

Browse files
Copilotmrjf
andauthored
Recover iteration 8 Go utility migrations
Co-authored-by: mrjf <180956+mrjf@users.noreply.github.com>
1 parent 79e701c commit 89fe442

11 files changed

Lines changed: 976 additions & 0 deletions

File tree

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/githubnext/apm
2+
3+
go 1.24.13

internal/utils/console/console.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Package console provides console utility functions for formatted CLI output.
2+
//
3+
// All output is within printable ASCII (U+0020-U+007E). Color codes use ANSI
4+
// escape sequences, disabled automatically when NO_COLOR is set or TERM=dumb.
5+
package console
6+
7+
import (
8+
"fmt"
9+
"io"
10+
"os"
11+
"strings"
12+
)
13+
14+
// StatusSymbols maps semantic names to ASCII bracket notation.
15+
var StatusSymbols = map[string]string{
16+
"success": "[*]",
17+
"sparkles": "[*]",
18+
"running": "[>]",
19+
"gear": "[*]",
20+
"info": "[i]",
21+
"warning": "[!]",
22+
"error": "[x]",
23+
"check": "[+]",
24+
"cross": "[x]",
25+
"list": "[#]",
26+
"preview": "[>]",
27+
"robot": "[>]",
28+
"metrics": "[#]",
29+
"default": "[>]",
30+
"eyes": "[>]",
31+
"folder": "[>]",
32+
"cogs": "[*]",
33+
"plugin": "[>]",
34+
"search": "[>]",
35+
"download": "[>]",
36+
"update": "[~]",
37+
"remove": "[-]",
38+
"equal": "[=]",
39+
}
40+
41+
// ANSI color codes.
42+
const (
43+
ansiReset = "\033[0m"
44+
ansiRed = "\033[31m"
45+
ansiGreen = "\033[32m"
46+
ansiYellow = "\033[33m"
47+
ansiBlue = "\033[34m"
48+
ansiCyan = "\033[36m"
49+
ansiBold = "\033[1m"
50+
)
51+
52+
// colorEnabled returns true when ANSI color output is supported.
53+
func colorEnabled() bool {
54+
if os.Getenv("NO_COLOR") != "" {
55+
return false
56+
}
57+
if os.Getenv("TERM") == "dumb" {
58+
return false
59+
}
60+
return true
61+
}
62+
63+
// Echo writes a message to w (defaults to os.Stdout) with optional color and
64+
// symbol prefix. color may be "red", "green", "yellow", "blue", "cyan", or
65+
// empty for default terminal color.
66+
func Echo(w io.Writer, message, color, symbol string, bold bool) {
67+
if w == nil {
68+
w = os.Stdout
69+
}
70+
if sym, ok := StatusSymbols[symbol]; ok && symbol != "" {
71+
message = sym + " " + message
72+
}
73+
if colorEnabled() && color != "" {
74+
code := colorCode(color)
75+
if bold {
76+
fmt.Fprintf(w, "%s%s%s%s\n", ansiBold, code, message, ansiReset)
77+
} else {
78+
fmt.Fprintf(w, "%s%s%s\n", code, message, ansiReset)
79+
}
80+
} else {
81+
fmt.Fprintln(w, message)
82+
}
83+
}
84+
85+
func colorCode(color string) string {
86+
switch strings.ToLower(color) {
87+
case "red":
88+
return ansiRed
89+
case "green":
90+
return ansiGreen
91+
case "yellow":
92+
return ansiYellow
93+
case "blue":
94+
return ansiBlue
95+
case "cyan":
96+
return ansiCyan
97+
default:
98+
return ""
99+
}
100+
}
101+
102+
// Success prints a success message (green, bold).
103+
func Success(message, symbol string) {
104+
Echo(os.Stdout, message, "green", symbol, true)
105+
}
106+
107+
// Error prints an error message (red).
108+
func Error(message, symbol string) {
109+
Echo(os.Stderr, message, "red", symbol, false)
110+
}
111+
112+
// Warning prints a warning message (yellow).
113+
func Warning(message, symbol string) {
114+
Echo(os.Stdout, message, "yellow", symbol, false)
115+
}
116+
117+
// Info prints an info message (blue).
118+
func Info(message, symbol string) {
119+
Echo(os.Stdout, message, "blue", symbol, false)
120+
}
121+
122+
// Panel prints content framed by a simple ASCII border with an optional title.
123+
func Panel(content, title, style string) {
124+
if title != "" {
125+
fmt.Printf("\n--- %s ---\n", title)
126+
}
127+
fmt.Println(content)
128+
if title != "" {
129+
fmt.Println(strings.Repeat("-", len(title)+8))
130+
}
131+
}
132+
133+
// PrintFilesTable prints a simple two-column table of file name + description.
134+
func PrintFilesTable(files [][]string, tableTitle string) {
135+
if tableTitle != "" {
136+
fmt.Println(tableTitle)
137+
}
138+
for _, row := range files {
139+
name := ""
140+
desc := ""
141+
if len(row) > 0 {
142+
name = row[0]
143+
}
144+
if len(row) > 1 {
145+
desc = row[1]
146+
}
147+
fmt.Printf(" %-40s %s\n", name, desc)
148+
}
149+
}
150+
151+
// DownloadSpinner prints a simple download-in-progress message and calls fn.
152+
// Unlike Python's context-manager spinner, this is a function-based helper.
153+
func DownloadSpinner(repoName string, fn func()) {
154+
fmt.Printf("[>] Downloading %s...\n", repoName)
155+
fn()
156+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package console_test
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
"github.com/githubnext/apm/internal/utils/console"
9+
)
10+
11+
func TestStatusSymbols(t *testing.T) {
12+
cases := map[string]string{
13+
"success": "[*]",
14+
"error": "[x]",
15+
"warning": "[!]",
16+
"info": "[i]",
17+
"check": "[+]",
18+
}
19+
for k, want := range cases {
20+
if got := console.StatusSymbols[k]; got != want {
21+
t.Errorf("StatusSymbols[%q] = %q, want %q", k, got, want)
22+
}
23+
}
24+
}
25+
26+
func TestEcho_noColor(t *testing.T) {
27+
t.Setenv("NO_COLOR", "1")
28+
var buf bytes.Buffer
29+
console.Echo(&buf, "hello", "green", "", false)
30+
if !strings.Contains(buf.String(), "hello") {
31+
t.Errorf("expected 'hello' in output, got %q", buf.String())
32+
}
33+
}
34+
35+
func TestEcho_withSymbol(t *testing.T) {
36+
t.Setenv("NO_COLOR", "1")
37+
var buf bytes.Buffer
38+
console.Echo(&buf, "done", "", "check", false)
39+
if !strings.Contains(buf.String(), "[+]") {
40+
t.Errorf("expected symbol [+] in output, got %q", buf.String())
41+
}
42+
}
43+
44+
func TestPrintFilesTable_smoke(t *testing.T) {
45+
// Just ensure no panic.
46+
console.PrintFilesTable([][]string{{"file.go", "main source"}}, "Files")
47+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Package contenthash provides deterministic SHA-256 content hashing for
2+
// package integrity verification.
3+
package contenthash
4+
5+
import (
6+
"crypto/sha256"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"sort"
12+
)
13+
14+
const (
15+
// MarkerFilename is the cache-pin marker excluded from package hashes.
16+
MarkerFilename = ".apm-pin"
17+
)
18+
19+
var excludedDirs = map[string]bool{
20+
".git": true,
21+
"__pycache__": true,
22+
}
23+
24+
// emptyHash is the well-known hash for an empty or missing package.
25+
var emptyHash = "sha256:" + func() string {
26+
h := sha256.Sum256([]byte{})
27+
return fmt.Sprintf("%x", h)
28+
}()
29+
30+
// ComputePackageHash computes a deterministic SHA-256 hash of a package's
31+
// file tree. The hash is computed over sorted file paths and their contents,
32+
// making it independent of filesystem ordering and metadata.
33+
//
34+
// Returns a hash string in format "sha256:<hex_digest>".
35+
func ComputePackageHash(packagePath string) (string, error) {
36+
info, err := os.Lstat(packagePath)
37+
if err != nil || !info.IsDir() {
38+
return emptyHash, nil
39+
}
40+
41+
var relFiles []string
42+
err = filepath.WalkDir(packagePath, func(path string, d os.DirEntry, err error) error {
43+
if err != nil {
44+
return err
45+
}
46+
// Skip symlinks
47+
if d.Type()&os.ModeSymlink != 0 {
48+
return nil
49+
}
50+
rel, relErr := filepath.Rel(packagePath, path)
51+
if relErr != nil {
52+
return relErr
53+
}
54+
if rel == "." {
55+
return nil
56+
}
57+
// Skip excluded directories
58+
parts := splitPath(rel)
59+
for _, part := range parts {
60+
if excludedDirs[part] {
61+
if d.IsDir() {
62+
return filepath.SkipDir
63+
}
64+
return nil
65+
}
66+
}
67+
if d.IsDir() {
68+
return nil
69+
}
70+
// Exclude root-level marker files
71+
if len(parts) == 1 && parts[0] == MarkerFilename {
72+
return nil
73+
}
74+
relFiles = append(relFiles, filepath.ToSlash(rel))
75+
return nil
76+
})
77+
if err != nil {
78+
return "", fmt.Errorf("contenthash: walking %s: %w", packagePath, err)
79+
}
80+
81+
if len(relFiles) == 0 {
82+
return emptyHash, nil
83+
}
84+
85+
sort.Strings(relFiles)
86+
87+
h := sha256.New()
88+
for _, rel := range relFiles {
89+
h.Write([]byte(rel))
90+
f, openErr := os.Open(filepath.Join(packagePath, filepath.FromSlash(rel)))
91+
if openErr != nil {
92+
return "", fmt.Errorf("contenthash: opening %s: %w", rel, openErr)
93+
}
94+
_, copyErr := io.Copy(h, f)
95+
f.Close()
96+
if copyErr != nil {
97+
return "", fmt.Errorf("contenthash: reading %s: %w", rel, copyErr)
98+
}
99+
}
100+
101+
return fmt.Sprintf("sha256:%x", h.Sum(nil)), nil
102+
}
103+
104+
// ComputeFileHash computes SHA-256 of a single file's contents.
105+
// Returns "sha256:<hex_digest>". Returns the empty-content hash when the
106+
// path does not exist or is not a regular file.
107+
func ComputeFileHash(filePath string) (string, error) {
108+
info, err := os.Lstat(filePath)
109+
if err != nil {
110+
return emptyHash, nil
111+
}
112+
if !info.Mode().IsRegular() {
113+
return emptyHash, nil
114+
}
115+
f, err := os.Open(filePath)
116+
if err != nil {
117+
return emptyHash, nil
118+
}
119+
defer f.Close()
120+
h := sha256.New()
121+
if _, err = io.Copy(h, f); err != nil {
122+
return "", fmt.Errorf("contenthash: reading %s: %w", filePath, err)
123+
}
124+
return fmt.Sprintf("sha256:%x", h.Sum(nil)), nil
125+
}
126+
127+
// VerifyPackageHash verifies a package's content matches the expected hash.
128+
// Returns true if hash matches.
129+
func VerifyPackageHash(packagePath, expectedHash string) (bool, error) {
130+
actual, err := ComputePackageHash(packagePath)
131+
if err != nil {
132+
return false, err
133+
}
134+
return actual == expectedHash, nil
135+
}
136+
137+
// splitPath splits a slash-separated relative path into its components.
138+
func splitPath(p string) []string {
139+
s := filepath.ToSlash(p)
140+
var parts []string
141+
start := 0
142+
for i := 0; i <= len(s); i++ {
143+
if i == len(s) || s[i] == '/' {
144+
if seg := s[start:i]; seg != "" && seg != "." {
145+
parts = append(parts, seg)
146+
}
147+
start = i + 1
148+
}
149+
}
150+
return parts
151+
}

0 commit comments

Comments
 (0)