diff --git a/.github/workflows/bump-gitlink.yml b/.github/workflows/bump-gitlink.yml index eef9ba3..e3dda3a 100644 --- a/.github/workflows/bump-gitlink.yml +++ b/.github/workflows/bump-gitlink.yml @@ -1,11 +1,7 @@ name: Bump superproject gitlink -# Tells Gryt-chat/gryt that main moved, so it can fast-forward this -# repository's gitlink instead of waiting for its hourly sweep. Implementation -# lives in Gryt-chat/.github so all repos share one copy. -# -# Needs the org secret GITLINK_DISPATCH_TOKEN. Without it the dispatch is -# skipped with a warning and the sweep picks the change up instead, later. +# Tells Gryt-chat/gryt that main moved so it can fast-forward the gitlink. Shared +# implementation in Gryt-chat/.github; needs the org secret GITLINK_DISPATCH_TOKEN. on: push: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3793c66..1b625f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,10 @@ jobs: - run: go test ./... - run: go vet ./... - run: go build ./cmd/gryt + + # No comment runs past two lines. The script carries the list of paths still + # to be swept, and that list only ever shrinks. + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: node scripts/check-comment-length.mjs diff --git a/.github/workflows/discord-ci-notify.yml b/.github/workflows/discord-ci-notify.yml index 2843d52..4fc3aef 100644 --- a/.github/workflows/discord-ci-notify.yml +++ b/.github/workflows/discord-ci-notify.yml @@ -1,12 +1,7 @@ name: Discord CI notification -# Reports failed workflow runs to #ci in Discord, and the recovery when one -# goes green again. Implementation lives in Gryt-chat/.github so all repos -# share one copy. Needs the org secret DISCORD_CI_WEBHOOK_URL. -# -# Workflows are listed by name rather than left to a wildcard: GitHub's docs -# don't say whether omitting `workflows` means all of them, and an unlisted -# workflow notifies nobody. Add new workflows here as you add them. +# Reports failed workflow runs to #ci in Discord, and the recovery. Shared implementation +# in Gryt-chat/.github. List new workflows here — an unlisted one notifies nobody. on: workflow_run: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b761a6e..e55822b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,7 @@ name: Release CLI -# Dispatched by hand, same shape as the SFU release. Version comes from the -# tags rather than a committed file, so nothing is pushed to main and this -# works against a protected branch. -# -# goreleaser does the building and creates the GitHub release itself, so there -# is no separate action-gh-release step here: the archives, the checksums and -# the release notes all come out of `goreleaser release`. install.sh in the -# superproject reads those assets, so the archive naming in .goreleaser.yml is -# load-bearing — changing it breaks the one-line install. +# Dispatched by hand, same shape as the SFU release: the version comes from the tags, so +# nothing is pushed to main. install.sh reads the archive names in .goreleaser.yml. on: workflow_dispatch: @@ -148,12 +141,8 @@ jobs: esac fi - # Until now the channel input was validated, written to an output and - # read by nothing. goreleaser decides prerelease from the tag alone, - # so dispatching with beta still shipped a stable release that - # /releases/latest returned and the installer picked up. Both - # install.sh and `gryt update` resolve /releases/latest, which - # excludes prereleases, so the distinction has to come from here. + # goreleaser decides prerelease from the tag alone, and both install.sh and + # `gryt update` resolve /releases/latest, which excludes prereleases. if [[ "$CHANNEL" == "beta" ]]; then BASE="${NEW_VERSION%%-*}" NEXT=1 diff --git a/.github/workflows/vikunja-task-done.yml b/.github/workflows/vikunja-task-done.yml index eeaab72..52f4698 100644 --- a/.github/workflows/vikunja-task-done.yml +++ b/.github/workflows/vikunja-task-done.yml @@ -1,9 +1,7 @@ name: Sync Vikunja task -# Keeps the Vikunja task named in the branch (e.g. claude/GRYT-1-slug) in step -# with the PR: labelled "in review" and linked when it opens, marked done when -# it merges. Implementation lives in Gryt-chat/.github so all repos share one -# copy. Needs the org secret VIKUNJA_API_TOKEN. +# Keeps the Vikunja task named in the branch in step with the PR: in review when it opens, +# done when it merges. Shared in Gryt-chat/.github; needs VIKUNJA_API_TOKEN. on: pull_request: diff --git a/cmd/gryt/main.go b/cmd/gryt/main.go index f9d00a9..bf816f5 100644 --- a/cmd/gryt/main.go +++ b/cmd/gryt/main.go @@ -58,10 +58,10 @@ func main() { } } -// runDoctor prints every check and returns the exit code, so that a script can -// gate on it. -// runUpdate replaces this binary with the newest release, or with --check only -// reports whether there is one. +// runDoctor prints every check and returns the exit code, so a script can gate on it. + +// runUpdate replaces this binary with the newest release, or with --check only reports +// whether there is one. func runUpdate(args []string) int { checkOnly := len(args) > 0 && (args[0] == "--check" || args[0] == "check") diff --git a/internal/app/entries.go b/internal/app/entries.go index e4d3a0d..2701c1f 100644 --- a/internal/app/entries.go +++ b/internal/app/entries.go @@ -2,13 +2,8 @@ package app import "github.com/Gryt-chat/cli/internal/config" -// An entry is a row in the table. Most are servers; the last two are the -// pieces the machine runs one of and every server shares. -// -// Before this the model assumed every row was a config.Profile, which is why -// the SFU and the object store appeared nowhere: the dashboard could say -// "voice server is not running" and then offer nothing to do about it, and -// there was no way at all to read the SFU's log. +// An entry is a row in the table. Most are servers; the last two are the pieces the machine +// runs one of and every server shares, which used to appear nowhere. type entryKind int const ( @@ -28,12 +23,8 @@ type entry struct { role string } -// entries lists the servers, then the shared pieces. -// -// Image workers are deliberately absent. One runs beside each server inside -// that server's own compose project, so `docker compose logs` for the server -// already carries its output; giving it a row would add a line to look at and -// no information that is not already one keypress away. +// entries lists the servers, then the shared pieces. Image workers are absent: one runs +// inside each server's own compose project, so its output is already in that server's logs. func (m Model) entries() []entry { list := make([]entry, 0, len(m.profiles)+2) for _, profile := range m.profiles { @@ -57,10 +48,8 @@ func (e entry) key() string { return e.container } -// actions are the things worth offering for a row in its current state. -// -// Start on a running server used to be accepted, run `compose up` again, and -// report "Start X" as though something had happened. The state decides now. +// actions are the things worth offering for a row in its current state. Start on a running +// server used to be accepted and report success without anything happening. type actions struct{ start, stop, restart bool } func availableActions(running, unknown bool) actions { diff --git a/internal/app/model.go b/internal/app/model.go index 2208019..6d7cf59 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -39,9 +39,8 @@ type statusesLoaded struct { containers map[string]bool // Each running server's version, read off its container. versions map[string]string - // Whether the SFU every server here shares is answering. A server can be - // running perfectly while voice is dead because the shared project is not - // up, and nothing on the dashboard used to say so. + // Whether the SFU every server here shares is answering. A server can be running + // perfectly while voice is dead because the shared project is not up. sharedUp bool } type operationDone struct { @@ -61,9 +60,8 @@ type logsLoaded struct { err error } -// tick drives the dashboard's own refresh. Without it the status was whatever -// it had been when you last pressed g, so a server that fell over looked fine -// until you thought to ask. +// tick drives the dashboard's own refresh. Without it the status was whatever it had been +// when you last pressed g, so a server that fell over looked fine. type tick time.Time const ( @@ -109,9 +107,8 @@ type Model struct { updateTag string updating bool sharedUp bool - // The address this machine answers on from outside its own network. - // Looked up once, when a detail view is first opened, because it leaves - // the machine and the table does not need it. + // The address this machine answers on from outside its own network. Looked up once, + // when a detail view is first opened, because it leaves the machine. publicIP string publicChecked bool versions map[string]string @@ -119,9 +116,8 @@ type Model struct { // here is behind. Fetched once, with the CLI's own update check. serverLatest string containers map[string]bool - // Which rows have an operation in flight, keyed by entry. Replaces a - // single busy flag that froze the whole dashboard for the length of a - // docker command. + // Which rows have an operation in flight, keyed by entry. Replaces a single busy flag + // that froze the whole dashboard for the length of a docker command. working map[string]bool // The settings screen's state. Held on the model rather than fetched per // draw, because reading them is a request to the server. @@ -143,16 +139,11 @@ func (m Model) Init() tea.Cmd { return tea.Batch(m.loadProfiles(), m.checkForUpdate(), m.checkServerRelease(), tickAfter(statusInterval)) } -// checkForUpdate asks GitHub which release is newest, once, at startup. -// -// It fails silently. Somebody managing a server on a machine with no route to -// the internet should see their servers, not an error about a version check -// they did not ask for. -// lookUpPublicAddress asks what this machine looks like from outside. -// -// Fails silently. Somebody on a network with no route out, or who has turned -// the lookup off, should see their server rather than an error about a question -// they did not ask. +// checkForUpdate asks GitHub which release is newest, once, at startup. It fails silently: +// somebody with no route out should see their servers, not a version-check error. + +// lookUpPublicAddress asks what this machine looks like from outside. Fails silently, for +// the same reason: a question the operator did not ask should not become an error. func (m Model) lookUpPublicAddress() tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) @@ -269,12 +260,8 @@ func (m Model) startWork(key string) Model { return m } -// saveProfile writes the server's files and, when that changed something a -// running container is already using, recreates it so the change takes effect. -// -// Saving used to write .env and compose.yaml and stop. Editing a running server -// rewrote both, reported "Saved", and left the container running the old -// values, with nothing to say so. The settings appeared to change and did not. +// saveProfile writes the server's files and recreates the container when that changed +// something it is already using. Saving used to report "Saved" and leave the old values. func (m Model) saveProfile(profile config.Profile) tea.Cmd { dir := m.store.ServerDir(profile.ID) running := m.states[profile.ID] == gruntime.StateRunning @@ -298,9 +285,8 @@ func (m Model) saveProfile(profile config.Profile) tea.Cmd { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - // up, not restart. A port or a volume change lands in compose.yaml, and - // `docker compose restart` restarts the container it already built - // rather than building the one the file now describes. + // up, not restart. A port or a volume change lands in compose.yaml, and `docker + // compose restart` restarts the container it already built. if err := m.runtime.Start(ctx, profile, dir); err != nil { return operationDone{err: fmt.Errorf("saved, but applying it failed: %w", err)} } @@ -322,18 +308,16 @@ func (m Model) runOperation(action string, profile config.Profile) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - // Ask before doing rather than after failing. Available was declared on - // the Manager interface from the start and never called, so the - // operator met a raw compose error instead of "Docker is not running". + // Ask before doing rather than after failing. Available was on the Manager interface + // from the start and never called, so the operator met a raw compose error. if err := m.runtime.Available(ctx); err != nil { return operationDone{err: err, key: key} } var err error switch action { case "start": - // The SFU lives in its own project shared by every server here, so - // it has to exist and be running before a server that expects to - // reach it comes up. + // The SFU lives in its own project shared by every server here, so it has to + // be running before a server that expects to reach it comes up. if _, err := m.store.WriteSharedCompose(); err != nil { return operationDone{err: err, key: key} } @@ -353,9 +337,8 @@ func (m Model) runOperation(action string, profile config.Profile) tea.Cmd { } } -// followLogs is loadLogs without the side effects of opening the view: it -// refreshes what is already on screen and stays quiet when it cannot, so a -// container that goes away mid-follow does not replace the logs with an error. +// followLogs is loadLogs without the side effects of opening the view: it refreshes what is +// on screen and stays quiet when it cannot, so a container going away is not an error. func (m Model) followLogs(profile config.Profile) tea.Cmd { dir := m.store.ServerDir(profile.ID) return func() tea.Msg { @@ -556,10 +539,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard.err = err.Error() return m, nil } - // Stays in the wizard while it writes. Leaving immediately - // meant the saving state had nowhere to appear, and a - // failed save dropped you on the dashboard with an error - // about a form you could no longer see. + // Stays in the wizard while it writes: leaving immediately + // left a failed save on the dashboard with no form to see. m.busy, m.wizard.err = true, "" return m, m.saveProfile(profile) } @@ -589,16 +570,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = modeDashboard return m, nil } - // Anything else falls through to the same key handling the table uses, so - // the detail view can act on the server it is showing. It used to swallow - // every key but esc, while its own footer listed s, x, r and l — naming - // keys that did nothing on the one screen dedicated to that server. + // Anything else falls through to the table's key handling, so the detail view can act on + // the server it shows. It used to swallow every key but esc while listing s, x, r and l. if !isKey { return m, nil } - // No global lock. The work already runs in goroutines, so navigation, - // enter, logs and quit stay live while a server starts; only a second - // action on a row already working is refused, below. + // No global lock. The work runs in goroutines, so navigation, logs and quit stay live + // while a server starts; only a second action on a working row is refused. profile, hasProfile := m.selectedProfile() selected, hasSelected := m.selectedEntry() can := actions{} @@ -642,9 +620,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if hasSelected && selected.kind == entryShared { - // Consequential in a way a per-server stop is not: one of these - // serves every server on the machine, so say what it costs rather - // than reporting it like any other stop. + // Consequential in a way a per-server stop is not: one of these serves every + // server on the machine, so say what it costs. return m.startWork(selected.key()), m.stopShared(selected.key(), selected.label) } if hasProfile { @@ -721,10 +698,8 @@ func (m Model) joinAddresses(profile config.Profile) []string { if profile.Host != "0.0.0.0" { return []string{profile.Host + ":" + port} } - // Bound to everything, so every address of this machine reaches it. - // Reachable addresses first and loopback last: the panel above this says - // "give people this address", and leading with 127.0.0.1 answers that with - // the one address nobody else can use. + // Bound to everything, so every address of this machine reaches it. Reachable addresses + // first and loopback last: leading with 127.0.0.1 answers with the useless one. var lines []string for _, address := range config.LocalAddresses() { lines = append(lines, address.IP+":"+port+m.styles.muted.Render(" ("+address.Label+")")) @@ -769,13 +744,8 @@ func (m Model) header(section string) string { return m.styles.header.Width(m.width).Render(left + strings.Repeat(" ", gap) + right) } -// viewDashboard is a console table: every server and its live state on one -// screen, the way k9s or docker ps present a fleet. -// -// It replaces a two-pane rail-and-panel where twelve identically-styled labels -// sat inside borders running at 1.45:1 against their own background. Nothing -// was primary, so nothing could be found. Here the row is the unit, the -// columns are the facts, and weight separates the selected row from the rest. +// viewDashboard is a console table: every server and its live state on one screen. The row +// is the unit, the columns are the facts, and weight separates the selected row. func (m Model) viewDashboard() string { noun := "servers" if len(m.profiles) == 1 { @@ -822,9 +792,8 @@ func (m Model) viewDashboard() string { voiceW = 8 uploadsW = 8 ) - // Capped, not greedy. Giving the name every spare column pushed the facts - // to the far right of a wide terminal, so the eye had to travel the whole - // width to pair a server with its state. + // Capped, not greedy. Giving the name every spare column pushed the facts to the far + // right of a wide terminal, so the eye had to travel to pair a server with its state. nameW := max(12, min(28, m.width-(statusW+addressW+voiceW+uploadsW)-8)) lines := []string{m.styles.column.Render(" " + @@ -878,12 +847,8 @@ func (m Model) viewDashboard() string { return head + "\n" + body + "\n" + footer } -// viewDetail is one server, with the facts ranked. -// -// The panel it replaces rendered twelve fields as identical muted labels, so -// the address you hand somebody had exactly the weight of the join policy and -// the eye had nowhere to land. Here one fact is the page and the rest is a -// single line under it. +// viewDetail is one server, with the facts ranked: one fact is the page and the rest is a +// single line under it. The panel it replaced gave twelve fields identical weight. func (m Model) viewDetail() string { profile, ok := m.selectedProfile() if !ok { @@ -897,9 +862,8 @@ func (m Model) viewDetail() string { } footer := m.styles.footer.Width(m.width).Render(keys) - // Grouped by who each address is for. A flat list headed "give people this - // address" cannot say that one of them only works from this machine and - // another needs a port forwarded first. + // Grouped by who each address is for. A flat list cannot say that one of them only works + // from this machine and another needs a port forwarded first. port := strconv.Itoa(profile.Port) lines := []string{ "", @@ -986,11 +950,8 @@ func (m Model) entryState(item entry) (glyph, word string, tone lipgloss.Style) return "○", "stopped", m.styles.muted } -// dashboardKeys lists what the selected row can actually do. -// -// It used to list every key regardless, so start was offered on a running -// server and stop on a stopped one — and pressing either reported success -// without anything having happened. +// dashboardKeys lists what the selected row can actually do. It used to list every key, so +// start was offered on a running server and pressing it reported success. func (m Model) dashboardKeys() string { parts := []string{"↑/↓ select"} @@ -1026,11 +987,8 @@ func (m Model) dashboardKeys() string { return strings.Join(parts, " ") } -// versionLine reports what this server runs, and what it could run. -// -// Read off the container rather than asked of the server: the image bakes -// SERVER_VERSION in at build time, so this works on images built long before -// the server had any way to report it. +// versionLine reports what this server runs, and what it could run. Read off the container +// rather than asked of the server, so it works on images built before that was possible. func (m Model) versionLine(profile config.Profile) string { current := m.versions[profile.ID] if current == "" { @@ -1097,9 +1055,8 @@ func uploadsCell(backend string) string { } } -// pad fills a cell to width with spaces. The cells are built plain and styled -// afterwards, because padding a string that already carries escape codes -// measures the codes and the columns drift. +// pad fills a cell to width with spaces. The cells are built plain and styled afterwards: +// padding a string that already carries escape codes measures the codes. func pad(text string, width int) string { for lipgloss.Width(text) < width { text += " " diff --git a/internal/app/model_test.go b/internal/app/model_test.go index 1d2788d..3e5b66c 100644 --- a/internal/app/model_test.go +++ b/internal/app/model_test.go @@ -11,9 +11,8 @@ import ( func TestEmptyDashboardExplainsNextAction(t *testing.T) { model := New(config.NewStore(t.TempDir()), &gruntime.Fake{}, "v0.1.0") view := model.viewDashboard() - // Asserts the intent rather than the wording: an empty dashboard has to - // name the state and the next keypress. Pinning the exact sentence made - // this fail on a copy change that improved it. + // Asserts the intent rather than the wording: an empty dashboard has to name the state + // and the next keypress. Pinning the sentence failed on a copy change that improved it. if !containsAll(view, "No servers", "Press n") { t.Fatalf("empty dashboard lacks next action:\n%s", view) } diff --git a/internal/app/settings.go b/internal/app/settings.go index 516f81d..f538710 100644 --- a/internal/app/settings.go +++ b/internal/app/settings.go @@ -13,14 +13,8 @@ import ( "github.com/Gryt-chat/cli/internal/management" ) -// The settings a server keeps in its own database, rather than in the -// environment its container was started with. -// -// These could not be changed from here at all: they are authorised by -// ownership, so managing a server meant being its owner in a client, even on a -// machine you administer. They are reached through the server's management API -// now, which is also what makes a change take effect — turning discovery off -// has to withdraw the mDNS advertisement, and only the server can do that. +// The settings a server keeps in its own database rather than in its container's +// environment. Reached through the management API, which is what makes a change take effect. type settingsLoaded struct { settings *management.Settings err error @@ -84,9 +78,8 @@ func (m Model) loadSettings(profile config.Profile) tea.Cmd { } } -// applySetting sends one key. Only that key: a patch carrying everything this -// side is holding would push a stale value back over anything changed -// elsewhere since it was read. +// applySetting sends one key, and only that key: a patch carrying everything this side holds +// would push a stale value back over anything changed elsewhere since it was read. func (m Model) applySetting(profile config.Profile, key string, value any) tea.Cmd { client := management.Client{Port: profile.AdminPort, Token: profile.AdminToken} return func() tea.Msg { diff --git a/internal/app/theme.go b/internal/app/theme.go index 0372668..8d741e9 100644 --- a/internal/app/theme.go +++ b/internal/app/theme.go @@ -1,10 +1,5 @@ -// Hallmark · pre-emit critique: P4 H5 E4 S5 R5 V4 -// Hallmark · macrostructure: Console Table · tone: technical/utilitarian -// Hallmark · chrome: inherit-terminal · contrast: foreground-only, see note -// -// The previous stamp claimed P5 H5 E4 S5 R5 V5 and shipped a dashboard its own -// author called hard to understand. These scores are deliberately lower and -// were assigned after the redesign rather than before it. +// Hallmark · pre-emit critique: P4 H5 E4 S5 R5 V4 · macrostructure: Console Table +// tone: technical/utilitarian · chrome: inherit-terminal · contrast: foreground-only package app import ( @@ -13,17 +8,8 @@ import ( "charm.land/lipgloss/v2" ) -// The palette is foreground-only on purpose. -// -// The old theme painted #0B1018 across the whole viewport, which imposes a -// dark slab on somebody running a light terminal and gets approximated into a -// different palette entirely on a 256-colour one. A terminal program does not -// own the background: the person running it does. Nothing here sets one, so -// the tool sits in whatever theme is already there. -// -// Contrast is therefore not fixed at build time. These hues are chosen to -// clear 4.5:1 against both a near-black and a near-white terminal, which is -// what makes them safe to ship without knowing the background. +// The palette is foreground-only on purpose: a terminal program does not own the background, +// and these hues clear 4.5:1 against both a near-black and a near-white terminal. type theme struct { text colorToken muted colorToken diff --git a/internal/app/view.go b/internal/app/view.go index 84002fb..2a36ab7 100644 --- a/internal/app/view.go +++ b/internal/app/view.go @@ -7,14 +7,8 @@ import ( "charm.land/lipgloss/v2" ) -// viewWizard is one question at a time, in the same language as the console -// table: left-aligned, hierarchy carried by weight, nothing drawn. -// -// It used to render inside panelActive, which the dashboard redesign turned -// into a no-op style. That left every step as unframed text floating after -// eight blank lines, with no visual relationship between the counter, the -// question, the help and the field. This does not restore the box; it gives -// the step a structure that does not depend on one. +// viewWizard is one question at a time, in the same language as the console table: +// left-aligned, hierarchy carried by weight, nothing drawn. No box, and none needed. func (m Model) viewWizard() string { field := m.wizard.fields[m.wizard.step] step, total := m.wizard.progress() @@ -41,9 +35,8 @@ func (m Model) viewWizard() string { } lines = append(lines, "", " "+m.control(field)) - // The four states a full-screen keyboard-driven step actually has. - // Saving is shown here, on the step that triggered it, rather than only as - // a notice on the dashboard after the wizard has already closed. + // The four states a full-screen keyboard-driven step actually has. Saving is shown here, + // on the step that triggered it, rather than as a notice after the wizard has closed. switch { case m.busy: lines = append(lines, "", " "+m.styles.accent.Render("Saving…")) @@ -55,9 +48,8 @@ func (m Model) viewWizard() string { return head + "\n" + body + "\n" + footer } -// progressDots shows where you are without counting. Filled for done, hollow -// for remaining; the total moves when an answer adds or removes steps, which -// is honest about a wizard whose length depends on what you pick. +// progressDots shows where you are without counting: filled for done, hollow for remaining. +// The total moves when an answer adds or removes steps, which is honest. func (m Model) progressDots(step, total int) string { if total > 20 { return "" @@ -143,10 +135,8 @@ func securityDescription(value string) string { } } -// Named for what each answer does rather than for how it is built. "Shared" -// and "s3" and "filesystem" are the words the code uses; somebody standing up -// a server for their friends is choosing between "it just works", "keep it -// simple" and "I already pay for storage somewhere". +// Named for what each answer does rather than how it is built. Somebody standing up a server +// for their friends is choosing between "it just works" and "I already pay for storage". func storageDescription(value string) string { switch value { case "filesystem": diff --git a/internal/app/wizard.go b/internal/app/wizard.go index 82c1d99..5a31d68 100644 --- a/internal/app/wizard.go +++ b/internal/app/wizard.go @@ -29,13 +29,11 @@ type wizardField struct { // What an empty field means. Shown as the placeholder and used when the // field is left alone, so a default is never text you have to delete. fallback string - // Which of choices is the one to pick when you have no reason to prefer - // another. Empty when the answer genuinely depends on the situation, so - // that the badge means something wherever it appears. + // Which of choices is the one to pick with no reason to prefer another. Empty when the + // answer depends on the situation, so the badge means something wherever it appears. recommended string - // Set for a tick-list. Some questions have more than one right answer at - // the same time: a server reachable over a LAN and over the internet is - // reachable over both, and the client picks whichever is faster. + // Set for a tick-list. A server reachable over a LAN and over the internet is reachable + // over both, and the client picks whichever is faster. options []multiOption cursor int } @@ -50,15 +48,8 @@ type wizard struct { original *config.Profile } -// inputField starts empty with the default shown as the placeholder, rather -// than pre-filled with it. -// -// A pre-filled field puts the cursor at the end, so typing appends: changing -// the port from 5000 to 5001 meant deleting four characters first, and typing -// "uploads" into a bucket field holding "gryt" produced "grytuploads". bubbles -// exposes no selection, so there is no select-on-focus to reach for. Leaving -// the field empty and treating empty as the default gets the same result and -// is less to explain. +// inputField starts empty with the default as placeholder, rather than pre-filled: a +// pre-filled field appends, so 5000 to 5001 meant deleting four characters first. func inputField(key, label, helper, fallback, placeholder string) wizardField { input := textinput.New() inputStyles := textinput.DefaultDarkStyles() @@ -77,15 +68,8 @@ func inputField(key, label, helper, fallback, placeholder string) wizardField { return wizardField{key: key, label: label, helper: helper, input: input, fallback: fallback} } -// reachField asks where people will connect from, offering this machine's own -// addresses rather than an empty box. -// -// It replaces a free-text "SFU WebSocket URL" with a `wss://…` placeholder, -// which nobody who did not already know the answer could fill in, and getting -// it wrong is how voice silently fails. The answers become SFU_PUBLIC_HOST, -// which takes a comma-separated list: the client pings each and uses whichever -// answers fastest, so ticking both a LAN address and a public one is a -// sensible thing to do rather than a contradiction. +// reachField asks where people will connect from, offering this machine's addresses. The +// answers become SFU_PUBLIC_HOST, a list the client pings, so ticking both is sensible. func reachField() wizardField { options := []multiOption{{ label: "This machine only (localhost)", @@ -115,10 +99,8 @@ func selectField(key, label, helper string, choices []string, current int) wizar return wizardField{key: key, label: label, helper: helper, choices: choices, choice: current} } -// recommend marks the choice to take when you have no reason to prefer -// another. Deliberately not on every question: path-style addressing is right -// for MinIO and wrong for AWS, so a badge there would be wrong half the time -// and would teach people to ignore it on the questions where it is right. +// recommend marks the choice to take with no reason to prefer another. Not on every +// question: path-style addressing is right for MinIO and wrong for AWS. func recommend(field wizardField, choice string) wizardField { field.recommended = choice return field @@ -130,9 +112,8 @@ func onlyWhen(field wizardField, key, value string) wizardField { return field } -// masked hides what is typed. Used for the one field here that is a secret -// rather than merely sensitive: an access key ID identifies an account, but a -// secret access key is the account. +// masked hides what is typed, for the one field that is a secret rather than merely +// sensitive: an access key ID identifies an account, a secret access key is the account. func masked(field wizardField) wizardField { field.input.EchoMode = textinput.EchoPassword return field @@ -180,10 +161,8 @@ func newWizard(taken []int) wizard { onlyWhen(inputField("domain", "Its address", "Include the scheme. Behind a reverse proxy with TLS this is wss://, otherwise ws:// and the port.", "", "wss://voice.example.com"), "reach", domainChoice), recommend(selectField("storage", "Where do uploads go?", "Images, files and avatars people send to this server.", []string{"shared", "filesystem", "s3"}, 0), "shared"), - // Only reachable when the backend is s3. Asking six questions about - // object storage to somebody who picked the filesystem would be six - // steps of nothing, and leaving them out entirely is what shipped a - // backend that could be selected but never configured. + // Only reachable when the backend is s3. Six questions about object storage for + // somebody on the filesystem would be six steps of nothing. onlyWhen(inputField("s3endpoint", "S3 endpoint", "Full URL of the S3 API. MinIO on the same host looks like http://minio:9000.", "", "https://s3.eu-central-1.amazonaws.com"), "storage", "s3"), onlyWhen(inputField("s3bucket", "Bucket", "Must already exist. Gryt does not create it.", "gryt", "gryt"), "storage", "s3"), onlyWhen(inputField("s3region", "Region", "Leave as auto for MinIO and most S3-compatible services.", "auto", "auto"), "storage", "s3"), @@ -205,9 +184,8 @@ func wizardFromProfile(profile config.Profile) wizard { "voice": strconv.Itoa(profile.VoiceMaxUsers), "proxy": strconv.Itoa(profile.TrustedProxyHops), "sfu": profile.SFUWebSocketURL, } - // Only override a default when the profile actually carries a value, or - // editing a filesystem server would blank the region and bucket defaults - // on the way past. + // Only override a default when the profile carries a value, or editing a filesystem + // server would blank the region and bucket defaults on the way past. for key, env := range map[string]string{ "s3endpoint": "S3_ENDPOINT", "s3bucket": "S3_BUCKET", @@ -232,9 +210,8 @@ func wizardFromProfile(profile config.Profile) wizard { field.input.SetValue(value) } if field.key == "reach" && profile.SFUWebSocketURL != "" { - // An address this server uses that is not one of this machine's - // current ones came from the typed field, so tick that and put it - // back where it was entered. + // An address this server uses that is not one of this machine's current ones + // came from the typed field, so tick that and put it back. var extra []string for j := range field.options { known := chosen[field.options[j].value] @@ -266,9 +243,8 @@ func (w *wizard) focus() tea.Cmd { for i := range w.fields { w.fields[i].input.Blur() } - // Only text fields have an input to focus. A tick-list and a one-of-many - // choice are built as bare structs, so their textinput is the zero value - // and focusing it panics. + // Only text fields have an input to focus. A tick-list and a one-of-many choice are + // bare structs, so their textinput is the zero value and focusing it panics. field := w.fields[w.step] if len(field.choices) == 0 && len(field.options) == 0 { return w.fields[w.step].input.Focus() @@ -315,9 +291,8 @@ func (w *wizard) update(msg tea.Msg) tea.Cmd { return nil } -// s3EnvKeys are the variables the wizard owns when the backend is s3. They are -// listed once so that switching back to the filesystem can clear exactly these -// and leave anything an operator added by hand alone. +// s3EnvKeys are the variables the wizard owns when the backend is s3, listed once so +// switching back to the filesystem clears exactly these and leaves hand-set ones alone. var s3EnvKeys = []string{ "S3_ENDPOINT", "S3_BUCKET", @@ -361,9 +336,8 @@ func (w wizard) visible() []int { return steps } -// Position of the current step among the visible ones, and how many there are. -// The count moves as the storage answer changes, which is honest: it is the -// number of questions actually left. +// Position of the current step among the visible ones, and how many there are. The count +// moves as the storage answer changes, which is the number of questions actually left. func (w wizard) progress() (int, int) { steps := w.visible() for n, i := range steps { @@ -402,14 +376,8 @@ func (w *wizard) previous() tea.Cmd { return nil } -// onLastStep reports whether enter should save rather than advance. -// -// The dashboard used to work this out for itself with -// `step == len(fields)-1`, which was the same answer while every field was -// always shown. Once fields became conditional the two definitions disagreed: -// a filesystem server sits on step 8 of 8 while the last field in the slice is -// the sixth S3 one, so enter fell through to next(), which had nowhere to go, -// and the wizard could not be saved at all. +// onLastStep reports whether enter should save rather than advance. `step == len(fields)-1` +// disagreed once fields became conditional, and the wizard could not be saved at all. func (w wizard) onLastStep() bool { steps := w.visible() return len(steps) > 0 && w.step == steps[len(steps)-1] @@ -485,11 +453,8 @@ func (w wizard) profile() (config.Profile, error) { } profile := config.NewProfile(values["name"]) - // The S3 answers are environment variables rather than profile fields, so - // they travel in ExtraEnv next to anything set outside the wizard. Those - // other keys are preserved; the six below are rewritten from the answers, - // and cleared when the backend is not s3 so that credentials do not sit in - // the file for a backend nothing is using. + // The S3 answers are environment variables rather than profile fields, so they travel in + // ExtraEnv. The six below are rewritten from the answers and cleared when not s3. extra := map[string]string{} if w.original != nil { profile.ID = w.original.ID diff --git a/internal/app/wizard_test.go b/internal/app/wizard_test.go index f9bb090..91e5f96 100644 --- a/internal/app/wizard_test.go +++ b/internal/app/wizard_test.go @@ -190,11 +190,8 @@ func indexOf(t *testing.T, w wizard, key string) int { return -1 } -// Regression: the dashboard decided whether enter saves with -// `step == len(fields)-1`, which stopped meaning "the last visible step" as -// soon as fields became conditional. A filesystem server could be walked to -// step 8 of 8 and never saved, because the last field in the slice was the -// sixth S3 one. +// Regression: `step == len(fields)-1` stopped meaning "the last visible step" once fields +// became conditional, so a filesystem server reached step 8 of 8 and could never be saved. func TestEnterSavesOnTheLastVisibleStepForBothBackends(t *testing.T) { for _, backend := range []string{"filesystem", "s3"} { w := newWizard(nil) @@ -219,9 +216,8 @@ func TestEnterSavesOnTheLastVisibleStepForBothBackends(t *testing.T) { } } -// The test that would actually have caught it: the bug lived in the dashboard's -// key handling, not in the wizard, so asserting on onLastStep alone proves -// nothing about whether enter is wired to it. +// The test that would actually have caught it: the bug lived in the dashboard's key +// handling, so asserting on onLastStep alone proves nothing about what enter is wired to. func TestPressingEnterOnTheLastStepSavesAFilesystemServer(t *testing.T) { model := New(config.NewStore(t.TempDir()), &gruntime.Fake{}, "v0.1.0") model.mode = modeWizard @@ -288,9 +284,8 @@ func TestLeavingDefaultedFieldsAloneUsesTheDefaults(t *testing.T) { } } -// The bug: the field arrived holding "5000" with the cursor at the end, so -// typing appended and you got 50005001. Typing now replaces because there is -// nothing there to append to. +// The bug: the field arrived holding "5000" with the cursor at the end, so typing appended +// and you got 50005001. Typing now replaces, because there is nothing to append to. func TestTypingIntoADefaultedFieldReplacesRatherThanAppends(t *testing.T) { w := newWizard(nil) set(t, &w, "name", "My Server") @@ -378,10 +373,8 @@ func TestStartingAServerBringsUpTheSharedStack(t *testing.T) { } } -// Focusing a field that has no text input panicked: a tick-list is built as a -// bare struct, so its textinput is the zero value. The wizard died on the way -// into the step rather than while drawing it, which is why rendering it in -// isolation looked fine. +// Focusing a field with no text input panicked: a tick-list is a bare struct, so its +// textinput is the zero value. It died on the way in, which is why rendering looked fine. func TestFocusingEveryStepDoesNotPanic(t *testing.T) { w := newWizard(nil) set(t, &w, "storage", "s3") @@ -555,9 +548,8 @@ func TestTheRecommendedChoiceIsTheOneAlreadySelected(t *testing.T) { } } -// Not every question has a right answer. Path-style addressing is right for -// MinIO and wrong for AWS, so badging it would be wrong half the time and -// would teach people to ignore the badge where it is right. +// Not every question has a right answer. Path-style addressing is right for MinIO and wrong +// for AWS, so badging it would teach people to ignore the badge where it is right. func TestQuestionsWithoutARightAnswerCarryNoRecommendation(t *testing.T) { w := newWizard(nil) if got := w.fields[indexOf(t, w, "s3path")].recommended; got != "" { @@ -694,9 +686,8 @@ func TestReachableAddressesComeBeforeLoopback(t *testing.T) { } } -// The detail view swallowed every key but esc, while its own footer listed -// s, x, r and l — naming keys that did nothing on the one screen dedicated to -// that server. +// The detail view swallowed every key but esc while its own footer listed s, x, r and l — +// naming keys that did nothing on the one screen dedicated to that server. func TestTheDetailViewCanActOnItsServer(t *testing.T) { store := config.NewStore(t.TempDir()) profile := config.NewProfile("Test") diff --git a/internal/config/addresses.go b/internal/config/addresses.go index d574bc0..3060cdd 100644 --- a/internal/config/addresses.go +++ b/internal/config/addresses.go @@ -12,14 +12,8 @@ type Address struct { Label string } -// LocalAddresses lists the IPv4 addresses of this machine's up interfaces, -// loopback excluded. -// -// Nothing is asked of the network to produce this: it reads the interfaces and -// stops. A machine behind NAT therefore reports its private address and not the -// address the internet sees, which is the honest answer. Finding the latter -// means asking a third party what it thinks your address is, and that is a -// request Gryt should not make on somebody's behalf without being told to. +// LocalAddresses lists the IPv4 addresses of this machine's up interfaces, loopback excluded. +// Nothing is asked of the network, so a machine behind NAT reports its private address. func LocalAddresses() []Address { interfaces, err := net.Interfaces() if err != nil { @@ -57,14 +51,8 @@ func LocalAddresses() []Address { return found } -// isVirtual drops the interfaces that exist because of software on this -// machine rather than because of a network somebody can reach it over. -// -// Not cosmetic. A Mac running Docker reported seven addresses, five of them -// bridges to container networks; advertising those in ICE gives every client a -// handful of candidates that can never connect and makes them wait to find out. -// The list is names rather than address ranges because a Docker bridge and a -// home network both look like 192.168. +// isVirtual drops interfaces that exist because of software rather than a reachable network. +// Names rather than ranges: a Docker bridge and a home network both look like 192.168. func isVirtual(name string) bool { prefixes := []string{ "bridge", "vmnet", "utun", "awdl", "llw", "ap", "anpi", // macOS @@ -85,13 +73,8 @@ func labelFor(ip net.IP, iface string) string { return iface + ", reachable from the internet" } -// AdvertiseIPs is what the SFU should announce in ICE candidates: every -// address this machine answers on. -// -// It belongs to the machine rather than to any one server, which is why it is -// derived here and written into the shared project instead of being asked -// about per server. The SFU takes a comma-separated list and clients pick -// whichever path works. +// AdvertiseIPs is what the SFU should announce in ICE candidates. It belongs to the machine +// rather than any one server, so it is written into the shared project. func AdvertiseIPs() string { addresses := LocalAddresses() ips := make([]string, 0, len(addresses)) diff --git a/internal/config/addresses_test.go b/internal/config/addresses_test.go index e7e5016..c2a0b56 100644 --- a/internal/config/addresses_test.go +++ b/internal/config/addresses_test.go @@ -21,9 +21,8 @@ func TestVirtualInterfacesAreExcluded(t *testing.T) { } } -// The reason the filter exists: a Mac running Docker reported seven addresses, -// five of them bridges to container networks. Advertising those hands every -// client candidates that can never connect. +// The reason the filter exists: a Mac running Docker reported seven addresses, five of them +// bridges to container networks. Advertising those hands out candidates that never connect. func TestLocalAddressesSkipsLoopbackAndVirtual(t *testing.T) { for _, address := range LocalAddresses() { if strings.HasPrefix(address.IP, "127.") { diff --git a/internal/config/config.go b/internal/config/config.go index 7f22603..1dc9934 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,19 +55,14 @@ type Profile struct { // Signs this server's session tokens. Generated once and kept, because // rotating it signs everybody out. JWTSecret string `json:"jwtSecret,omitempty"` - // Authorises the CLI against this server's management API. Kept here - // rather than in the generated .env on purpose: .env is the file somebody - // pastes into a bug report or copies to another machine, and this is a - // credential for a running server. It reaches the container through the - // compose command's own environment instead. + // Authorises the CLI against this server's management API. Kept out of the generated + // .env: that is the file somebody pastes into a bug report, and this is a credential. AdminToken string `json:"adminToken,omitempty"` // The management API's port on this machine. Published to loopback only, // and its own port because the server's main one is reachable by design. AdminPort int `json:"adminPort,omitempty"` - // Filled in when the server uses this machine's shared object store, so - // the generated files can name its credentials. Deliberately not - // persisted: the shared secrets file owns them, and copying them into - // every profile would mean rotating them in several places. + // Filled in when the server uses this machine's shared object store. Deliberately not + // persisted: the shared secrets file owns them, and copies would need rotating twice. SharedS3 *SharedSecrets `json:"-"` ExtraEnv map[string]string `json:"extraEnv,omitempty"` CreatedAt time.Time `json:"createdAt"` @@ -199,20 +194,12 @@ func (s *Store) List() ([]Profile, error) { if jsonErr := json.Unmarshal(data, &profile); jsonErr != nil { return nil, fmt.Errorf("decode %s: %w", path, jsonErr) } - // Profiles written before the CLI generated a secret have none, and a - // server without one refuses to start. Filling it in here, on the read - // path, is what makes an existing profile work on the next start - // rather than requiring the operator to recreate it. Written back so - // the value is stable: generating a fresh one on every load would sign - // everybody out each time. + // Profiles written before the CLI generated a secret have none, and a server without + // one refuses to start. Written back so the value is stable across loads. if profile.JWTSecret == "" { profile.JWTSecret = NewSecret() - // Best effort. If the profile cannot be written back, for any - // reason including it being invalid in some unrelated way, the - // listing still succeeds and this server still gets a working - // secret for as long as the process lives. Failing the whole - // listing because one profile could not be migrated would take - // every other server down with it. + // Best effort. Failing the whole listing because one profile could not be + // migrated would take every other server down with it. _ = s.Save(profile) } if profile.AdminToken == "" { @@ -221,9 +208,8 @@ func (s *Store) List() ([]Profile, error) { } profiles = append(profiles, profile) } - // A management port cannot be chosen while the profiles are still being - // read, because picking one needs to know what every other server already - // claims. Second pass, once they are all here. + // A management port cannot be chosen while the profiles are still being read, because + // picking one needs to know what every other server claims. Second pass. for i := range profiles { if profiles[i].AdminPort != 0 { continue diff --git a/internal/config/env.go b/internal/config/env.go index 63ef9c1..bfa7595 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -48,19 +48,16 @@ func (p Profile) EnvSettings() []EnvSetting { {Key: "GRYT_TRUSTED_PROXY_HOPS", Value: strconv.Itoa(p.TrustedProxyHops), Mode: ModeRestart}, {Key: "VOICE_MAX_USERS", Value: strconv.Itoa(p.VoiceMaxUsers), Mode: ModeRestart}, {Key: "STORAGE_BACKEND", Value: storageBackend(p.StorageBackend), Mode: ModeRestart}, - // The server refuses to start without this, deliberately: it treats - // the placeholder as fatal rather than signing tokens with a value - // everybody knows. + // The server refuses to start without this, deliberately: it treats the placeholder + // as fatal rather than signing tokens with a value everybody knows. {Key: "JWT_SECRET", Value: p.JWTSecret, Sensitive: true, Mode: ModeRestart}, } - // The server reaches the SFU over the shared network by container name. - // This is not the address clients dial: that is SFU_PUBLIC_HOST below, - // which depends on how this machine is reachable from wherever they are. + // The server reaches the SFU over the shared network by container name. Not the address + // clients dial: that is SFU_PUBLIC_HOST below. settings = append(settings, EnvSetting{Key: "SFU_WS_HOST", Value: InternalSFUHost(), Mode: ModeRestart}) - // What a client is told to connect to. Falls back to localhost, which is - // right for trying a server on the machine that hosts it and wrong for - // anything else, so the wizard asks. + // What a client is told to connect to. Falls back to localhost, which is right for + // trying a server on the machine that hosts it and wrong for anything else. public := p.SFUWebSocketURL if public == "" { public = "ws://localhost:" + strconv.Itoa(SFUPort) @@ -85,10 +82,8 @@ func (p Profile) EnvSettings() []EnvSetting { EnvSetting{Key: "S3_SECRET_ACCESS_KEY", Value: p.SharedS3.MinIOPassword, Sensitive: true, Mode: ModeRestart}, EnvSetting{Key: "S3_BUCKET", Value: p.SharedS3.Bucket, Mode: ModeRestart}, EnvSetting{Key: "S3_FORCE_PATH_STYLE", Value: "true", Mode: ModeRestart}, - // The image worker runs beside this server rather than in the - // shared project: it reads the job queue out of this server's - // SQLite database, so it needs this server's data directory and - // cannot be one process for all of them. + // The image worker runs beside this server rather than in the shared project: + // it reads the job queue out of this server's SQLite database. EnvSetting{Key: "IMAGE_WORKER_URL", Value: "http://gryt-" + p.ID + "-image-worker:8080", Mode: ModeRestart}, ) } @@ -108,9 +103,8 @@ func (p Profile) EnvSettings() []EnvSetting { return settings } -// storageBackend maps the wizard's answer onto what the server understands. -// "shared" is a deployment arrangement rather than a backend: to the server it -// is S3, pointed at the object store running beside it. +// storageBackend maps the wizard's answer onto what the server understands. "shared" is a +// deployment arrangement rather than a backend: to the server it is S3. func storageBackend(choice string) string { if choice == SharedStorage { return "s3" @@ -139,13 +133,8 @@ func quoteEnv(value string) string { return `"` + replacer.Replace(value) + `"` } -// Settings resolves what this server's environment actually is. -// -// EnvSettings alone is not enough for a server on the shared object store: its -// credentials live in the shared secrets file rather than on the profile, so -// they have to be attached first. Doing that here rather than at each call site -// is what stopped `gryt env` from reporting STORAGE_BACKEND=s3 with no S3 -// settings under it while the generated .env had all of them. +// Settings resolves what this server's environment actually is. A server on the shared +// object store keeps its credentials in the shared secrets file, so they attach here. func (s *Store) Settings(profile Profile) ([]EnvSetting, error) { if profile.StorageBackend == SharedStorage { secrets, err := s.Secrets() @@ -196,10 +185,8 @@ func (s *Store) WriteCompose(profile Profile) (string, error) { } path := filepath.Join(dir, "compose.yaml") - // The image worker reads the job queue out of this server's SQLite - // database, so it mounts this server's data directory and there is one per - // server. It cannot live in the shared project with the SFU and the object - // store, which serve every server from one process each. + // The image worker reads the job queue out of this server's SQLite database, so it + // mounts this server's data directory and there is one per server. worker := "" if profile.StorageBackend == SharedStorage { secrets, err := s.Secrets() diff --git a/internal/config/ports.go b/internal/config/ports.go index e0981e6..bac8d70 100644 --- a/internal/config/ports.go +++ b/internal/config/ports.go @@ -9,20 +9,11 @@ import ( // use, so a machine with nothing in the way still gets the documented one. const DefaultPort = 5000 -// FreePort returns the first port at or above DefaultPort that nothing holds -// and no existing server claims. -// -// A fixed default was wrong twice over. Every new server got 5000, so the -// second one on a machine failed to bind. And on macOS 5000 is taken before -// anything else starts: ControlCenter listens there for AirPlay Receiver, so -// the server bound nothing the host could reach, the dashboard showed it as -// unknown, and anybody given the address reached an AirPlay receiver instead. -// -// Probing rather than hardcoding a different number means this keeps working -// when the next thing squats the next port. -// AdminPortBase is where management ports are searched from. A different range -// to the servers' own so the two are told apart at a glance in `docker ps` and -// in a firewall rule. +// FreePort returns the first port at or above DefaultPort that nothing holds and no existing +// server claims. On macOS 5000 is ControlCenter's AirPlay receiver, so a fixed default lied. + +// AdminPortBase is where management ports are searched from — a different range to the +// servers' own, so the two are told apart in `docker ps` and in a firewall rule. const AdminPortBase = 5090 // FreeAdminPort returns a management port nothing holds and no other server @@ -50,19 +41,8 @@ func freePortFrom(start int, taken []int) int { return start } -// portFree reports whether this machine will let a server bind the port. -// -// Bound on all interfaces on purpose: 0.0.0.0 is the default bind address, and -// a port free on loopback but held on another interface would still fail. On -// macOS that is exactly the AirPlay case. -// -// "tcp4", not "tcp", and the difference is not cosmetic. With "tcp" and an -// address of 0.0.0.0, Go opens a dual-stack socket that binds happily while -// something else already holds the IPv4 port — measured against a container -// publishing 0.0.0.0:5001, where "tcp" succeeded and "tcp4" correctly reported -// the address in use. Docker publishes on IPv4, so the probe has to ask about -// IPv4 or it hands out ports that are already taken and the start fails with -// "port is already allocated". +// portFree reports whether this machine will let a server bind the port, on all interfaces. +// "tcp4", not "tcp": a dual-stack socket binds happily while Docker holds the IPv4 port. func portFree(port int) bool { listener, err := net.Listen("tcp4", net.JoinHostPort("0.0.0.0", strconv.Itoa(port))) if err != nil { diff --git a/internal/config/ports_test.go b/internal/config/ports_test.go index ce5355b..e91edc5 100644 --- a/internal/config/ports_test.go +++ b/internal/config/ports_test.go @@ -51,10 +51,8 @@ func TestPortsInUseReadsTheProfiles(t *testing.T) { } } -// The probe used net.Listen("tcp", "0.0.0.0:port"), which opens a dual-stack -// socket and binds happily while something else already holds the IPv4 port. -// Docker publishes on IPv4, so the CLI handed out ports that were already -// taken and the start failed with "port is already allocated". +// The probe used net.Listen("tcp", …), which opens a dual-stack socket and binds while +// something holds the IPv4 port. Docker publishes on IPv4, so the CLI handed out taken ports. func TestPortFreeAsksAboutIPv4(t *testing.T) { held, err := net.Listen("tcp4", "0.0.0.0:0") if err != nil { diff --git a/internal/config/preferences.go b/internal/config/preferences.go index 0d593b7..bd501f9 100644 --- a/internal/config/preferences.go +++ b/internal/config/preferences.go @@ -13,12 +13,8 @@ const ( ChannelBeta = "beta" ) -// Preferences are machine-wide rather than per-server. -// -// The channel covers both the CLI's own updates and the image tag its servers -// run, deliberately as one switch. A beta CLI managing stable servers, or the -// reverse, is the combination that produces confusing bug reports: the two -// move together or the pairing means nothing. +// Preferences are machine-wide rather than per-server. The channel covers the CLI's own +// updates and its servers' image tag as one switch: the two move together or mean nothing. type Preferences struct { Channel string `json:"channel"` } diff --git a/internal/config/preferences_test.go b/internal/config/preferences_test.go index e505442..122754d 100644 --- a/internal/config/preferences_test.go +++ b/internal/config/preferences_test.go @@ -23,9 +23,8 @@ func TestBetaChannelChangesTheImageTag(t *testing.T) { } } -// Anything that is not a channel this CLI knows falls back to stable rather -// than being written through, so a typo cannot leave a machine following a -// channel that does not exist. +// Anything that is not a channel this CLI knows falls back to stable rather than being +// written through, so a typo cannot leave a machine following a channel that does not exist. func TestAnUnknownChannelFallsBackToStable(t *testing.T) { store := NewStore(t.TempDir()) if err := store.SetChannel("weekly"); err != nil { diff --git a/internal/config/publicaddr.go b/internal/config/publicaddr.go index cd3bdea..01d6ea3 100644 --- a/internal/config/publicaddr.go +++ b/internal/config/publicaddr.go @@ -11,24 +11,14 @@ import ( "time" ) -// PublicLookupDisabled reports whether the operator has asked not to be told -// their public address. The lookup leaves the machine, so it gets an off switch -// and the docs name the host it contacts. +// PublicLookupDisabled reports whether the operator has asked not to be told their public +// address. The lookup leaves the machine, so it has an off switch and the docs name the host. func PublicLookupDisabled() bool { return strings.TrimSpace(os.Getenv("GRYT_NO_PUBLIC_LOOKUP")) != "" } -// PublicAddress reports the address this machine is reachable at from outside -// its own network. -// -// Interfaces cannot answer this. A machine behind NAT holds a private address -// and has no way of knowing what the world sees, so something outside has to -// say. STUN exists for precisely that question, the SFU already depends on it -// for voice, and the default server is the one already named in the shared -// stack's configuration — so this adds no new dependency and no new party. -// -// A machine that genuinely holds a public address on an interface is answered -// from the interface, and nothing is sent at all. +// PublicAddress reports the address this machine is reachable at from outside. Interfaces +// cannot answer it, so STUN does — the server the shared stack already names. func PublicAddress(ctx context.Context, server string) (string, error) { for _, address := range LocalAddresses() { if ip := net.ParseIP(address.IP); ip != nil && !ip.IsPrivate() { @@ -38,19 +28,16 @@ func PublicAddress(ctx context.Context, server string) (string, error) { if PublicLookupDisabled() { return "", errors.New("public address lookup is turned off") } - // IPv4 first. A dual-stack machine reaches Google's STUN over IPv6 and is - // told its IPv6 address, which is correct and almost never what somebody - // wants: port forwarding, and most of what people hand out, is v4. Fall - // back to whatever the network offers when there is no v4 path at all. + // IPv4 first. A dual-stack machine is told its IPv6 address, which is correct and almost + // never what somebody wants. Fall back when there is no v4 path at all. if address, err := stunBinding(ctx, "udp4", server); err == nil { return address, nil } return stunBinding(ctx, "udp", server) } -// stunBinding sends one STUN binding request and reads the mapped address out -// of the reply. RFC 5389 in about forty lines: a fixed header, a random -// transaction id, and one attribute worth reading. +// stunBinding sends one STUN binding request and reads the mapped address out of the reply: +// RFC 5389 in about forty lines, and one attribute worth reading. func stunBinding(ctx context.Context, network, server string) (string, error) { deadline, ok := ctx.Deadline() if !ok { diff --git a/internal/config/publicaddr_test.go b/internal/config/publicaddr_test.go index 4e694b0..0ef494e 100644 --- a/internal/config/publicaddr_test.go +++ b/internal/config/publicaddr_test.go @@ -8,9 +8,8 @@ import ( "time" ) -// stubSTUN answers one binding request with an XOR-MAPPED-ADDRESS of the given -// family, so the parser can be exercised without the internet and without -// depending on which family a real server happens to answer over. +// stubSTUN answers one binding request with an XOR-MAPPED-ADDRESS of the given family, so +// the parser can be exercised without the internet. func stubSTUN(t *testing.T, family byte, ip net.IP) string { t.Helper() conn, err := net.ListenPacket("udp4", "127.0.0.1:0") @@ -75,9 +74,8 @@ func TestStunReadsAnIPv4MappedAddress(t *testing.T) { } } -// The case that broke it against the real server: a dual-stack machine reaches -// Google's STUN over IPv6 and is answered with family 0x02, which the first -// version walked straight past. +// The case that broke it against the real server: a dual-stack machine reaches Google's STUN +// over IPv6 and is answered with family 0x02, which the first version walked past. func TestStunReadsAnIPv6MappedAddress(t *testing.T) { server := stubSTUN(t, 0x02, net.ParseIP("2001:db8::1")) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) diff --git a/internal/config/secrets.go b/internal/config/secrets.go index 2ecf039..66b2b8e 100644 --- a/internal/config/secrets.go +++ b/internal/config/secrets.go @@ -6,21 +6,16 @@ import ( "path/filepath" ) -// SharedSecrets are the credentials for the object store every server on this -// machine shares. Generated once and kept, because rotating them orphans every -// upload already written under them. +// SharedSecrets are the credentials for the object store every server on this machine +// shares. Generated once and kept: rotating them orphans every upload already written. type SharedSecrets struct { MinIOUser string `json:"minioUser"` MinIOPassword string `json:"minioPassword"` Bucket string `json:"bucket"` } -// Secrets loads the shared credentials, creating them on first use. -// -// Not "minioadmin/minioadmin" as the compose examples use. Those are fine in a -// file somebody reads before deploying and edits; they are not fine written -// automatically onto a machine where the object store is published and nobody -// was ever prompted to change them. +// Secrets loads the shared credentials, creating them on first use. Not +// "minioadmin/minioadmin": nobody is ever prompted to change what is written automatically. func (s *Store) Secrets() (SharedSecrets, error) { path := filepath.Join(s.SharedDir(), "secrets.json") diff --git a/internal/config/shared.go b/internal/config/shared.go index bda0691..f472e44 100644 --- a/internal/config/shared.go +++ b/internal/config/shared.go @@ -6,52 +6,31 @@ import ( "strconv" ) -// The shared stack: the pieces every server on this machine uses one of. -// -// The SFU is built for this. One process serves every Gryt server on a host, -// which is how production has always run it, so giving each server its own -// would be both wasteful and unlike the deployment everything else is tested -// against. -// -// It is a separate compose project rather than another service in each -// server's file, so that starting and stopping a server stays a per-server -// operation and does not take the media plane down with it. +// The shared stack: the pieces every server on this machine uses one of. A separate compose +// project, so starting a server does not take the media plane down with it. const ( - // SharedNetwork is created by the shared project and joined by each server - // as external. The name is fixed because the server files have to name it - // without reading anything else. + // SharedNetwork is created by the shared project and joined by each server as external. + // The name is fixed because the server files name it without reading anything else. SharedNetwork = "gryt" - // SFUContainer is how servers address the SFU. A container name rather - // than a service name, so it resolves the same way from another compose - // project on the shared network. + // SFUContainer is how servers address the SFU. A container name rather than a service + // name, so it resolves the same from another compose project on the shared network. SFUContainer = "gryt-sfu" - // MinIOContainer is the object store every server here shares. One store - // with one bucket, because the alternative is a MinIO per server on a - // machine that already runs one process per server. + // MinIOContainer is the object store every server here shares. One store with one + // bucket, because a MinIO per server on a one-process-per-server machine is silly. MinIOContainer = "gryt-minio" - // MinIOPort is the port inside the network. It is deliberately not - // published to the host: servers reach the store over the shared network - // by name, and publishing it only creates a collision. On this machine it - // collided immediately with an unrelated MinIO already on 9000, and the - // failure surfaced as "failed to set up container networking", which says - // nothing about ports. + // MinIOPort is the port inside the network, deliberately not published: servers reach + // the store by name, and publishing it collided with an unrelated MinIO on 9000. MinIOPort = 9000 // SFUPort is the signalling port inside the network. SFUPort = 5005 - // DefaultSTUN matches what production runs. Without it the server logs - // "Missing STUN servers! SFU may not reach all clients" and media fails - // for anybody behind NAT, which is most people. STUN reveals a client's - // address to the STUN server and nothing else; it is set here rather than - // left empty because voice that only works on one LAN is not voice. + // DefaultSTUN matches what production runs. Without it media fails for anybody behind + // NAT, which is most people; STUN reveals a client's address to the STUN server only. DefaultSTUN = "stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302" - // DefaultSTUNServer is the same first server, in the host:port form a - // binding request needs. Named once so the docs can say which host the - // public-address lookup contacts. + // DefaultSTUNServer is the same first server in host:port form, named once so the docs + // can say which host the public-address lookup contacts. DefaultSTUNServer = "stun.l.google.com:19302" - // SFUMuxPort carries every participant's media over one UDP port. - // Production uses 443, which needs a privileged bind and collides with - // anything already serving HTTPS; 3478 is unprivileged and is the port - // people already open for STUN. + // SFUMuxPort carries every participant's media over one UDP port. Production uses 443, + // which needs a privileged bind; 3478 is unprivileged and already open for STUN. SFUMuxPort = 3478 ) @@ -61,9 +40,8 @@ func (s *Store) SharedDir() string { return filepath.Join(s.root, "shared") } -// InternalSFUHost is what a server uses to reach the SFU over the shared -// network. Not what a client uses: that is SFU_PUBLIC_HOST, which depends on -// how this machine is reachable and is asked for separately. +// InternalSFUHost is what a server uses to reach the SFU over the shared network. Not what a +// client uses: that is SFU_PUBLIC_HOST, which depends on how this machine is reachable. func InternalSFUHost() string { return "ws://" + SFUContainer + ":5005" } diff --git a/internal/config/shared_test.go b/internal/config/shared_test.go index 1bc9462..5ce4740 100644 --- a/internal/config/shared_test.go +++ b/internal/config/shared_test.go @@ -54,9 +54,8 @@ func TestServerComposeJoinsTheSharedNetworkWithoutCreatingIt(t *testing.T) { } } -// The two halves of the SFU configuration answer different questions, and -// confusing them is how voice half-works: the server talks to the container, -// the client is told an address it can actually reach. +// The two halves of the SFU configuration answer different questions, and confusing them is +// how voice half-works: the server talks to the container, the client to a reachable address. func TestSFUEnvSplitsInternalFromPublic(t *testing.T) { profile := NewProfile("My Server") @@ -165,9 +164,8 @@ func TestSharedStorageAddsAPerServerImageWorker(t *testing.T) { } } -// gryt env reported STORAGE_BACKEND=s3 with no S3 settings under it, while the -// generated .env had all of them, because the credentials were attached in one -// path and not the other. +// gryt env reported STORAGE_BACKEND=s3 with no S3 settings under it while the generated .env +// had all of them, because the credentials were attached in one path and not the other. func TestSettingsResolvesTheSharedCredentials(t *testing.T) { store := NewStore(t.TempDir()) settings, err := store.Settings(NewProfile("Env Test")) diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 6ba0d55..e0a5b2c 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -1,10 +1,5 @@ -// Package doctor answers "why did that not work" before the operator has to -// ask it. -// -// Everything here is about the machine rather than about Gryt: whether Docker -// is installed, whether its daemon is up, whether the config directory can be -// written. The CLI used to find all of this out by running docker compose and -// showing whatever came back, which is accurate and unreadable. +// Package doctor answers "why did that not work" before the operator has to ask. Everything +// here is about the machine rather than Gryt: Docker installed, daemon up, directory writable. package doctor import ( @@ -62,9 +57,8 @@ func Problems(checks []Check) []Check { return problems } -// Docker is the subset that decides whether a deployment can be started at -// all. Kept separate so the pre-flight check before an action and the doctor -// command cannot drift apart. +// Docker is the subset that decides whether a deployment can start at all. Kept separate so +// the pre-flight check and the doctor command cannot drift apart. func Docker(ctx context.Context, probe Probe) []Check { return []Check{dockerInstalled(), composePlugin(ctx, probe), daemonRunning(ctx, probe)} } @@ -92,10 +86,8 @@ func composePlugin(ctx context.Context, probe Probe) Check { return check } -// The check the old one should have been. `docker compose version` asks the -// client about itself and never contacts the daemon, so it passes with Docker -// Desktop installed and shut down, which on macOS is the single most likely -// thing to be wrong. `docker info` is the cheapest call that needs the daemon. +// `docker compose version` asks the client about itself and never contacts the daemon, so it +// passes with Docker Desktop shut down. `docker info` is the cheapest call that needs it. func daemonRunning(ctx context.Context, probe Probe) Check { check := Check{Name: "Docker daemon"} if err := probe(ctx, "docker", "info"); err != nil { @@ -125,9 +117,8 @@ func configWritable(root string) Check { return check } -// Two servers on one port is a configuration mistake rather than a machine -// one, and it is invisible until the second container fails to bind. The -// wizard defaults every new server to 5000, so it is easy to arrive at. +// Two servers on one port is a configuration mistake, invisible until the second container +// fails to bind. The wizard defaults every new server to 5000, so it is easy to arrive at. func duplicatePorts(profiles []config.Profile) *Check { seen := map[string]string{} for _, profile := range profiles { @@ -147,15 +138,8 @@ func duplicatePorts(profiles []config.Profile) *Check { return &Check{Name: "Ports", OK: true, Detail: strconv.Itoa(len(profiles)) + " server(s), no clashes"} } -// squattedPorts finds a server whose port is held by something that is not it. -// -// The discriminator is the answer, not whether the port can be bound. -// -// Binding was the first attempt and it was wrong: macOS allowed a bind of -// 127.0.0.1:5000 while ControlCenter held *:5000 for AirPlay, so the check -// concluded the port was free and never asked. Asking is the whole point. A -// Gryt server replies to /health with 2xx; AirTunes replies 403; a stopped -// server refuses the connection, which is not a problem and is skipped. +// squattedPorts finds a server whose port is held by something that is not it. The +// discriminator is the answer, not whether the port binds: macOS allowed both. func squattedPorts(ctx context.Context, profiles []config.Profile) *Check { client := http.Client{Timeout: time.Second} for _, profile := range profiles { diff --git a/internal/management/client.go b/internal/management/client.go index cd83135..67f700a 100644 --- a/internal/management/client.go +++ b/internal/management/client.go @@ -1,8 +1,5 @@ -// Package management talks to a server's local management API. -// -// The API only listens when the server was started with a token, and the -// generated Compose file publishes it to 127.0.0.1 only, so this reaches a -// server running on this machine and nothing else can reach it at all. +// Package management talks to a server's local management API, which only listens when the +// server was started with a token and is published to 127.0.0.1 only. package management import ( diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index c6a4581..aa86fc7 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -34,9 +34,8 @@ type Manager interface { Stop(context.Context, config.Profile, string) error Restart(context.Context, config.Profile, string) error Logs(context.Context, config.Profile, string, int) (string, error) - // ContainerRunning reports whether one named container is up. The shared - // project's pieces are addressed by container name because they are not - // any server's, so there is no profile to ask about. + // ContainerRunning reports whether one named container is up. The shared project's + // pieces are addressed by container name because they are not any server's. ContainerRunning(context.Context, string) bool // ContainerLogs reads one container's output, for the same reason. ContainerLogs(context.Context, string, int) (string, error) @@ -48,14 +47,8 @@ type Manager interface { type Docker struct{} -// Available reports the first thing standing between the operator and a -// running container. -// -// This used to run `docker compose version` alone, which asks the client about -// itself and never contacts the daemon: it passes with Docker Desktop -// installed and shut down, which on macOS is the most likely thing to be -// wrong. The checks live in internal/doctor so that this and `gryt doctor` -// cannot disagree. +// Available reports the first thing standing between the operator and a running container. +// The checks live in internal/doctor, so this and `gryt doctor` cannot disagree. func (Docker) Available(ctx context.Context) error { problems := doctor.Problems(doctor.Docker(ctx, doctor.Exec)) if len(problems) == 0 { @@ -90,12 +83,8 @@ func composeCommand(ctx context.Context, dir string, args ...string) error { return composeCommandEnv(ctx, dir, nil, args...) } -// composeCommandEnv runs compose with extra environment of its own. -// -// The management token reaches the container this way rather than through -// .env: compose substitutes ${GRYT_ADMIN_TOKEN} in the generated file from its -// own environment, so the value lives in the CLI's profile and never in a file -// somebody might paste into a bug report or copy to another machine. +// composeCommandEnv runs compose with extra environment of its own. The management token +// reaches the container this way rather than through .env, which people paste into reports. func composeCommandEnv(ctx context.Context, dir string, env []string, args ...string) error { base := []string{"compose", "--project-directory", dir, "--file", dir + "/compose.yaml"} cmd := exec.CommandContext(ctx, "docker", append(base, args...)...) @@ -111,15 +100,8 @@ func composeCommandEnv(ctx context.Context, dir string, env []string, args ...st return nil } -// composeReason picks the line worth showing out of compose's output. -// -// Compose narrates to stderr, so a failed run opens with several "Container X -// Creating" lines and puts the reason further down. Returning the whole buffer -// meant the dashboard, which has one line to show an error in, displayed the -// first of those — so a start that failed reported something that reads like a -// start that is working. -// -// The reason is at the end, and is usually the only line that says so. +// composeReason picks the line worth showing out of compose's output. The reason is at the +// end; returning the whole buffer showed "Container X Creating" for a failed start. func composeReason(output string, fallback error) string { lines := strings.Split(strings.TrimSpace(output), "\n") @@ -153,9 +135,8 @@ func (Docker) Start(ctx context.Context, profile config.Profile, dir string) err return composeCommandEnv(ctx, dir, adminEnv(profile), "up", "--detach", "--remove-orphans") } -// adminEnv carries the management token into the compose invocation. Empty -// when the profile has none, in which case the generated file substitutes an -// empty string and the server starts no management listener at all. +// adminEnv carries the management token into the compose invocation. Empty when the profile +// has none, in which case the server starts no management listener at all. func adminEnv(profile config.Profile) []string { if profile.AdminToken == "" { return nil @@ -181,12 +162,8 @@ func (Docker) ContainerRunning(ctx context.Context, name string) bool { return strings.TrimSpace(string(out)) == "true" } -// ContainerEnv reads a variable out of a running container. -// -// This is how the CLI knows which version a server is running. The image bakes -// SERVER_VERSION in at build time, so it is right there and needs nothing from -// the server itself — which also means it works on images built before the -// server had any way to report it. +// ContainerEnv reads a variable out of a running container. The image bakes SERVER_VERSION +// in at build time, so this works on images built before the server could report it. func (Docker) ContainerEnv(ctx context.Context, name, key string) string { cmd := exec.CommandContext(ctx, "docker", "inspect", "--format", "{{range .Config.Env}}{{println .}}{{end}}", name) diff --git a/internal/updater/updater.go b/internal/updater/updater.go index c5a2356..af62dae 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -1,9 +1,5 @@ -// Package updater moves the CLI from one release to the next. -// -// It talks to the GitHub releases API and nothing else, only when asked. This -// is an administrator's tool for machines the administrator owns, so knowing -// it is out of date is part of the job rather than telemetry: no identifier is -// sent, nothing is recorded, and a failed check is silent. +// Package updater moves the CLI from one release to the next. It talks to the GitHub +// releases API and nothing else, only when asked: no identifier, nothing recorded. package updater import ( @@ -29,9 +25,8 @@ import ( // exercise the real decoding instead of a copy of it. var releasesURL = "https://api.github.com/repos/Gryt-chat/cli/releases/latest" -// allReleasesURL includes prereleases, which /releases/latest excludes by -// design. Following the beta channel means asking the list and taking the -// newest, because the newest beta is exactly what /releases/latest hides. +// allReleasesURL includes prereleases, which /releases/latest excludes by design. The newest +// beta is exactly what /releases/latest hides. var allReleasesURL = "https://api.github.com/repos/Gryt-chat/cli/releases?per_page=10" type Release struct { @@ -39,12 +34,13 @@ type Release struct { Assets map[string]string // name -> download URL } -// Check asks which release is newest. A caller that cannot reach the network -// gets an error rather than a wrong answer. +// Check asks which release is newest. A caller that cannot reach the network gets an error +// rather than a wrong answer. + // Check asks which release is newest on the stable channel. -// LatestServerRelease reports the newest published Gryt server, so the CLI can -// say whether the one running here is behind. Same request shape as its own -// update check, against a different repository. + +// LatestServerRelease reports the newest published Gryt server, so the CLI can say whether +// the one running here is behind. Same request shape, different repository. func LatestServerRelease(ctx context.Context, client *http.Client, beta bool) (string, error) { url := "https://api.github.com/repos/Gryt-chat/server/releases/latest" if beta { @@ -179,9 +175,8 @@ func checkLatest(ctx context.Context, client *http.Client) (Release, error) { return release, nil } -// Newer reports whether want is a later version than have. Both may carry a -// leading v. A build with no version compiled in, which is what `go run` -// produces, is never considered out of date: there is nothing to compare. +// Newer reports whether want is a later version than have; both may carry a leading v. A +// build with no version compiled in, which `go run` produces, is never out of date. func Newer(have, want string) bool { if have == "" || have == "dev" || want == "" { return false @@ -214,9 +209,8 @@ func parse(version string) ([3]int, string) { return out, pre } -// AssetFor picks this platform's archive out of a release. Matching on the -// pieces rather than on a filename means a change to goreleaser's naming -// template does not silently stop updates working. +// AssetFor picks this platform's archive out of a release. Matching on the pieces rather +// than a filename means a change to goreleaser's naming template does not stop updates. func (r Release) AssetFor(goos, goarch string) (name, url string, ok bool) { for name, url := range r.Assets { lower := strings.ToLower(name) @@ -230,13 +224,8 @@ func (r Release) AssetFor(goos, goarch string) (name, url string, ok bool) { return "", "", false } -// Apply downloads this platform's build of the release and replaces the binary -// at path with it. -// -// The download is verified against the release's checksums.txt before anything -// is replaced, and the replacement is a rename within the same directory, so a -// failure part way through leaves the existing binary untouched rather than -// half-written. +// Apply downloads this platform's build and replaces the binary at path. Verified against +// checksums.txt first, and replaced by a rename, so a failure leaves the old one untouched. func Apply(ctx context.Context, client *http.Client, release Release, path string) error { name, url, ok := release.AssetFor(runtime.GOOS, runtime.GOARCH) if !ok { @@ -335,9 +324,8 @@ func verify(ctx context.Context, client *http.Client, sumsURL, name string, arch return fmt.Errorf("%s is not listed in checksums.txt", name) } -// extract pulls the gryt binary out of a .tar.gz in memory. The archives are a -// few megabytes, so there is no reason to touch the disk before the checksum -// has been checked. +// extract pulls the gryt binary out of a .tar.gz in memory. The archives are a few +// megabytes, so there is no reason to touch the disk before the checksum has been checked. func extract(archive []byte) ([]byte, error) { gz, err := gzip.NewReader(bytes.NewReader(archive)) if err != nil { diff --git a/scripts/check-comment-length.mjs b/scripts/check-comment-length.mjs new file mode 100644 index 0000000..676acff --- /dev/null +++ b/scripts/check-comment-length.mjs @@ -0,0 +1,94 @@ +// No comment may run past two lines. Storytelling, history and restated types +// belong in git, in a task, or nowhere. See .claude/CLAUDE.md in the superproject. + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const LIMIT = 2; +const EMPTY = /^(\/\*+|\*+\/?|\/\/|#)$|[─=]{3,}\s*\*?\/?$/; + +/* Paths not swept yet. Delete an entry once that directory is clean. */ +const NOT_YET = []; + +const ROOTS = ["cmd", "internal", "scripts", ".github/workflows"]; +const SKIP = new Set(["node_modules", "dist", "build", "out", "coverage", ".git"]); +const CODE = /\.(go|ts|tsx|js|mjs|cjs|jsx)$/; +const HASH = /\.(ya?ml|sh)$/; + +function files(dir) { + const out = []; + for (const name of readdirSync(dir)) { + if (SKIP.has(name)) continue; + const full = join(dir, name); + if (statSync(full).isDirectory()) out.push(...files(full)); + else if (CODE.test(name) || HASH.test(name)) out.push(full); + } + return out; +} + +// A run is consecutive comment lines: one block comment, or a stack of // lines. +// Only lines carrying words count: `/**`, `*/` and a ── section rule are free. +function runs(text, hash) { + const lines = text.split("\n"); + const found = []; + let start = -1; + let length = 0; + let inBlock = false; + + const close = () => { + if (length > LIMIT) found.push({ line: start + 1, length }); + start = -1; + length = 0; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + let comment = false; + + if (inBlock) { + comment = true; + if (line.includes("*/")) inBlock = false; + } else if (!hash && line.startsWith("/*")) { + comment = true; + if (!line.includes("*/")) inBlock = true; + } else if (!hash && line.startsWith("//")) { + comment = true; + } else if (hash && line.startsWith("#") && !line.startsWith("#!")) { + comment = true; + } + + if (comment) { + if (start === -1) start = i; + if (!EMPTY.test(line)) length++; + } else if (start !== -1) { + close(); + } + } + if (start !== -1) close(); + return found; +} + +const offenders = []; +for (const root of ROOTS) { + let entries; + try { + entries = files(root); + } catch { + continue; + } + for (const file of entries) { + if (NOT_YET.some((prefix) => file.startsWith(prefix))) continue; + const text = readFileSync(file, "utf8"); + for (const run of runs(text, HASH.test(file))) { + offenders.push(`${file}:${run.line} — ${run.length} lines`); + } + } +} + +if (offenders.length > 0) { + console.error(`${offenders.length} comments longer than ${LIMIT} lines:\n`); + for (const line of offenders) console.error(` ${line}`); + process.exit(1); +} + +console.log("comments: none longer than two lines");