Loading bootstrap_asset.go +1 −1 Changes for bootstrap_asset.go: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -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 Loading bootstrap_asset_test.go +3 −0 Changes for bootstrap_asset_test.go: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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. Loading cmd/felis/tui_summary.go +66 −1 Changes for cmd/felis/tui_summary.go: 66 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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; Loading @@ -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{} } Loading Loading @@ -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 Loading cmd/felis/tui_summary_test.go 0 → 100644 +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") } } deploy/bootstrap.sh +6 −6 Changes for deploy/bootstrap.sh: 6 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -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" } Loading @@ -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})" Loading Loading @@ -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" \ Loading @@ -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" \ Loading @@ -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" Loading Loading @@ -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 Loading
bootstrap_asset.go +1 −1 Changes for bootstrap_asset.go: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -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 Loading
bootstrap_asset_test.go +3 −0 Changes for bootstrap_asset_test.go: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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. Loading
cmd/felis/tui_summary.go +66 −1 Changes for cmd/felis/tui_summary.go: 66 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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; Loading @@ -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{} } Loading Loading @@ -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 Loading
cmd/felis/tui_summary_test.go 0 → 100644 +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") } }
deploy/bootstrap.sh +6 −6 Changes for deploy/bootstrap.sh: 6 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -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" } Loading @@ -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})" Loading Loading @@ -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" \ Loading @@ -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" \ Loading @@ -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" Loading Loading @@ -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