From 9ca87871bf286c0fdbf464792fc0e368cea11ca7 Mon Sep 17 00:00:00 2001 From: borjaperfra Date: Mon, 14 Sep 2026 16:33:03 +0200 Subject: [PATCH] feat(tui): the setup as four steps, under the banner, with a way out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked for by name: the banner first, then the four steps enumerated, a face on each, and a way to sign out. What was there asked one question at a time and never said how many there were. The sign-in screen said "Step 1 of 2" and then handed the member back to the panel to find the other two on their own, which is the shape of the whole complaint: guided for one step and a scavenger hunt after it. Now it is one screen that owns the terminal - no tab bar to wander into - with the mascot and the wordmark on top and the four steps listed, the one you are on marked and the ones behind it ticked: ✓ 1. Let's get you logged in ✓ 2. Let's confirm it with the magic link ▶ 3. Let's set up your API key 4. Let's configure your tools The mascot has a face per step rather than one for the whole thing: thinking while it waits for the link, happy when the last one is done. Signing out is `o`, twice. `nan auth logout` already existed as a command, which is no use to somebody looking at the panel who wants to switch accounts. It takes the key and the cached tabs with it - the key lives in the same file, and those numbers were true for somebody else - and drops straight back into the setup, which is the only thing left to do. Esc leaves the setup for the panel and does NOT quit the program: someone who skipped a step has not asked to close the CLI. The tool list is one function now, shared by the Setup tab and the last step, because two copies of a list with a cursor in it would drift the first time either moved. Co-Authored-By: Claude Opus 5 (1M context) --- internal/tui/config_test.go | 156 ++++++++++++++++ internal/tui/tui.go | 359 +++++++++++++++++++++++++++++------- scripts/install.ps1 | 2 +- scripts/install.sh | 2 +- 4 files changed, 453 insertions(+), 66 deletions(-) diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index 7290ef0..786785c 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -1497,3 +1497,159 @@ func TestTheAcceptedKeyPointsAtTheToolsStep(t *testing.T) { t.Errorf("the check says %q, which does not lead anywhere", got) } } + +// ── the guided setup ───────────────────────────────────────────────────────── + +func wizardAt(t *testing.T, step wizardStep) model { + t.Helper() + m := setupModel(t, &session.Session{}) + m.lay = newLayout(96, 44) + m.wizard = step + return m +} + +// Four numbered steps, in the member's own words, with the banner over them. +// It replaced a screen that said "Step 1 of 2" and then handed you back to the +// panel to find the other two yourself. +func TestTheGuidedSetupShowsAllFourSteps(t *testing.T) { + out := wizardAt(t, wizardEmail).renderWizard(newLayout(96, 44)) + + for _, want := range []string{ + "Setting up", + "1. Let's get you logged in", + "2. Let's confirm it with the magic link", + "3. Let's set up your API key", + "4. Let's configure your tools", + } { + if !strings.Contains(out, want) { + t.Errorf("the setup screen does not show %q", want) + } + } + // And the banner, because the thing you just opened should say what it is. + if !strings.Contains(out, "nan.builders") { + t.Error("the setup screen has no banner") + } +} + +// The one you are on is marked, and the ones behind it are ticked. +func TestTheGuidedSetupSaysWhereYouAre(t *testing.T) { + third := wizardAt(t, wizardKey).renderWizard(newLayout(96, 44)) + lines := strings.Split(third, "\n") + for _, line := range lines { + switch { + case strings.Contains(line, "1. Let's get you"), strings.Contains(line, "2. Let's confirm"): + if !strings.Contains(line, "✓") { + t.Errorf("a finished step is not ticked: %q", strings.TrimSpace(line)) + } + case strings.Contains(line, "3. Let's set up"): + if !strings.Contains(line, "▶") { + t.Errorf("the current step is not marked: %q", strings.TrimSpace(line)) + } + } + } +} + +// A face per step, so the mascot is doing something that matches it. +func TestEachStepHasItsOwnFace(t *testing.T) { + if wizardLink.mood() != moodThinking { + t.Error("waiting for a link is not a thinking face") + } + if wizardDone.mood() != moodHappy { + t.Error("finishing the setup is not a happy face") + } + for _, step := range []wizardStep{wizardEmail, wizardLink, wizardKey, wizardTools, wizardDone} { + if _, ok := mascotFaces[step.mood()]; !ok { + t.Errorf("step %v asks for a face that does not exist", step) + } + } +} + +// Esc leaves the setup and lands in the panel. It must not quit the program: +// someone who skipped a step has not asked to close the CLI. +func TestEscapeLeavesTheSetupAndNotTheProgram(t *testing.T) { + m := wizardAt(t, wizardEmail) + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + after := updated.(model) + if after.wizard != wizardOff { + t.Error("esc does not leave the setup") + } + if cmd != nil { + t.Error("esc during the setup quits the program") + } +} + +// Signing out from the panel, which `nan auth logout` is no use for when you +// are looking at the panel and want to switch accounts. +func TestSigningOutTakesTwoPressesAndClearsEverything(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("HERMES_HOME", filepath.Join(home, "h")) + + sess := &session.Session{Token: "t", APIKey: testKey} + if err := session.Save(sess); err != nil { + t.Fatal(err) + } + m := newModel(api.New("t"), sess) + m.lay = newLayout(90, 30) + m.cache[tabUsage] = "somebody else's numbers" + + press := func(m model, r rune) model { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + return updated.(model) + } + + // One press asks. + after := press(m, 'o') + if after.sess.Token == "" { + t.Fatal("one press signed out, with no confirmation") + } + if !strings.Contains(after.setupMsg, "press o again") { + t.Errorf("the first press says %q", after.setupMsg) + } + + // Anything else calls it off. + if press(after, 'r').confirmSignOut { + t.Error("the confirmation survives another key") + } + + // Two presses do it, and take the key and the cached answers with them. + out := press(press(m, 'o'), 'o') + if out.sess.Token != "" || out.sess.APIKey != "" { + t.Error("signing out left the session behind") + } + if out.client.Token() != "" { + t.Error("the client still carries the old token") + } + if len(out.cache) != 0 { + t.Error("the tabs keep answers that were true for another account") + } + if _, err := session.Load(); err == nil { + t.Error("the session file is still on disk") + } +} + +// And signing out drops straight back into the setup, which is the only thing +// left to do. +func TestSigningOutStartsTheSetupAgain(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("HERMES_HOME", filepath.Join(home, "h")) + sess := &session.Session{Token: "t", APIKey: testKey} + if err := session.Save(sess); err != nil { + t.Fatal(err) + } + m := newModel(api.New("t"), sess) + m.lay = newLayout(90, 30) + + press := func(m model, r rune) model { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + return updated.(model) + } + out := press(press(m, 'o'), 'o') + if out.wizard != wizardEmail { + t.Errorf("after signing out the panel is at %v, want the first step", out.wizard) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 66ef341..33023f1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -164,15 +164,73 @@ type model struct { configuring bool configured []string - loginStage loginStage - loginInput textinput.Model - loginEmail string - loginMsg string - loginBusy bool + // Set by the first press of `o`, cleared by anything else: signing out is + // not something to do to somebody on a stray keystroke. + confirmSignOut bool + wizard wizardStep + loginStage loginStage + loginInput textinput.Model + loginEmail string + loginMsg string + loginBusy bool +} + +// Where a member is in the guided setup. The inner inputs still drive +// themselves - loginStage for the two sign-in questions, editingKey for the +// key - and this is the step the screen is showing around them, so the panel +// can draw the four of them as one sequence with a banner over it. +type wizardStep int + +const ( + wizardOff wizardStep = iota + wizardEmail + wizardLink + wizardKey + wizardTools + wizardDone +) + +// The words are the member's, from the report that asked for this. +var wizardSteps = [...]struct { + n int + title string +}{ + {1, "Let's get you logged in"}, + {2, "Let's confirm it with the magic link"}, + {3, "Let's set up your API key"}, + {4, "Let's configure your tools"}, +} + +// Which of the four a step belongs to. wizardLink is step 2, and everything +// after the tools is step 4 still finishing. +func (w wizardStep) index() int { + switch w { + case wizardEmail: + return 0 + case wizardLink: + return 1 + case wizardKey: + return 2 + } + return 3 +} + +// The face for each, so the mascot is doing something that matches the step +// rather than staring through it. +func (w wizardStep) mood() mascotMood { + switch w { + case wizardEmail, wizardKey: + return moodNormal + case wizardLink: + return moodThinking + case wizardDone: + return moodHappy + } + return moodNormal } // Where a member is in the sign-in flow. It is two questions - an address and -// the link that arrives at it - so it is two stages and not a wizard. +// the link that arrives at it. type loginStage int const ( @@ -254,6 +312,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case configuredMsg: m.configuring = false + if m.wizard == wizardTools && !strings.HasPrefix(msg.msg, "error") { + m.wizard = wizardDone + } m.setupMsg = msg.msg m.configured = msg.written @@ -267,6 +328,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loginStage = loginAskEmail return m, m.loginInput.Focus() } + m.wizard = wizardLink m.loginStage = loginAskLink m.loginMsg = "a link is on its way to " + m.loginEmail + " — copy it out of the email without opening it, the link works once" @@ -292,7 +354,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // member in and then answered every tab `unauthorized`, which reads // exactly like the login having failed. m.client = api.New(m.sess.Token) + wasGuided := m.wizard != wizardOff m.cancelLogin() + if wasGuided { + m.wizard = wizardKey + } m.cache = make(map[tabID]any) m.err = nil m.keyAsked = false @@ -318,6 +384,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if n := installedTools(); n > 0 { m.keyCheck += fmt.Sprintf(" · press c to configure the %d tools found", n) } + if m.wizard == wizardKey { + m.wizard = wizardTools + } } case tea.KeyMsg: @@ -381,6 +450,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editingKey = false m.keyInput.Blur() m.setupMsg = "" + // Leaving the field during the guided setup leaves the setup: + // staying would show step 3 with nothing to type into. + m.wizard = wizardOff default: var cmd tea.Cmd m.keyInput, cmd = m.keyInput.Update(msg) @@ -389,15 +461,33 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + if m.confirmSignOut && msg.String() != "o" { + m.confirmSignOut = false + m.setupMsg = "" + } + switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "esc": - if m.showHelp { + switch { + case m.showHelp: m.showHelp = false - } else { + case m.wizard != wizardOff: + // Out of the setup and into the panel. Not out of the program: + // someone who wanted that has q, and quitting the whole CLI + // because they skipped a step would be its own small betrayal. + m.wizard = wizardOff + m.active = tabIndex(tabSetup) + default: return m, tea.Quit } + case "enter": + if m.wizard == wizardDone { + m.wizard = wizardOff + m.active = tabIndex(tabHome) + return m, m.maybeLoad() + } case "?": m.showHelp = !m.showHelp case "right", "l", "tab": @@ -418,7 +508,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "up", "k": if !m.showHelp { - if m.activeID() == tabSetup { + if m.activeID() == tabSetup || m.wizard == wizardTools { if m.setupCursor > 0 { m.setupCursor-- } @@ -428,14 +518,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "down", "j": if !m.showHelp { - if m.activeID() == tabSetup { + if m.activeID() == tabSetup || m.wizard == wizardTools { m.setupCursor++ } else { m.scrollY++ } } case " ": - if !m.showHelp && m.activeID() == tabSetup && !m.configuring { + if !m.showHelp && !m.configuring && (m.activeID() == tabSetup || m.wizard == wizardTools) { tools := detectTools() if m.setupCursor < len(tools) && tools[m.setupCursor].installed { name := tools[m.setupCursor].name @@ -457,6 +547,35 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.scrollY = 0 return m, m.maybeLoad() } + // Signing out, in two presses. `nan auth logout` already existed as a + // command, which is no use to someone who is looking at the panel and + // wants to switch accounts. + case "o": + if m.showHelp || m.wizard != wizardOff || m.sess.Token == "" { + break + } + if !m.confirmSignOut { + m.confirmSignOut = true + m.setupMsg = "press o again to sign out, any other key to keep the session" + m.active = tabIndex(tabSetup) + break + } + m.confirmSignOut = false + if err := session.Delete(); err != nil { + m.setupMsg = "error: " + err.Error() + break + } + // Everything the session was holding goes with it: the key lives in + // the same file, and the tabs are full of answers that were true + // for somebody else. + m.sess = &session.Session{} + m.client = api.New("") + m.cache = make(map[tabID]any) + m.keyStatus, m.keyAsked, m.keyCheck = nil, false, "" + m.configured, m.setupMsg = nil, "signed out" + m.err = nil + return m, m.resumeSetup() + // `s` and not `l`: l is already the vim spelling of "next tab". case "s": // Any tab, because the one a member is looking at when this is @@ -473,7 +592,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.startKeyEdit() } case "c": - if !m.showHelp && m.activeID() == tabSetup && !m.configuring { + if !m.showHelp && !m.configuring && (m.activeID() == tabSetup || m.wizard == wizardTools) { // Pressing the key that configures everything and having // nothing happen, with nothing said, is the worst of the // three possible answers. @@ -546,6 +665,7 @@ func (m *model) maybeLoad() tea.Cmd { // key. So it asks the same two questions here, where the keystrokes certainly // arrive, and the member never leaves the thing they just opened. func (m *model) startLogin() tea.Cmd { + m.wizard = wizardEmail m.loginStage = loginAskEmail m.loginMsg = "" m.loginInput.SetValue("") @@ -556,6 +676,9 @@ func (m *model) startLogin() tea.Cmd { // startKeyEdit opens the API key field, moving to the tab that shows it. func (m *model) startKeyEdit() tea.Cmd { + if m.wizard != wizardOff { + m.wizard = wizardKey + } if m.activeID() != tabSetup { m.active = tabIndex(tabSetup) m.scrollY = 0 @@ -588,6 +711,7 @@ func (m *model) resumeSetup() tea.Cmd { } func (m *model) cancelLogin() { + m.wizard = wizardOff m.loginStage = loginOff m.loginBusy = false m.loginInput.Blur() @@ -728,7 +852,11 @@ func (m model) View() string { } var b strings.Builder - b.WriteString(renderTabBar(m.active, l) + "\n\n") + if m.wizard == wizardOff { + b.WriteString(renderTabBar(m.active, l) + "\n\n") + } else { + b.WriteString("\n") + } // Content area height: total minus tab-bar, blank, blank-before-footer, footer contentH := l.h - 4 @@ -736,15 +864,25 @@ func (m model) View() string { contentH = 1 } - // Drawn over whatever tab is showing, because `s` works from all of them. - if m.loginStage != loginOff { - body := strings.Split(strings.TrimRight(m.renderLogin(l), "\n"), "\n") + // The guided setup owns the screen: no tab bar to wander off into, and the + // banner at the top so the thing you just opened says what it is. + if m.wizard != wizardOff { + hint := "enter to continue esc to leave setup" + switch m.wizard { + case wizardTools: + hint = "↑/↓ pick space toggle c configure esc to leave setup" + case wizardDone: + hint = "esc or enter to open the panel" + } + body := strings.Split(strings.TrimRight(m.renderWizard(l), "\n"), "\n") for len(body) < contentH { body = append(body, "") } - b.WriteString(strings.Join(body[:contentH], "\n")) - b.WriteString("\n" + lipgloss.NewStyle().Foreground(cGray). - Render(l.indent+"enter to continue esc to cancel")) + if len(body) > contentH { + body = body[:contentH] + } + b.WriteString(strings.Join(body, "\n")) + b.WriteString("\n" + lipgloss.NewStyle().Foreground(cGray).Render(l.indent+hint)) return b.String() } @@ -2296,6 +2434,86 @@ func removeOpencodeConfig(cfgPath string) error { } // The sign-in questions, drawn where the tab content would be. +// The guided setup: the banner, the four steps with the one you are on marked, +// and whatever that step needs underneath. +// +// It replaced a sign-in screen that showed "Step 1 of 2" and then handed you +// back to the panel to find the other two yourself. Four numbered lines cost +// almost nothing and answer "how much is left", which is the question someone +// halfway through a setup is actually holding. +func (m model) renderWizard(l layout) string { + title := lipgloss.NewStyle().Bold(true).Foreground(cWhite) + dim := lipgloss.NewStyle().Foreground(cDimGray) + done := lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")) + now := lipgloss.NewStyle().Foreground(cCyan).Bold(true) + + var b strings.Builder + if l.w >= BannerWidthPlain+4 { + b.WriteString(Banner(l.indent, m.wizard.mood(), + l.w >= BannerWidth+4 && l.h >= BannerRoom) + "\n") + } + + b.WriteString(l.indent + title.Render("Setting up") + "\n\n") + + at := m.wizard.index() + for i, step := range wizardSteps { + marker, style := dim.Render(" "), dim + switch { + case i < at || m.wizard == wizardDone: + marker, style = done.Render("✓ "), dim + case i == at: + marker, style = now.Render("▶ "), now + } + b.WriteString(l.indent + marker + + style.Render(fmt.Sprintf("%d. %s", step.n, step.title)) + "\n") + } + b.WriteString("\n") + + switch m.wizard { + case wizardEmail, wizardLink: + b.WriteString(l.indent + m.loginInput.View() + "\n") + if m.loginMsg != "" { + b.WriteString("\n" + m.wrapped(l, m.loginMsg) + "\n") + } + case wizardKey: + if m.editingKey { + b.WriteString(l.indent + m.keyInput.View() + "\n") + } + if m.keyCheck != "" { + b.WriteString("\n" + m.wrapped(l, m.keyCheck) + "\n") + } + case wizardTools, wizardDone: + if m.keyCheck != "" { + b.WriteString(l.indent + m.wrapped(l, m.keyCheck) + "\n\n") + } + tools := detectTools() + cursor := m.setupCursor + if len(tools) > 0 && cursor >= len(tools) { + cursor = len(tools) - 1 + } + b.WriteString(m.renderToolList(l, tools, cursor)) + if m.configuring { + b.WriteString("\n" + l.indent + m.spin.View() + + dim.Render(" writing the configs - a few seconds") + "\n") + } else if m.setupMsg != "" { + b.WriteString("\n" + m.wrapped(l, m.setupMsg) + "\n") + } + } + return b.String() +} + +// A message wrapped to the panel, coloured by whether it is one. A sign-in +// link is longer than any terminal. +func (m model) wrapped(l layout, msg string) string { + style := lipgloss.NewStyle().Foreground(cCyan) + if strings.HasPrefix(msg, "error") { + style = lipgloss.NewStyle().Foreground(cRed) + } + return indentBlock(lipgloss.NewStyle(). + Width(l.w-lipgloss.Width(l.indent)-1). + Render(style.Render(msg)), l.indent) +} + func (m model) renderLogin(l layout) string { title := lipgloss.NewStyle().Bold(true).Foreground(cWhite) dim := lipgloss.NewStyle().Foreground(cGray) @@ -2352,6 +2570,61 @@ func lpadTo(v string, w int) string { return v + strings.Repeat(" ", w-len(v)) } +// renderToolList draws the tools and their state. Pulled out of the Setup +// tab because the guided setup shows the same list as its last step, and two +// copies of a list with a cursor in it would drift the first time either moved. +func (m model) renderToolList(l layout, tools []toolInfo, cursor int) string { + dimStyle := lipgloss.NewStyle().Foreground(cDimGray) + okStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")) + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")) + var b strings.Builder + nameW := 14 + for _, t := range tools { + if len(t.name) > nameW { + nameW = len(t.name) + } + } + + checkStyle := lipgloss.NewStyle().Foreground(cCyan) + cursorStyle := lipgloss.NewStyle().Foreground(cCyan).Bold(true) + + for i, t := range tools { + isCursor := i == cursor + enabled := m.toolEnabled(t.name) + + cur := " " + if isCursor { + cur = cursorStyle.Render("▶") + " " + } + + var check string + if !t.installed { + check = dimStyle.Render("[ ]") + } else if enabled { + check = checkStyle.Render("[✓]") + } else { + check = dimStyle.Render("[ ]") + } + + nameStyle := lipgloss.NewStyle().Foreground(cText).Width(nameW) + if isCursor { + nameStyle = nameStyle.Foreground(cWhite) + } + + var status string + if !t.installed { + status = dimStyle.Render("not installed") + } else if t.configured { + status = okStyle.Render("✓ configured with NaN") + } else { + status = warnStyle.Render("○ not configured") + } + + b.WriteString(l.indent + cur + check + " " + nameStyle.Render(t.name) + " " + status + "\n") + } + return b.String() +} + func (m model) renderSetup(l layout) string { var b strings.Builder @@ -2447,50 +2720,7 @@ func (m model) renderSetup(l layout) string { cursor = len(tools) - 1 } - nameW := 14 - for _, t := range tools { - if len(t.name) > nameW { - nameW = len(t.name) - } - } - - checkStyle := lipgloss.NewStyle().Foreground(cCyan) - cursorStyle := lipgloss.NewStyle().Foreground(cCyan).Bold(true) - - for i, t := range tools { - isCursor := i == cursor - enabled := m.toolEnabled(t.name) - - cur := " " - if isCursor { - cur = cursorStyle.Render("▶") + " " - } - - var check string - if !t.installed { - check = dimStyle.Render("[ ]") - } else if enabled { - check = checkStyle.Render("[✓]") - } else { - check = dimStyle.Render("[ ]") - } - - nameStyle := lipgloss.NewStyle().Foreground(cText).Width(nameW) - if isCursor { - nameStyle = nameStyle.Foreground(cWhite) - } - - var status string - if !t.installed { - status = dimStyle.Render("not installed") - } else if t.configured { - status = okStyle.Render("✓ configured with NaN") - } else { - status = warnStyle.Render("○ not configured") - } - - b.WriteString(l.indent + cur + check + " " + nameStyle.Render(t.name) + " " + status + "\n") - } + b.WriteString(m.renderToolList(l, tools, cursor)) b.WriteString("\n") if m.sess.APIKey == "" { @@ -2504,7 +2734,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.15" +const Version = "0.1.16" func renderAbout(l layout) string { var b strings.Builder @@ -2559,6 +2789,7 @@ func renderHelp() string { {"e", "edit API key (Setup tab)"}, {"space", "tick or untick the tool under the cursor (Setup tab)"}, {"c", "configure the ticked tools (Setup tab)"}, + {"o", "sign out, twice to confirm"}, {"?", "toggle this help"}, {"q / Esc", "quit"}, } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 23e7416..c3c4abe 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -118,7 +118,7 @@ function Get-LatestVersion { could not work out the latest version from the GitHub API it rate limits unauthenticated requests, so this is usually temporary wait a few minutes, or pick a version yourself: - & ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -Version v0.1.15 + & ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -Version v0.1.16 the releases are at https://github.com/$Repo/releases "@ } diff --git a/scripts/install.sh b/scripts/install.sh index 4d4355d..b62018b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -76,7 +76,7 @@ require_version() { err "could not work out the latest version from the GitHub API" err "it rate limits unauthenticated requests, so this is usually temporary" err "wait a few minutes, or pick a version yourself:" - printf " VERSION=v0.1.15 curl -fsSL https://nan.builders/install | bash + printf " VERSION=v0.1.16 curl -fsSL https://nan.builders/install | bash " >&2 err "the releases are at https://github.com/$REPO/releases" exit 1