feat(cli): show download progress and copy setup links
This commit is contained in:
12 files changed
+229
-16
No files matched your search
+1
-1
@@ -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/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/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/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
|
var gameStackAssets embed.FS
|
||||||
|
|
||||||
// GameStackTar streams the embedded game-stack sources as a tar, rooted so that
|
// GameStackTar streams the embedded game-stack sources as a tar, rooted so that
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ func TestBootstrapPinsViaBlockConnectionsOff(t *testing.T) {
|
|||||||
// inputs from the script and from each Dockerfile's own COPY lines instead of restating
|
// 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.
|
// them here; a fourth image inherits the check for free.
|
||||||
func TestGameStackTarCarriesEveryBuildInput(t *testing.T) {
|
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
|
// 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
|
// 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.
|
// the glob for gradle's output, none of which are inputs this tar has to carry.
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/atotto/clipboard"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// summaryModel is the terminal screen of the setup wizard. On a first run it
|
// 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
|
localHint bool // show the self-signed-cert note
|
||||||
// alerts is where the watchdog's alerts go; nil leaves the rows out.
|
// alerts is where the watchdog's alerts go; nil leaves the rows out.
|
||||||
alerts *alertRoute
|
alerts *alertRoute
|
||||||
|
copyText func(string) error
|
||||||
|
copyNotice string
|
||||||
|
copyFailed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type summaryCopiedMsg struct{ err error }
|
||||||
|
|
||||||
func (m *summaryModel) Init() tea.Cmd { return nil }
|
func (m *summaryModel) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
// arrowNavOK lets the root repurpose ←/→ to walk back through completed steps;
|
// 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) arrowNavOK() bool { return true }
|
||||||
|
|
||||||
func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
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 {
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
switch key.String() {
|
switch key.String() {
|
||||||
case "c", "C":
|
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{} }
|
return m, func() tea.Msg { return reconfigureConnectMsg{} }
|
||||||
case "s", "S":
|
case "s", "S":
|
||||||
return m, func() tea.Msg { return reconfigureStorageMsg{} }
|
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(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()
|
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:
|
// 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
|
// by mail through the [smtp] relay to the Owners' verified addresses, and the
|
||||||
// heartbeat that notices the host itself going down (docs/troubleshooting.md
|
// heartbeat that notices the host itself going down (docs/troubleshooting.md
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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
@@ -3017,7 +3017,7 @@ EOF
|
|||||||
chmod 0755 "${tmp}/felis"
|
chmod 0755 "${tmp}/felis"
|
||||||
|
|
||||||
log "building ${FELIS_IMAGE} from the current felis binary"
|
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"
|
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
|
# 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
|
# "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.
|
# 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"
|
--build-arg FELIS_VERSION="${FELIS_VERSION:-dev}" "$SRC_DIR"
|
||||||
|
|
||||||
log "extracting the felis binary onto the host (${HOST_BIN})"
|
log "extracting the felis binary onto the host (${HOST_BIN})"
|
||||||
@@ -3504,7 +3504,7 @@ build_game_image() {
|
|||||||
limbo)
|
limbo)
|
||||||
img="$FELIS_LIMBO_IMAGE"
|
img="$FELIS_LIMBO_IMAGE"
|
||||||
log "building ${img} (LOOHP/Limbo ${LIMBO_VERSION}, Minecraft ${MC_VERSION})"
|
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_URL="$LIMBO_JAR_URL" \
|
||||||
--build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \
|
--build-arg LIMBO_JAR_SHA256="$LIMBO_JAR_SHA256" \
|
||||||
--build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \
|
--build-arg LIMBO_SCHEM_URL="$LIMBO_SCHEM_URL" \
|
||||||
@@ -3515,7 +3515,7 @@ build_game_image() {
|
|||||||
lobby)
|
lobby)
|
||||||
img="$FELIS_LOBBY_IMAGE"
|
img="$FELIS_LOBBY_IMAGE"
|
||||||
log "building ${img} (Paper ${MC_VERSION} + felis-paper /menu + LuckPerms)"
|
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_URL="$PAPER_JAR_URL" \
|
||||||
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
|
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
|
||||||
--build-arg LUCKPERMS_JAR_URL="$LUCKPERMS_JAR_URL" \
|
--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.
|
# operator initContainer's job, so this image carries no /menu plugin and no secret gate.
|
||||||
img="$FELIS_PAPER_IMAGE"
|
img="$FELIS_PAPER_IMAGE"
|
||||||
log "building ${img} (plain Paper ${MC_VERSION}, forwarding via the operator initContainer)"
|
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_URL="$PAPER_JAR_URL" \
|
||||||
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
|
--build-arg PAPER_JAR_SHA256="$PAPER_JAR_SHA256" \
|
||||||
-t "$img" "$GAME_STACK_DIR"
|
-t "$img" "$GAME_STACK_DIR"
|
||||||
@@ -3633,7 +3633,7 @@ build_velocity_plugin() {
|
|||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "${GAME_STACK_DIR}:/src:z" \
|
-v "${GAME_STACK_DIR}:/src:z" \
|
||||||
-w /src/plugins/velocity \
|
-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"
|
|| die "felis-velocity plugin build failed"
|
||||||
local -a jars=( "${GAME_STACK_DIR}"/plugins/velocity/build/libs/felis-velocity-*.jar )
|
local -a jars=( "${GAME_STACK_DIR}"/plugins/velocity/build/libs/felis-velocity-*.jar )
|
||||||
[ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] \
|
[ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] \
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ mkdir -p "${WORK}/velocity"
|
|||||||
tar -C . --exclude=build --exclude=.gradle -cf - plugins/velocity plugins/shared | tar -C "${WORK}/velocity" -xf -
|
tar -C . --exclude=build --exclude=.gradle -cf - plugins/velocity plugins/shared | tar -C "${WORK}/velocity" -xf -
|
||||||
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e GRADLE_USER_HOME=/tmp/gradle \
|
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e GRADLE_USER_HOME=/tmp/gradle \
|
||||||
-v "${WORK}/velocity:/src" -w /src/plugins/velocity \
|
-v "${WORK}/velocity:/src" -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
|
||||||
jars=( "${WORK}"/velocity/plugins/velocity/build/libs/felis-velocity-*.jar )
|
jars=( "${WORK}"/velocity/plugins/velocity/build/libs/felis-velocity-*.jar )
|
||||||
[ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] || die "the felis-velocity build must produce exactly one plugin jar"
|
[ "${#jars[@]}" -eq 1 ] && [ -f "${jars[0]}" ] || die "the felis-velocity build must produce exactly one plugin jar"
|
||||||
install -m 0644 "${jars[0]}" "${OUT}/felis-velocity.jar"
|
install -m 0644 "${jars[0]}" "${OUT}/felis-velocity.jar"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ RUN if [ -z "${LIMBO_VERSION:-}" ]; then \
|
|||||||
echo "LIMBO_VERSION is required (deploy/game-stack.lock)" >&2; exit 1; \
|
echo "LIMBO_VERSION is required (deploy/game-stack.lock)" >&2; exit 1; \
|
||||||
fi \
|
fi \
|
||||||
&& cd plugins/limbo \
|
&& cd plugins/limbo \
|
||||||
&& gradle --no-daemon -PlimboVersion="$LIMBO_VERSION" build \
|
&& gradle --no-daemon --console=plain --init-script ../shared/build-progress.gradle -PlimboVersion="$LIMBO_VERSION" build \
|
||||||
&& cp build/libs/*.jar /felis-limbo.jar
|
&& cp build/libs/*.jar /felis-limbo.jar
|
||||||
|
|
||||||
# ---- assemble the runtime ----
|
# ---- assemble the runtime ----
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ WORKDIR /src
|
|||||||
COPY plugins/paper/ ./plugins/paper/
|
COPY plugins/paper/ ./plugins/paper/
|
||||||
COPY plugins/shared/ ./plugins/shared/
|
COPY plugins/shared/ ./plugins/shared/
|
||||||
RUN cd plugins/paper \
|
RUN cd plugins/paper \
|
||||||
&& gradle --no-daemon build \
|
&& gradle --no-daemon --console=plain --init-script ../shared/build-progress.gradle build \
|
||||||
&& cp build/libs/*.jar /felis-paper.jar
|
&& cp build/libs/*.jar /felis-paper.jar
|
||||||
|
|
||||||
# ---- assemble the runtime ----
|
# ---- assemble the runtime ----
|
||||||
|
|||||||
@@ -2320,6 +2320,13 @@ about … MiB under /var/lib/containerd, and … has … MiB free; nothing has b
|
|||||||
once the asset downloads (the messages above it say which one failed), free that space, or
|
once the asset downloads (the messages above it say which one failed), free that space, or
|
||||||
set `FELIS_PREFLIGHT=warn` to build anyway **[SH-TESTED]**.
|
set `FELIS_PREFLIGHT=warn` to build anyway **[SH-TESTED]**.
|
||||||
|
|
||||||
|
Host builds keep Docker's output in the terminal scrollback. During plugin builds,
|
||||||
|
`[felis] Downloading <file> (<host>)` identifies each Gradle dependency download;
|
||||||
|
every ten seconds an active download reports bytes, speed and elapsed time. A
|
||||||
|
percentage appears only when the server supplies a total size. After thirty seconds
|
||||||
|
without new bytes the line says `no new data for …; waiting for the download`;
|
||||||
|
this reports a wait, without changing Gradle's timeout or retry behavior.
|
||||||
|
|
||||||
| Message | Meaning | What to do |
|
| Message | Meaning | What to do |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `release vX publishes no SHA256SUMS … building them on this host instead` | the release predates release assets, or release.yml is still uploading them | nothing for an old release; for a new one, rerun once the release page lists `SHA256SUMS` |
|
| `release vX publishes no SHA256SUMS … building them on this host instead` | the release predates release assets, or release.yml is still uploading them | nothing for an old release; for a new one, rerun once the release page lists `SHA256SUMS` |
|
||||||
@@ -2747,7 +2754,7 @@ host yourself, plus the off-site encryption key if the copy is in the bucket.
|
|||||||
`cloudflared-felis` unit stay behind with the old disk, so the panel
|
`cloudflared-felis` unit stay behind with the old disk, so the panel
|
||||||
hostnames answer Cloudflare error 1033 until a connector runs here.
|
hostnames answer Cloudflare error 1033 until a connector runs here.
|
||||||
|
|
||||||
Run `sudo felis setup`, press `c` (change connection) on the status
|
Run `sudo felis setup`, press `n` (change connection) on the status
|
||||||
screen and choose Cloudflare Tunnel + Access. On the step's first screen
|
screen and choose Cloudflare Tunnel + Access. On the step's first screen
|
||||||
press `i` to install cloudflared, then `l` for `cloudflared tunnel login`
|
press `i` to install cloudflared, then `l` for `cloudflared tunnel login`
|
||||||
(browser consent on your account, which writes `cert.pem`); `enter` opens
|
(browser consent on your account, which writes `cert.pem`); `enter` opens
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ go 1.26
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.7
|
||||||
github.com/descope/virtualwebauthn v1.0.5
|
github.com/descope/virtualwebauthn v1.0.5
|
||||||
github.com/go-logr/logr v1.4.2
|
github.com/go-logr/logr v1.4.2
|
||||||
github.com/go-webauthn/webauthn v0.17.4
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
@@ -27,13 +29,11 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect
|
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/catppuccin/go v0.3.0 // indirect
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import org.gradle.api.logging.Logging
|
||||||
|
import org.gradle.internal.operations.BuildOperationListener
|
||||||
|
import org.gradle.internal.operations.BuildOperationListenerManager
|
||||||
|
import org.gradle.internal.operations.OperationProgressDetails
|
||||||
|
import org.gradle.internal.resource.ExternalResourceReadBuildOperationType
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
// The Docker images pin Gradle. Observe its download events without changing requests,
|
||||||
|
// dependency verification or caching; plain console output otherwise hides these events.
|
||||||
|
def logger = Logging.getLogger('felis.download')
|
||||||
|
def downloads = new ConcurrentHashMap()
|
||||||
|
def bytes = { long value ->
|
||||||
|
value >= 1024 * 1024 ? String.format(Locale.ROOT, '%.1f MiB', value / (1024.0 * 1024)) :
|
||||||
|
String.format(Locale.ROOT, '%.1f KiB', value / 1024.0)
|
||||||
|
}
|
||||||
|
def seconds = { long since -> TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - since) }
|
||||||
|
def listener = [
|
||||||
|
started: { operation, event ->
|
||||||
|
if (!(operation.details instanceof ExternalResourceReadBuildOperationType.Details)) return
|
||||||
|
def uri = URI.create(operation.details.location)
|
||||||
|
if (!(uri.scheme in ['http', 'https'])) return
|
||||||
|
def now = System.nanoTime()
|
||||||
|
def name = "${uri.path.tokenize('/').last()} (${uri.host})"
|
||||||
|
downloads[operation.id] = [name: name, bytes: 0L, total: 0L, started: now,
|
||||||
|
changed: now, sampled: now, sampledBytes: 0L]
|
||||||
|
logger.lifecycle("[felis] Downloading ${name}")
|
||||||
|
},
|
||||||
|
progress: { id, event ->
|
||||||
|
def download = downloads[id]
|
||||||
|
if (download == null || !(event.details instanceof OperationProgressDetails)) return
|
||||||
|
if (event.details.units != 'bytes') return
|
||||||
|
synchronized (download) {
|
||||||
|
if (event.details.progress > download.bytes) download.changed = System.nanoTime()
|
||||||
|
download.bytes = event.details.progress
|
||||||
|
download.total = event.details.total
|
||||||
|
}
|
||||||
|
},
|
||||||
|
finished: { operation, event ->
|
||||||
|
def download = downloads.remove(operation.id)
|
||||||
|
if (download == null) return
|
||||||
|
def outcome = event.failure != null ? 'Download failed' :
|
||||||
|
event.result?.missing ? 'Not found' : 'Downloaded'
|
||||||
|
logger.lifecycle("[felis] ${outcome} ${download.name} (${seconds(download.started)}s)")
|
||||||
|
}
|
||||||
|
] as BuildOperationListener
|
||||||
|
|
||||||
|
def manager = gradle.services.get(BuildOperationListenerManager)
|
||||||
|
manager.addListener(listener)
|
||||||
|
def timer = Executors.newSingleThreadScheduledExecutor({ task ->
|
||||||
|
def thread = new Thread(task, 'felis-download-progress')
|
||||||
|
thread.daemon = true
|
||||||
|
thread
|
||||||
|
} as java.util.concurrent.ThreadFactory)
|
||||||
|
timer.scheduleAtFixedRate({
|
||||||
|
downloads.values().each { download ->
|
||||||
|
synchronized (download) {
|
||||||
|
def now = System.nanoTime()
|
||||||
|
def rate = (long) ((download.bytes - download.sampledBytes) * 1e9 / (now - download.sampled))
|
||||||
|
def total = download.total > 0 ? bytes(download.total) : 'unknown size'
|
||||||
|
def percent = download.total > 0 ? " (${Math.min(100, (100L * download.bytes).intdiv(download.total))}%)" : ''
|
||||||
|
def idle = seconds(download.changed)
|
||||||
|
def waiting = idle >= 30 ? " | no new data for ${idle}s; waiting for the download" : ''
|
||||||
|
logger.lifecycle("[felis] ${download.name}: ${bytes(download.bytes)} / ${total}${percent} | ${bytes(rate)}/s | ${seconds(download.started)}s elapsed${waiting}")
|
||||||
|
download.sampled = now
|
||||||
|
download.sampledBytes = download.bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as Runnable, 10, 10, TimeUnit.SECONDS)
|
||||||
|
gradle.buildFinished {
|
||||||
|
timer.shutdownNow()
|
||||||
|
manager.removeListener(listener)
|
||||||
|
}
|
||||||
+2
-2
@@ -226,7 +226,7 @@ java -cp "$work/linkcard-classes:$adventure_api:$adventure_key:$examination_api"
|
|||||||
|
|
||||||
for module in velocity paper; do
|
for module in velocity paper; do
|
||||||
echo "==> plugins/$module: ./gradlew --no-daemon build"
|
echo "==> plugins/$module: ./gradlew --no-daemon build"
|
||||||
( cd "plugins/$module" && ./gradlew --no-daemon build )
|
( cd "plugins/$module" && ./gradlew --no-daemon --console=plain --init-script ../shared/build-progress.gradle build )
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "==> plugins/velocity: ./gradlew --no-daemon routingTest"
|
echo "==> plugins/velocity: ./gradlew --no-daemon routingTest"
|
||||||
@@ -238,4 +238,4 @@ echo "==> plugins/paper: ./gradlew --no-daemon lobbyTest"
|
|||||||
limbo_version="$(sed -n 's/^LIMBO_VERSION=//p' deploy/game-stack.lock)"
|
limbo_version="$(sed -n 's/^LIMBO_VERSION=//p' deploy/game-stack.lock)"
|
||||||
[ -n "$limbo_version" ] || { echo "deploy/game-stack.lock sets no LIMBO_VERSION" >&2; exit 1; }
|
[ -n "$limbo_version" ] || { echo "deploy/game-stack.lock sets no LIMBO_VERSION" >&2; exit 1; }
|
||||||
echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build loginTest"
|
echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build loginTest"
|
||||||
( cd plugins/limbo && ./gradlew --no-daemon -PlimboVersion="$limbo_version" build loginTest )
|
( cd plugins/limbo && ./gradlew --no-daemon --console=plain --init-script ../shared/build-progress.gradle -PlimboVersion="$limbo_version" build loginTest )
|
||||||
Reference in new issue
Block a user