Unverified Commit e6789697 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(cli): show download progress and copy setup links

parent e4a2a633
Loading
Loading
Loading
Loading
+1 −1
Changes for bootstrap_asset.go: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -33,7 +33,7 @@ var bootstrapAssets embed.FS
//go:embed plugins/limbo/build.gradle plugins/limbo/settings.gradle plugins/limbo/src plugins/limbo/gradle/verification-metadata.xml
//go:embed plugins/paper/build.gradle plugins/paper/settings.gradle plugins/paper/src plugins/paper/gradle/verification-metadata.xml
//go:embed plugins/velocity/build.gradle plugins/velocity/settings.gradle plugins/velocity/src plugins/velocity/gradle/verification-metadata.xml
//go:embed plugins/shared/src
//go:embed plugins/shared/src plugins/shared/build-progress.gradle
var gameStackAssets embed.FS

// GameStackTar streams the embedded game-stack sources as a tar, rooted so that
+3 −0
Changes for bootstrap_asset_test.go: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -147,6 +147,9 @@ func TestBootstrapPinsViaBlockConnectionsOff(t *testing.T) {
// inputs from the script and from each Dockerfile's own COPY lines instead of restating
// them here; a fourth image inherits the check for free.
func TestGameStackTarCarriesEveryBuildInput(t *testing.T) {
	// The plugin builds invoke this shared init script after COPYing the directory;
	// a directory entry alone would pass the COPY check even if the script was omitted.
	requireEmbedded(t, "plugins/shared/build-progress.gradle")
	// Matches the path only when GAME_STACK_DIR is followed by one, which skips the
	// build-context arguments (`"$GAME_STACK_DIR"`, `"${GAME_STACK_DIR}:/src:z"`) and
	// the glob for gradle's output, none of which are inputs this tar has to carry.
+66 −1
Changes for cmd/felis/tui_summary.go: 66 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -2,10 +2,14 @@ package main

import (
	"context"
	"io"
	"os"
	"strings"
	"time"

	"github.com/atotto/clipboard"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/x/ansi"
)

// summaryModel is the terminal screen of the setup wizard. On a first run it
@@ -25,8 +29,13 @@ type summaryModel struct {
	localHint     bool // show the self-signed-cert note
	// alerts is where the watchdog's alerts go; nil leaves the rows out.
	alerts     *alertRoute
	copyText   func(string) error
	copyNotice string
	copyFailed bool
}

type summaryCopiedMsg struct{ err error }

func (m *summaryModel) Init() tea.Cmd { return nil }

// arrowNavOK lets the root repurpose ←/→ to walk back through completed steps;
@@ -34,9 +43,35 @@ func (m *summaryModel) Init() tea.Cmd { return nil }
func (m *summaryModel) arrowNavOK() bool { return true }

func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	if copied, ok := msg.(summaryCopiedMsg); ok {
		m.copyFailed = copied.err != nil
		switch {
		case copied.err != nil:
			m.copyNotice = "Could not copy: " + copied.err.Error()
		case os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "":
			m.copyNotice = "Copy sent to terminal. If it does not paste, your terminal needs OSC 52 support."
		default:
			m.copyNotice = "Link copied to clipboard."
		}
		return m, nil
	}
	if key, ok := msg.(tea.KeyMsg); ok {
		switch key.String() {
		case "c", "C":
			link := m.panelURL
			if m.setupTokenURL != "" {
				link = m.setupTokenURL
			}
			if link == "" {
				m.copyNotice, m.copyFailed = "No link to copy.", true
				return m, nil
			}
			copyText := m.copyText
			if copyText == nil {
				copyText = copyTerminalText
			}
			return m, func() tea.Msg { return summaryCopiedMsg{err: copyText(link)} }
		case "n", "N":
			return m, func() tea.Msg { return reconfigureConnectMsg{} }
		case "s", "S":
			return m, func() tea.Msg { return reconfigureStorageMsg{} }
@@ -101,10 +136,40 @@ func (m *summaryModel) View() string {
		b.WriteString(tuiHint.Render("  The local certificate is self-signed; your browser may warn on first visit.") + "\n")
	}

	b.WriteString("\n" + tuiAction("c", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit"))
	if m.copyNotice != "" {
		style := tuiOK
		if m.copyFailed {
			style = tuiWarn
		}
		b.WriteString("\n" + style.Render(m.copyNotice) + "\n")
	}
	copyLabel := "copy panel link"
	if m.setupTokenURL != "" {
		copyLabel = "copy setup link"
	}
	b.WriteString("\n" + tuiAction("c", copyLabel, "n", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit"))
	return b.String()
}

// SSH copies through the terminal; the remote host's desktop clipboard is unrelated.
// /dev/tty keeps the one-time link out of redirected stdout and install logs.
func copyTerminalText(text string) error {
	if os.Getenv("SSH_TTY") == "" && os.Getenv("SSH_CONNECTION") == "" {
		return clipboard.WriteAll(text)
	}
	tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
	if err != nil {
		return err
	}
	defer tty.Close()
	sequence := ansi.SetSystemClipboard(text)
	if os.Getenv("TMUX") != "" {
		sequence = ansi.TmuxPassthrough(sequence)
	}
	_, err = io.WriteString(tty, sequence)
	return err
}

// alertRoute is where this host's watchdog alerts go, as the summary shows it:
// by mail through the [smtp] relay to the Owners' verified addresses, and the
// heartbeat that notices the host itself going down (docs/troubleshooting.md
+64 −0
Changes for cmd/felis/tui_summary_test.go: 64 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"errors"
	"strings"
	"testing"

	tea "github.com/charmbracelet/bubbletea"
)

func TestSummaryCopyLink(t *testing.T) {
	t.Setenv("SSH_TTY", "")
	t.Setenv("SSH_CONNECTION", "")
	for _, tc := range []struct {
		name, setup, want string
	}{
		{"first login", "https://panel.example/setup?token=test-token", "https://panel.example/setup?token=test-token"},
		{"already set up", "", "https://panel.example"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			var copied string
			m := &summaryModel{
				panelURL: "https://panel.example", setupTokenURL: tc.setup,
				copyText: func(text string) error { copied = text; return nil },
			}
			_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")})
			if cmd == nil {
				t.Fatal("copy did not return a command")
			}
			msg := cmd()
			if _, ok := msg.(summaryCopiedMsg); !ok {
				t.Fatalf("c returned %T; must copy, not reconfigure", msg)
			}
			m.Update(msg)
			if copied != tc.want || m.copyFailed || !strings.Contains(m.View(), "Link copied to clipboard.") {
				t.Fatalf("copied %q, notice %q", copied, m.copyNotice)
			}
			_, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
			if _, ok := cmd().(reconfigureConnectMsg); !ok {
				t.Fatal("n must still allow changing the connection")
			}
		})
	}
}

func TestSummaryCopyFailureAndSSHFeedback(t *testing.T) {
	t.Setenv("SSH_TTY", "/dev/pts/1")
	m := &summaryModel{
		panelURL: "https://panel.example",
		copyText: func(string) error { return errors.New("clipboard unavailable") },
	}
	_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("C")})
	m.Update(cmd())
	if !m.copyFailed || !strings.Contains(m.View(), "clipboard unavailable") {
		t.Fatal("copy failure must be visible")
	}
	m.Update(summaryCopiedMsg{})
	if m.copyFailed || !strings.Contains(m.View(), "Copy sent to terminal") || strings.Contains(m.View(), "Link copied to clipboard") {
		t.Fatal("OSC 52 has no confirmation; do not claim the local clipboard was updated")
	}
	if _, cmd := (&summaryModel{}).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}); cmd != nil {
		t.Fatal("a missing link must not trigger a clipboard write")
	}
}
+6 −6
Changes for deploy/bootstrap.sh: 6 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -3017,7 +3017,7 @@ EOF
  chmod 0755 "${tmp}/felis"

  log "building ${FELIS_IMAGE} from the current felis binary"
  docker build -t "$FELIS_IMAGE" "$tmp"
  docker build --progress=plain -t "$FELIS_IMAGE" "$tmp"
  rm -rf "$tmp"
}

@@ -3031,7 +3031,7 @@ build_image_from_source() {
  # Without the stamp main.version stays "dev", and `felis update` refuses to compare a
  # "dev" build against upstream rather than treating it as 0.0.0. So an unstamped image
  # is not a cosmetic problem: it silently disables update reporting for the install.
  docker build -t "$FELIS_IMAGE" \
  docker build --progress=plain -t "$FELIS_IMAGE" \
    --build-arg FELIS_VERSION="${FELIS_VERSION:-dev}" "$SRC_DIR"

  log "extracting the felis binary onto the host (${HOST_BIN})"
@@ -3504,7 +3504,7 @@ build_game_image() {
    limbo)
      img="$FELIS_LIMBO_IMAGE"
      log "building ${img} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})"
      docker build -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \
      docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/limbo/Dockerfile" \
        --build-arg LIMBO_JAR_URL="$LIMBO_JAR_URL" \
        --build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \
        --build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \
@@ -3515,7 +3515,7 @@ build_game_image() {
    lobby)
      img="$FELIS_LOBBY_IMAGE"
      log "building ${img} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)"
      docker build -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \
      docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/lobby/Dockerfile" \
        --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \
        --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
        --build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \
@@ -3527,7 +3527,7 @@ build_game_image() {
      # operator initContainer's job, so this image carries no /menu plugin and no secret gate.
      img="$FELIS_PAPER_IMAGE"
      log "building ${img} (plain Paper ${MC_VERSION}, forwarding via the operator initContainer)"
      docker build -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \
      docker build --progress=plain -f "${GAME_STACK_DIR}/deploy/paper/Dockerfile" \
        --build-arg PAPER_JAR_URL="$PAPER_JAR_URL" \
        --build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
        -t "$img" "$GAME_STACK_DIR"
@@ -3633,7 +3633,7 @@ build_velocity_plugin() {
  docker run --rm \
    -v "${GAME_STACK_DIR}:/src:z" \
    -w /src/plugins/velocity \
    "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build \
    "$PLUGIN_BUILD_IMAGE" gradle --no-daemon clean build --console=plain --init-script ../shared/build-progress.gradle \
    || die "felis-velocity plugin build failed"
  local -a jars=( "${GAME_STACK_DIR}"/plugins/velocity/build/libs/felis-velocity-*.jar )
  [ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] \
Loading