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
52 changes: 16 additions & 36 deletions internal/cli/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,24 +82,20 @@ func ackLine(w io.Writer, tty bool, th *theme.Theme, m components.AckMarker, tex
_, _ = fmt.Fprintln(w, components.AckLinePlain(m, text))
}

// runInitConfirmDecision asks the user whether to initialize the managed root
// when none is detected. Returns (true, nil) to proceed, (false, nil) to
// abort, or a non-nil error on D8 violation.
//
// D8 policy: non-TTY without --yes → error.
func runInitConfirmDecision(w io.Writer, th *theme.Theme, s updateSeams, path string, yes bool) (bool, error) {
if yes {
return true, nil
}
if !s.isInteractiveFn() {
return false, fmt.Errorf("interactive input requires a TTY — pass --yes to proceed non-interactively")
}
title := fmt.Sprintf("No managed installation root at %s. Initialize?", path)
ok, err := s.runConfirmFn(th, title, "")
if err != nil {
return false, err
}
return ok, nil
// errNoRoot is the message shown when the resolved tree path carries no
// orbital tree. `update` must never create one: the path is derived from the
// running binary's location, so a formae reached through a copy or symlink in
// a foreign prefix (a Homebrew bin, /usr/local/bin, a build tree) resolves to
// that prefix. Initializing there is destructive — orbital's force-init wipes
// the root — so the only safe answer is to refuse and let the installer own
// root creation.
func errNoRoot(path string) error {
return fmt.Errorf(
"no formae installation root at %s\n\n"+
"formae update installs into the tree next to the running binary and never creates one.\n"+
"Reinstall with the official installer, run the formae in your install root (e.g. /opt/pel/bin/formae),\n"+
"or set %s to an existing install root.",
path, opsmgr.FormaePelRootEnv)
}

// runUpdateFlow is the testable core of the interactive update flow.
Expand Down Expand Up @@ -187,25 +183,9 @@ func UpdateCmd() *cobra.Command {

th := themeFor(a)

// Init root if needed — D8 gated confirm.
// Never initialize a root here — see errNoRoot.
if !orb.Ready() {
seams := updateSeams{
isInteractiveFn: isInteractive,
runConfirmFn: runConfirm,
// stopAgentFn and installFn are not used in the init path.
}
proceed, err := runInitConfirmDecision(os.Stdout, th, seams, orb.Path(), yes)
if err != nil {
return err
}
if !proceed {
return nil
}

_, err = orb.Initialize()
if err != nil {
return err
}
return errNoRoot(orb.Path())
}

err = orb.Refresh()
Expand Down
44 changes: 11 additions & 33 deletions internal/cli/update/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/require"

"github.com/platform-engineering-labs/formae/internal/cli/tui/theme"
"github.com/platform-engineering-labs/formae/internal/opsmgr"
"github.com/platform-engineering-labs/orbital/opm/records"
"github.com/platform-engineering-labs/orbital/ops"
)
Expand Down Expand Up @@ -131,42 +132,19 @@ func TestUpdateFlow_ConsequenceBeforeConfirm(t *testing.T) {
}

// ----------------------------------------------------------------------------
// D8 gate tests — runInitConfirmDecision
// no-root policy: update must refuse, never initialize
// ----------------------------------------------------------------------------

// Non-TTY without --yes must error with "interactive input requires a TTY".
func TestInitConfirm_NonTTY_NoYes(t *testing.T) {
stub := &stubInstaller{}
var buf captureWriter
th := theme.New("formae")

_, err := runInitConfirmDecision(&buf, th, seamsFor(stub, false, false), "/some/path", false)
// The resolved tree path is derived from the running binary's location, so a
// formae reached through a foreign prefix resolves to that prefix. `update`
// must refuse with actionable guidance instead of initializing a tree there —
// orbital's force-init wipes the root it is handed.
func TestErrNoRoot_RefusesAndGuides(t *testing.T) {
err := errNoRoot("/opt/homebrew")
require.Error(t, err)
assert.Contains(t, err.Error(), "interactive input requires a TTY")
}

// Non-TTY with --yes must proceed (return true, nil) without calling confirm.
func TestInitConfirm_NonTTY_WithYes(t *testing.T) {
stub := &stubInstaller{}
var confirmCalled bool

seams := updateSeams{
isInteractiveFn: func() bool { return false },
runConfirmFn: func(_ *theme.Theme, _, _ string) (bool, error) {
confirmCalled = true
return true, nil
},
stopAgentFn: stub.stop,
installFn: stub.install,
}

var buf captureWriter
th := theme.New("formae")

result, err := runInitConfirmDecision(&buf, th, seams, "/some/path", true)
require.NoError(t, err)
assert.True(t, result, "should proceed when --yes on non-TTY")
assert.False(t, confirmCalled, "confirm must not be called with --yes")
assert.Contains(t, err.Error(), "/opt/homebrew")
assert.Contains(t, err.Error(), "never creates one")
assert.Contains(t, err.Error(), opsmgr.FormaePelRootEnv)
}

// ----------------------------------------------------------------------------
Expand Down
16 changes: 15 additions & 1 deletion internal/opsmgr/opsmgr.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ func newManager(logger *slog.Logger, repos []pkgmodel.Repository, channel string
// resolveTreePath returns FORMAE_PEL_ROOT when set, otherwise the
// directory two levels above the running binary (matches the
// /opt/pel/bin/formae → /opt/pel install layout).
//
// Symlinks are resolved first. os.Executable() reports the path the process
// was invoked with — on darwin symlinks are left intact — so a shim such as
// /opt/homebrew/bin/formae → /opt/pel/bin/formae would otherwise make the
// shim's own prefix the tree root, and every install and removal would target
// that foreign directory.
func resolveTreePath() (string, error) {
if root := os.Getenv(FormaePelRootEnv); root != "" {
return root, nil
Expand All @@ -117,5 +123,13 @@ func resolveTreePath() (string, error) {
if err != nil {
return "", fmt.Errorf("could not determine binary path: %w", err)
}
return filepath.Dir(filepath.Dir(binPath)), nil
return treePathFrom(binPath), nil
}

// treePathFrom derives the tree root from a binary path, resolving symlinks.
func treePathFrom(binPath string) string {
if resolved, err := filepath.EvalSymlinks(binPath); err == nil {
binPath = resolved
}
return filepath.Dir(filepath.Dir(binPath))
}
40 changes: 40 additions & 0 deletions internal/opsmgr/treepath_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// © 2026 Platform Engineering Labs Inc.
//
// SPDX-License-Identifier: FSL-1.1-ALv2

//go:build unit

package opsmgr

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

"github.com/stretchr/testify/require"
)

// A formae reached through a shim in a foreign prefix (a Homebrew bin, a
// /usr/local/bin copy) must resolve to the real install root, not to the
// shim's prefix: the derived root is what update installs into and what
// orbital manages, so getting it wrong points package operations at an
// unrelated directory.
func TestTreePathFrom_ResolvesShimToRealRoot(t *testing.T) {
tmp := t.TempDir()
realRoot := filepath.Join(tmp, "pel")
shimRoot := filepath.Join(tmp, "homebrew")
require.NoError(t, os.MkdirAll(filepath.Join(realRoot, "bin"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(shimRoot, "bin"), 0o755))

realBin := filepath.Join(realRoot, "bin", "formae")
require.NoError(t, os.WriteFile(realBin, []byte("#!/bin/sh\n"), 0o755))
shimBin := filepath.Join(shimRoot, "bin", "formae")
require.NoError(t, os.Symlink(realBin, shimBin))

// EvalSymlinks resolves /tmp on darwin; compare against the resolved form.
want, err := filepath.EvalSymlinks(realRoot)
require.NoError(t, err)

require.Equal(t, want, treePathFrom(shimBin), "shim must resolve to the real root")
require.Equal(t, want, treePathFrom(realBin), "direct invocation unchanged")
}
Loading