utils
packageAPI reference for the utils
package.
Imports
(24)fmt
STD
net
STD
os
STD
os/exec
STD
strings
PKG
github.com/pkg/browser
STD
testing
STD
log
STD
net/http
STD
time
PKG
github.com/fatih/color
INT
github.com/rfwlab/rfw/v2/core
STD
context
STD
crypto/sha256
STD
crypto/subtle
STD
encoding/hex
STD
encoding/json
STD
errors
STD
io
STD
path/filepath
STD
runtime
INT
github.com/rfwlab/rfw/v2/internal/safehttp
STD
bytes
STD
net/http/httptest
GetLocalIP
GetLocalIP returns the first non-loopback IPv4 address.
Returns
func GetLocalIP() (string, error)
{
addrs, err := net.InterfaceAddrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() && ipNet.IP.To4() != nil {
return ipNet.IP.String(), nil
}
}
return "", fmt.Errorf("no local IP address found")
}
OpenBrowser
OpenBrowser opens url with the configured system browser.
Parameters
Returns
func OpenBrowser(url string) error
{
if configured := os.Getenv("BROWSER"); configured != "" {
cmd := strings.Fields(configured)[0]
if _, err := exec.LookPath(cmd); err != nil {
return err
}
}
return browser.OpenURL(url)
}
TestOpenBrowserError
Parameters
func TestOpenBrowserError(t *testing.T)
{
t.Setenv("BROWSER", "nonexistent-browser")
if err := OpenBrowser("http://example.com"); err == nil {
t.Fatalf("expected error when browser command is missing")
}
}
ClearScreen
ClearScreen clears the terminal.
func ClearScreen()
{
fmt.Print("\033[H\033[2J")
fmt.Println()
}
PrintStartupInfo
PrintStartupInfo prints local and network development URLs.
Parameters
func PrintStartupInfo(port, httpsPort, localIP string, host bool)
{
fmt.Println(indent, boldRed("rfw"), faint(core.Version()))
fmt.Println()
fmt.Println(indent, red("➜ "), bold("Local:"), red(fmt.Sprintf("http://localhost:%s/ - https://localhost:%s/", port, httpsPort)))
if host {
fmt.Println(indent, red("➜ "), faint(bold("Network:")), white(fmt.Sprintf("http://%s:%s/ (https://%s:%s/)", localIP, port, localIP, httpsPort)))
} else {
fmt.Println(indent, red("➜ "), faint(bold("Network:")), white("--host"), faint("to expose"))
}
fmt.Println(indent, faintRed("➜ "), faint("Press"), bold("h + enter"), faint("to show help"))
fmt.Println()
}
LogServeRequest
LogServeRequest logs an HTTP request path.
Parameters
func LogServeRequest(r *http.Request)
{
fmt.Printf("%s %s %s\n", faint(time.Now().Format("15:04:05")), boldYellow("serving"), faint(r.URL.Path))
}
Info
Info prints an informational CLI message.
Parameters
func Info(message string)
{
fmt.Println(boldRed("[rfw]"), message)
}
Fatal
Fatal logs a fatal CLI error and exits.
Parameters
func Fatal(message string, err error)
{
log.Fatalf(boldRed("[rfw] "), message, err)
}
Error
Error reports a failure without terminating the process, for loops that
must survive it (e.g. the dev watcher across compile errors).
Parameters
func Error(message string, err error)
{
log.Printf("%s%s%v", boldRed("[rfw] "), message, err)
}
EnableDebug
EnableDebug enables or disables debug output.
Parameters
func EnableDebug(d bool)
{ dbg = d }
IsDebug
IsDebug reports whether debug mode is enabled.
Returns
func IsDebug() bool
{ return dbg }
Debug
Debug prints a message when debug output is enabled.
Parameters
func Debug(message string)
{
if dbg {
fmt.Println(boldRed("[rfw][debug]"), faint(message))
}
}
PrintHelp
PrintHelp prints CLI shortcuts and flags.
func PrintHelp()
{
ClearScreen()
fmt.Println()
fmt.Println(indent, red("➜ "), bold("Help"))
fmt.Println(indent, indent, yellow("➜ "), bold("Shortcuts"))
fmt.Println(indent, indent, indent, faint("Press"), bold("c + enter"), faint("to stop the server"))
fmt.Println(indent, indent, indent, faint("Press"), bold("o + enter"), faint("to open the browser"))
fmt.Println(indent, indent, indent, faint("Press"), bold("u + enter"), faint("to show the startup info and clear logs"))
fmt.Println(indent, indent, indent, faint("Press"), bold("h + enter"), faint("to show this help"))
fmt.Println(indent, indent, yellow("➜ "), bold("Flags"))
fmt.Println(indent, indent, indent, faint("Use"), bold("--host"), faint("to expose the server to the network"))
fmt.Println(indent, indent, indent, faint("Use"), bold("--port=XXXX"), faint("to specify a port"))
fmt.Println()
}
githubRelease
type githubRelease struct
Fields
| Name | Type | Description |
|---|---|---|
| TagName | string | json:"tag_name" |
| Assets | []struct { BrowserDownloadURL string `json:"browser_download_url"` Digest string `json:"digest"` Name string `json:"name"` } | json:"assets" |
fetchLatestRelease
Returns
func fetchLatestRelease() (githubRelease, error)
{
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := safehttp.NewRequest(
ctx,
http.MethodGet,
"https://api.github.com/repos/"+githubRepo+"/releases/latest",
)
if err != nil {
return githubRelease{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := safehttp.NewClient().Do(req)
if err != nil {
return githubRelease{}, err
}
if resp.StatusCode != http.StatusOK {
if closeErr := resp.Body.Close(); closeErr != nil {
return githubRelease{}, closeErr
}
return githubRelease{}, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
closeErr := resp.Body.Close()
if err != nil {
return githubRelease{}, err
}
if closeErr != nil {
return githubRelease{}, closeErr
}
var release githubRelease
if err := json.Unmarshal(body, &release); err != nil {
return githubRelease{}, err
}
if release.TagName == "" {
return githubRelease{}, fmt.Errorf("latest release has no tag")
}
return release, nil
}
shouldCheckUpdate
Returns
func shouldCheckUpdate() bool
{
home, err := os.UserHomeDir()
if err != nil {
return true
}
path := home + "/" + checkFile
info, err := os.Stat(path)
if err != nil {
return true
}
return time.Since(info.ModTime()) > checkInterval
}
markChecked
Returns
func markChecked() error
{
home, err := os.UserHomeDir()
if err != nil {
return err
}
path := home + "/" + checkFile
return os.WriteFile(path, []byte(time.Now().Format(time.RFC3339)), 0o600)
}
semanticVersion
type semanticVersion struct
Fields
| Name | Type | Description |
|---|---|---|
| core | [3]string | |
| prerelease | []string |
isNewer
Parameters
Returns
func isNewer(current, latest string) bool
{
currentVersion, currentOK := parseSemanticVersion(current)
latestVersion, latestOK := parseSemanticVersion(latest)
if !currentOK || !latestOK {
return false
}
for i := 0; i < 3; i++ {
comparison := compareNumericIdentifier(latestVersion.core[i], currentVersion.core[i])
if comparison > 0 {
return true
}
if comparison < 0 {
return false
}
}
return comparePrerelease(latestVersion.prerelease, currentVersion.prerelease) > 0
}
parseSemanticVersion
Parameters
Returns
func parseSemanticVersion(raw string) (semanticVersion, bool)
{
value := strings.TrimPrefix(raw, "v")
if value == "" {
return semanticVersion{}, false
}
main, build, hasBuild := strings.Cut(value, "+")
if hasBuild && !validIdentifierList(build, false) {
return semanticVersion{}, false
}
core, prerelease, hasPrerelease := strings.Cut(main, "-")
coreParts := strings.Split(core, ".")
if len(coreParts) != 3 {
return semanticVersion{}, false
}
var parsed semanticVersion
for i, part := range coreParts {
if !validNumericIdentifier(part) {
return semanticVersion{}, false
}
parsed.core[i] = part
}
if hasPrerelease {
if !validIdentifierList(prerelease, true) {
return semanticVersion{}, false
}
parsed.prerelease = strings.Split(prerelease, ".")
}
return parsed, true
}
validIdentifierList
Parameters
Returns
func validIdentifierList(value string, rejectNumericLeadingZero bool) bool
{
parts := strings.Split(value, ".")
for _, part := range parts {
if part == "" {
return false
}
numeric := true
for i := 0; i < len(part); i++ {
character := part[i]
if character < '0' || character > '9' {
numeric = false
}
if (character < '0' || character > '9') &&
(character < 'A' || character > 'Z') &&
(character < 'a' || character > 'z') &&
character != '-' {
return false
}
}
if rejectNumericLeadingZero && numeric && len(part) > 1 && part[0] == '0' {
return false
}
}
return true
}
validNumericIdentifier
Parameters
Returns
func validNumericIdentifier(value string) bool
{
if value == "" || len(value) > 1 && value[0] == '0' {
return false
}
for i := 0; i < len(value); i++ {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return true
}
compareNumericIdentifier
Parameters
Returns
func compareNumericIdentifier(left, right string) int
{
if len(left) != len(right) {
if len(left) > len(right) {
return 1
}
return -1
}
return strings.Compare(left, right)
}
comparePrerelease
Parameters
Returns
func comparePrerelease(left, right []string) int
{
if len(left) == 0 || len(right) == 0 {
switch {
case len(left) == 0 && len(right) == 0:
return 0
case len(left) == 0:
return 1
default:
return -1
}
}
sharedLength := min(len(left), len(right))
for i := 0; i < sharedLength; i++ {
leftNumeric := validNumericIdentifier(left[i])
rightNumeric := validNumericIdentifier(right[i])
switch {
case leftNumeric && rightNumeric:
if comparison := compareNumericIdentifier(left[i], right[i]); comparison != 0 {
return comparison
}
case leftNumeric:
return -1
case rightNumeric:
return 1
default:
if comparison := strings.Compare(left[i], right[i]); comparison != 0 {
return comparison
}
}
}
switch {
case len(left) > len(right):
return 1
case len(left) < len(right):
return -1
default:
return 0
}
}
downloadAndReplace
Parameters
Returns
func downloadAndReplace(assetURL, digest string) (err error)
{
expectedDigest, err := parseSHA256Digest(digest)
if err != nil {
return err
}
exePath, err := os.Executable()
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(exePath), ".rfw-update-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() {
if tmp != nil {
if closeErr := tmp.Close(); err == nil {
err = closeErr
}
}
if removeErr := os.Remove(tmpName); err == nil && removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
err = removeErr
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
req, err := safehttp.NewRequest(ctx, http.MethodGet, assetURL)
if err != nil {
return err
}
resp, err := safehttp.NewClient().Do(req)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
if closeErr := resp.Body.Close(); closeErr != nil {
return closeErr
}
return fmt.Errorf("download failed: status %d", resp.StatusCode)
}
copyErr := copyWithSHA256(tmp, resp.Body, expectedDigest)
closeErr := resp.Body.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
if err := tmp.Chmod(0o700); err != nil {
return err
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
tmp = nil
if err := replaceExecutable(tmpName, exePath); err != nil {
return err
}
return nil
}
parseSHA256Digest
Parameters
Returns
func parseSHA256Digest(digest string) ([sha256.Size]byte, error)
{
var expected [sha256.Size]byte
encoded, found := strings.CutPrefix(digest, "sha256:")
if !found {
return expected, fmt.Errorf("release asset has no SHA-256 digest")
}
decoded, err := hex.DecodeString(encoded)
if err != nil || len(decoded) != sha256.Size {
return expected, fmt.Errorf("release asset has an invalid SHA-256 digest")
}
copy(expected[:], decoded)
return expected, nil
}
copyWithSHA256
func copyWithSHA256(destination io.Writer, source io.Reader, expected [sha256.Size]byte) error
{
hasher := sha256.New()
if _, err := io.Copy(io.MultiWriter(destination, hasher), source); err != nil {
return err
}
if subtle.ConstantTimeCompare(hasher.Sum(nil), expected[:]) != 1 {
return fmt.Errorf("downloaded asset checksum does not match the release")
}
return nil
}
replaceExecutable
Parameters
Returns
func replaceExecutable(source, target string) error
{
if runtime.GOOS != "windows" {
return os.Rename(source, target)
}
backup := target + ".old"
if err := os.Remove(backup); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(target, backup); err != nil {
return err
}
if err := os.Rename(source, target); err != nil {
if rollbackErr := os.Rename(backup, target); rollbackErr != nil {
return errors.Join(err, rollbackErr)
}
return err
}
return nil
}
getAssetName
Returns
func getAssetName() string
{
goos := runtime.GOOS
goarch := runtime.GOARCH
ext := ""
if goos == "windows" {
ext = ".exe"
}
return fmt.Sprintf("rfw-%s-%s%s", goos, goarch, ext)
}
shouldSkipUpdateCheck
shouldSkipUpdateCheck reports whether the update check must not run at all:
explicit opt-out via RFW_NO_UPDATE_CHECK, or a non-interactive session (CI,
scripts, pipes) where a network call and an update prompt would get in the
way.
Parameters
Returns
func shouldSkipUpdateCheck(noUpdateEnv string, stdinTTY, stdoutTTY bool) bool
{
if noUpdateEnv != "" {
return true
}
return !stdinTTY || !stdoutTTY
}
isTerminal
isTerminal reports whether f is attached to a character device.
Parameters
Returns
func isTerminal(f *os.File) bool
{
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
CheckForUpdate
CheckForUpdate checks GitHub for a newer CLI release in interactive sessions.
func CheckForUpdate()
{
if shouldSkipUpdateCheck(os.Getenv("RFW_NO_UPDATE_CHECK"), isTerminal(os.Stdin), isTerminal(os.Stdout)) {
return
}
if !shouldCheckUpdate() {
return
}
release, err := fetchLatestRelease()
if err != nil {
return
}
if err := markChecked(); err != nil {
Debug(fmt.Sprintf("failed to record update check: %v", err))
}
latest := release.TagName
if !isNewer(core.Version(), release.TagName) {
return
}
assetName := getAssetName()
fmt.Println()
Info(fmt.Sprintf("Update available: %s → %s", faint(core.Version()), boldCyan(latest)))
fmt.Print(indent, red("➜ "), bold("Update now? [y/N] "))
var answer string
if _, err := fmt.Scanln(&answer); err != nil {
return
}
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println(indent, faint("Skipped."))
return
}
assetURL := ""
assetDigest := ""
for _, a := range release.Assets {
if a.Name == assetName {
assetURL = a.BrowserDownloadURL
assetDigest = a.Digest
break
}
}
if assetURL == "" {
Info(fmt.Sprintf("No binary found for %s/%s", runtime.GOOS, runtime.GOARCH))
return
}
Info("Downloading...")
if err := downloadAndReplace(assetURL, assetDigest); err != nil {
Info(fmt.Sprintf("Update failed: %v", err))
return
}
Info(fmt.Sprintf("Updated to %s!", latest))
}
TestShouldSkipUpdateCheck
The update check must stay out of the way in CI, scripts and pipes, and
honour the explicit RFW_NO_UPDATE_CHECK opt-out.
Parameters
func TestShouldSkipUpdateCheck(t *testing.T)
{
cases := []struct {
name string
env string
stdinTTY bool
stdoutTTY bool
wantSkip bool
}{
{"interactive", "", true, true, false},
{"env opt-out", "1", true, true, true},
{"stdin piped", "", false, true, true},
{"stdout piped", "", true, false, true},
{"fully non-interactive", "", false, false, true},
}
for _, tc := range cases {
if got := shouldSkipUpdateCheck(tc.env, tc.stdinTTY, tc.stdoutTTY); got != tc.wantSkip {
t.Errorf("%s: got %v, want %v", tc.name, got, tc.wantSkip)
}
}
}
TestIsNewer
Parameters
func TestIsNewer(t *testing.T)
{
tests := []struct {
name string
current string
latest string
want bool
}{
{name: "minor with two digits", current: "v2.9.0", latest: "v2.10.0", want: true},
{name: "stable after prerelease", current: "v2.1.0-beta.19", latest: "v2.1.0", want: true},
{name: "prerelease before stable", current: "v2.1.0", latest: "v2.1.0-beta.19"},
{name: "numeric prerelease", current: "v2.1.0-beta.9", latest: "v2.1.0-beta.10", want: true},
{name: "build metadata ignored", current: "v2.1.0+one", latest: "v2.1.0+two"},
{name: "older release", current: "v3.0.0", latest: "v2.99.99"},
{name: "invalid current", current: "development", latest: "v2.1.0"},
{name: "invalid latest", current: "v2.1.0", latest: "v2.01.0"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := isNewer(test.current, test.latest); got != test.want {
t.Fatalf("isNewer(%q, %q) = %v, want %v", test.current, test.latest, got, test.want)
}
})
}
}
TestCopyWithSHA256
Parameters
func TestCopyWithSHA256(t *testing.T)
{
content := []byte("verified update")
expected := sha256.Sum256(content)
var destination bytes.Buffer
if err := copyWithSHA256(&destination, bytes.NewReader(content), expected); err != nil {
t.Fatalf("copy verified content: %v", err)
}
if !bytes.Equal(destination.Bytes(), content) {
t.Fatalf("copied %q, want %q", destination.Bytes(), content)
}
destination.Reset()
wrong := sha256.Sum256([]byte("different"))
if err := copyWithSHA256(&destination, bytes.NewReader(content), wrong); err == nil {
t.Fatal("expected checksum mismatch")
}
}
TestParseSHA256Digest
Parameters
func TestParseSHA256Digest(t *testing.T)
{
content := sha256.Sum256([]byte("release"))
digest := "sha256:" + fmt.Sprintf("%x", content)
got, err := parseSHA256Digest(digest)
if err != nil {
t.Fatalf("parse digest: %v", err)
}
if got != content {
t.Fatalf("parsed digest %x, want %x", got, content)
}
if _, err := parseSHA256Digest(""); err == nil {
t.Fatal("expected missing digest error")
}
}
captureOutput
captureOutput redirects stdout for the duration of f and returns what was
written to it.
Parameters
Returns
func captureOutput(t *testing.T, f func()) string
{
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdout = w
f()
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
os.Stdout = orig
defer func() {
if err := r.Close(); err != nil {
t.Errorf("close reader: %v", err)
}
}()
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
return buf.String()
}
TestDebug
Parameters
func TestDebug(t *testing.T)
{
EnableDebug(true)
out := captureOutput(t, func() { Debug("hello") })
if !strings.Contains(out, "[rfw][debug]") {
t.Fatalf("expected debug output, got %q", out)
}
EnableDebug(false)
out = captureOutput(t, func() { Debug("no output") })
if out != "" {
t.Fatalf("expected no output, got %q", out)
}
}
TestIsDebug
Parameters
func TestIsDebug(t *testing.T)
{
EnableDebug(true)
if !IsDebug() {
t.Fatalf("expected true in debug mode")
}
EnableDebug(false)
if IsDebug() {
t.Fatalf("expected false when debug disabled")
}
}
TestPrintStartupInfo
Parameters
func TestPrintStartupInfo(t *testing.T)
{
out := captureOutput(t, func() { PrintStartupInfo("8080", "8443", "192.168.0.1", true) })
if !strings.Contains(out, "http://localhost:8080/") {
t.Fatalf("expected local URL in output, got %q", out)
}
if !strings.Contains(out, "http://192.168.0.1:8080/") {
t.Fatalf("expected network URL, got %q", out)
}
out = captureOutput(t, func() { PrintStartupInfo("8080", "8443", "", false) })
if !strings.Contains(out, "--host") {
t.Fatalf("expected hint about --host, got %q", out)
}
}
TestPrintHelp
Parameters
func TestPrintHelp(t *testing.T)
{
out := captureOutput(t, PrintHelp)
if !strings.Contains(out, "Shortcuts") || !strings.Contains(out, "Flags") {
t.Fatalf("missing help sections, got %q", out)
}
}
TestLogServeRequest
Parameters
func TestLogServeRequest(t *testing.T)
{
req := httptest.NewRequest("GET", "/foo", nil)
out := captureOutput(t, func() { LogServeRequest(req) })
if !strings.Contains(out, "/foo") {
t.Fatalf("expected path in output, got %q", out)
}
}
TestReplaceExecutable
Parameters
func TestReplaceExecutable(t *testing.T)
{
dir := t.TempDir()
source := dir + "/source"
target := dir + "/target"
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := root.Close(); err != nil {
t.Errorf("close test root: %v", err)
}
})
if err := root.WriteFile("source", []byte("new"), 0o600); err != nil {
t.Fatal(err)
}
if err := root.WriteFile("target", []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := replaceExecutable(source, target); err != nil {
t.Fatalf("replace executable: %v", err)
}
content, err := root.ReadFile("target")
if err != nil {
t.Fatal(err)
}
if string(content) != "new" {
t.Fatalf("target contains %q, want %q", content, "new")
}
if _, err := os.Stat(source); !os.IsNotExist(err) {
t.Fatalf("source still exists: %v", err)
}
}