Skip to content
Closed
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
4 changes: 3 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ jobs:
- name: Check
run: go run ./tools/docslint

# The Go tests on both platforms Flock supports (the race detector needs cgo, so macOS only).
# The Go tests on the platforms Flock supports (the race detector needs cgo, so not on Windows).
# Without code changes the steps are skipped, not the job: a skipped matrix job reports under its
# unexpanded name, so the required "Go tests (...)" checks would never pass.
go-test:
Expand All @@ -133,6 +133,8 @@ jobs:
include:
- { name: macOS, os: macos-26, flags: -race }
- { name: Windows, os: windows-2025, flags: "" }
- { name: Linux, os: ubuntu-24.04, flags: -race }
- { name: Linux arm64, os: ubuntu-24.04-arm, flags: -race }
runs-on: ${{ matrix.os }}
env:
CODE: ${{ needs.changes.outputs.code }}
Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ build:
build-all:
GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/flock-darwin-arm64 ./cmd/flock
GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/flock-windows-amd64.exe ./cmd/flock
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/flock-linux-amd64 ./cmd/flock
GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/flock-linux-arm64 ./cmd/flock

test:
go test -race ./...
Expand Down
3 changes: 2 additions & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ type DNSStatus struct {
TLD string `json:"tld"`
Addr string `json:"addr"` // the embedded DNS server, when it runs
// Wildcard reports whether every name under the TLD resolves (any subdomain, at any depth).
// When it doesn't, WildcardProblem says why. On Windows (the DNS rule is missing or overridden
// When it doesn't, WildcardProblem says why. On Linux it can also be set while names resolve,
// for what will stop them (Flock's link lost its address, or NetworkManager manages it). On Windows (the DNS rule is missing or overridden
// by a policy) the sites' domains and extra domains still resolve, through the hosts file; on
// macOS (/etc/resolver/<tld> missing or overridden) nothing under the TLD does.
Wildcard bool `json:"wildcard"`
Expand Down
8 changes: 6 additions & 2 deletions internal/binaries/binaries.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ func (m *Manifest) Versions(pkg, platform string) []Version {
return out
}

// Platform names this machine in the manifest ("macos-arm64", "windows-x64"); "" where Flock
// publishes no packages (Flock supports Apple Silicon Macs and Windows x64).
// Platform names this machine in the manifest ("macos-arm64", "windows-x64", "linux-x64",
// "linux-arm64"); "" where Flock publishes no packages.
func Platform() string {
return platform(runtime.GOOS, runtime.GOARCH)
}
Expand All @@ -142,6 +142,10 @@ func platform(goos, goarch string) string {
return "macos-arm64"
case goos == "windows" && goarch == "amd64":
return "windows-x64"
case goos == "linux" && goarch == "amd64":
return "linux-x64"
case goos == "linux" && goarch == "arm64":
return "linux-arm64"
}
return ""
}
Expand Down
2 changes: 1 addition & 1 deletion internal/binaries/binaries_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ func TestEmbeddedKeyAndPlatforms(t *testing.T) {
if len(PublicKey()) != ed25519.PublicKeySize {
t.Fatal("embedded key")
}
for _, c := range [][3]string{{"darwin", "arm64", "macos-arm64"}, {"darwin", "amd64", ""}, {"windows", "amd64", "windows-x64"}, {"linux", "amd64", ""}} {
for _, c := range [][3]string{{"darwin", "arm64", "macos-arm64"}, {"darwin", "amd64", ""}, {"windows", "amd64", "windows-x64"}, {"linux", "amd64", "linux-x64"}, {"linux", "arm64", "linux-arm64"}, {"linux", "386", ""}} {
if got := platform(c[0], c[1]); got != c[2] {
t.Errorf("%s/%s: %q, want %q", c[0], c[1], got, c[2])
}
Expand Down
23 changes: 21 additions & 2 deletions internal/certs/system_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,27 @@ package certs

import "os"

// bundleFiles are where distributions keep the trust store as one PEM file (the list Go's
// crypto/x509 reads), tried in order. SSL_CERT_FILE, when set, comes first.
var bundleFiles = []string{
"/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Arch, Gentoo
"/etc/pki/tls/certs/ca-bundle.crt", // Fedora, RHEL
"/etc/ssl/ca-bundle.pem", // openSUSE
"/etc/pki/tls/cacert.pem", // OpenELEC
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS, RHEL 7
"/etc/ssl/cert.pem", // Alpine, the BSDs
}

// SystemRoots returns the OS trust store as PEM, for tools (PHP) that cannot read it themselves.
func SystemRoots() []byte {
data, _ := os.ReadFile("/etc/ssl/cert.pem")
return data
files := bundleFiles
if env := os.Getenv("SSL_CERT_FILE"); env != "" {
files = append([]string{env}, files...)
}
for _, f := range files {
if data, err := os.ReadFile(f); err == nil && len(data) > 0 {
return data
}
}
return nil
}
38 changes: 38 additions & 0 deletions internal/certs/system_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//go:build !windows && !darwin

package certs

import (
"os"
"path/filepath"
"testing"
)

func TestSystemRootsReadsTheFirstBundleThatExists(t *testing.T) {
dir := t.TempDir()
missing, empty, debian, alpine := filepath.Join(dir, "missing"), filepath.Join(dir, "empty"), filepath.Join(dir, "debian"), filepath.Join(dir, "alpine")
for path, data := range map[string]string{empty: "", debian: "debian roots", alpine: "alpine roots"} {
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
old := bundleFiles
bundleFiles = []string{missing, empty, debian, alpine}
t.Cleanup(func() { bundleFiles = old })
t.Setenv("SSL_CERT_FILE", "")

if got := string(SystemRoots()); got != "debian roots" {
t.Errorf("SystemRoots() = %q, want the Debian bundle", got)
}

t.Setenv("SSL_CERT_FILE", alpine)
if got := string(SystemRoots()); got != "alpine roots" {
t.Errorf("with SSL_CERT_FILE: %q, want its bundle", got)
}

bundleFiles = []string{missing}
t.Setenv("SSL_CERT_FILE", "")
if got := SystemRoots(); got != nil {
t.Errorf("without a bundle: %q, want nil", got)
}
}
8 changes: 6 additions & 2 deletions internal/cli/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ import (

"flock/internal/certs"
"flock/internal/node"
"flock/internal/paths"
"flock/internal/php"
)

const composerURL = "https://getcomposer.org/download/latest-stable/composer.phar"

// serviceNote is what `flock install` reports once installService is done.
var serviceNote = "daemon runs in the background and starts at login"

func newInstallCommand() *cobra.Command {
var skipPrivileged, privilegedOnly, ignoreConflicts bool

Expand Down Expand Up @@ -97,7 +101,7 @@ func newInstallCommand() *cobra.Command {
if err := installService(s.root, bin); err != nil {
return fmt.Errorf("install background service: %w", err)
}
cmd.Println("✓ daemon runs in the background and starts at login")
cmd.Println("✓ " + serviceNote)

if skipPrivileged || privilegedFirst {
return nil
Expand Down Expand Up @@ -146,7 +150,7 @@ func installBinary(root string) (string, error) {
return "", err
}

dst := filepath.Join(root, "bin", exeName("flock"))
dst := filepath.Join(root, "bin", paths.CLI())
if self == dst {
return dst, nil
}
Expand Down
105 changes: 2 additions & 103 deletions internal/cli/install_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
Expand All @@ -20,115 +19,15 @@ import (
)

const (
launchLabel = "dev.flock.daemon"
profileMarker = "# Flock (added by `flock install`)"
launchLabel = "dev.flock.daemon"
// appBundleID and appExecutable identify the menu bar app (macos/build-app.sh).
appBundleID = "dev.flock.bar"
appExecutable = "FlockBar"
)

// shellProfiles are the profiles addToShellPath writes to (~/.zshrc always, ~/.bash_profile if present).
// shellProfiles are the profiles addToShellPath writes to: ~/.zshrc always, ~/.bash_profile if present.
var shellProfiles = []string{".zshrc", ".bash_profile"}

func exeName(name string) string { return name }

func writeShims(root, bin string) error {
dir := filepath.Join(root, "bin")
for _, tool := range tools {
script := fmt.Sprintf("#!/bin/sh\nexec %q exec %s \"$@\"\n", bin, tool)
if err := os.WriteFile(filepath.Join(dir, tool), []byte(script), 0o755); err != nil {
return err
}
}
return nil
}

// addToShellPath prepends dirs, in this order, in ~/.zshrc (and ~/.bash_profile if present): a
// marked export line, appended once and rewritten when the directories change.
func addToShellPath(dirs ...string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}

quoted := make([]string, len(dirs))
for i, dir := range dirs {
quoted[i] = strconv.Quote(dir)
}
export := fmt.Sprintf("export PATH=%s:\"$PATH\"", strings.Join(quoted, ":"))
for _, name := range shellProfiles {
path := filepath.Join(home, name)
data, err := os.ReadFile(path)
if err != nil && name != ".zshrc" {
continue
}
lines := strings.Split(string(data), "\n")
if i := slices.Index(lines, profileMarker); i >= 0 {
if i+1 < len(lines) && lines[i+1] == export {
continue
}
if i+1 < len(lines) && strings.HasPrefix(lines[i+1], "export PATH=") {
lines[i+1] = export
} else {
lines = slices.Insert(lines, i+1, export)
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
return err
}
continue
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
_, err = f.WriteString("\n" + profileMarker + "\n" + export + "\n")
f.Close()
if err != nil {
return err
}
}
return nil
}

// removeFromShellProfiles takes out what addToShellPath added: the marker, its export line and
// the blank line before them. It reports whether a profile had them.
func removeFromShellProfiles() (bool, error) {
home, err := os.UserHomeDir()
if err != nil {
return false, err
}
removed := false
for _, name := range shellProfiles {
path := filepath.Join(home, name)
data, err := os.ReadFile(path)
if err != nil {
continue
}
lines := strings.Split(string(data), "\n")
i := slices.Index(lines, profileMarker)
if i < 0 {
continue
}
end := i + 1
if end < len(lines) && strings.HasPrefix(lines[end], "export PATH=") {
end++
}
if i > 0 && lines[i-1] == "" {
i--
}
lines = slices.Delete(lines, i, end)
info, err := os.Stat(path)
if err != nil {
return removed, err
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm()); err != nil {
return removed, err
}
removed = true
}
return removed, nil
}

func plistPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
Expand Down
44 changes: 0 additions & 44 deletions internal/cli/install_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,50 +9,6 @@ import (
"testing"
)

// Uninstalling leaves the shell profiles as they were before `flock install`.
func TestShellProfileRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

zshrc := filepath.Join(home, ".zshrc")
before := "export EDITOR=vim\nalias ll='ls -l'\n"
if err := os.WriteFile(zshrc, []byte(before), 0o600); err != nil {
t.Fatal(err)
}

if err := addToShellPath("/f/bin", "/f/node-global/current/bin"); err != nil {
t.Fatal(err)
}
// A second install with other directories rewrites the line instead of adding another.
if err := addToShellPath("/f/bin"); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(zshrc)
if strings.Count(string(data), profileMarker) != 1 || !strings.Contains(string(data), `export PATH="/f/bin":"$PATH"`) {
t.Fatalf("profile after install:\n%s", data)
}

removed, err := removeFromShellProfiles()
if err != nil || !removed {
t.Fatalf("removed %v, err %v", removed, err)
}
data, _ = os.ReadFile(zshrc)
if string(data) != before {
t.Fatalf("profile after uninstall:\n%q\nwant\n%q", data, before)
}
if info, _ := os.Stat(zshrc); info.Mode().Perm() != 0o600 {
t.Fatalf("permissions changed to %v", info.Mode().Perm())
}
// No ~/.bash_profile existed, and none is created.
if _, err := os.Stat(filepath.Join(home, ".bash_profile")); !os.IsNotExist(err) {
t.Fatalf(".bash_profile: %v", err)
}

if removed, err := removeFromShellProfiles(); err != nil || removed {
t.Fatalf("second removal: removed %v, err %v", removed, err)
}
}

// The command line reaches the shell of `do shell script` intact, whatever the paths contain.
func TestAdministratorScriptQuoting(t *testing.T) {
exe := "/Users/o'brien/Library/Application Support/Flock/bin/flock"
Expand Down
Loading
Loading