diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80c6429..108e65a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,10 @@ name: release # where ${version} is the tag verbatim, `v` included, because the script reads # it from the release's `tag_name`. Renaming these breaks the installer # silently: the release looks fine on GitHub and the one-liner 404s. +# +# The Windows archives are .zip and sit outside that contract: install.sh is +# bash and never asks for them. They are published so that installing on +# Windows stops meaning `go build`. on: push: @@ -64,17 +68,28 @@ jobs: run: | set -euo pipefail mkdir -p dist - for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do os="${target%/*}" arch="${target#*/}" + binary="nan" + if [ "$os" = "windows" ]; then + binary="nan.exe" + fi # -trimpath keeps build paths out of the binary; -s -w drops the # symbol and DWARF tables, which is most of the size. CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \ - go build -trimpath -ldflags "-s -w" -o dist/nan . - tar -czf "dist/nan-cli_${VERSION}_${os}_${arch}.tar.gz" -C dist nan - rm dist/nan + go build -trimpath -ldflags "-s -w" -o "dist/$binary" . + # Windows gets a .zip, not a .tar.gz: nothing on a stock Windows + # unpacks a tarball by double-clicking, and scripts/install.sh is + # bash, so nobody reaches these through the one-liner anyway. + if [ "$os" = "windows" ]; then + (cd dist && zip -q "nan-cli_${VERSION}_${os}_${arch}.zip" "$binary") + else + tar -czf "dist/nan-cli_${VERSION}_${os}_${arch}.tar.gz" -C dist "$binary" + fi + rm "dist/$binary" done - cd dist && sha256sum *.tar.gz > checksums.txt + cd dist && sha256sum *.tar.gz *.zip > checksums.txt cat checksums.txt # Installing what we are about to publish, the same way a member would, @@ -86,6 +101,19 @@ jobs: /tmp/nan --version /tmp/nan --help > /dev/null + # The Windows binaries cannot be run here, but an archive that unpacks + # to nothing, or to a name Windows will not execute, can still be caught. + - name: Check the Windows archives carry an .exe + run: | + set -euo pipefail + for arch in amd64 arm64; do + zip="dist/nan-cli_${{ steps.tag.outputs.value }}_windows_${arch}.zip" + unzip -l "$zip" | grep -q "nan.exe" || { + echo "$zip does not contain nan.exe" >&2 + exit 1 + } + done + - name: Publish the release env: GH_TOKEN: ${{ github.token }} @@ -93,4 +121,4 @@ jobs: gh release create "${{ steps.tag.outputs.value }}" \ --title "${{ steps.tag.outputs.value }}" \ --generate-notes \ - dist/*.tar.gz dist/checksums.txt + dist/*.tar.gz dist/*.zip dist/checksums.txt diff --git a/README.md b/README.md index b97e4db..ef82947 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,21 @@ By default installs to `/usr/local/bin`. Override with `INSTALL_DIR`: INSTALL_DIR=~/.local/bin curl -fsSL https://nan.builders/install | bash ``` +On Windows, from PowerShell: + +```powershell +irm https://nan.builders/install.ps1 | iex +``` + +Same work: latest release, the `.zip` for your architecture, checksum verified, +`nan.exe` into `%LOCALAPPDATA%\Programs +an` and that directory added to your +user `PATH`. Override with `-InstallDir`: + +```powershell +& ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -InstallDir "C: ools" +``` + ## Usage Run `nan` to open the TUI dashboard: @@ -41,11 +56,18 @@ The Setup tab lets you configure AI coding tools to use the NaN API automaticall - [OpenCode](https://opencode.ai) - [Factory AI](https://factory.ai) (`droid`) -- [Pi](https://pi.ai) +- [Pi](https://pi.dev) - [Codex](https://github.com/openai/codex) +- [Hermes](https://hermes-agent.nousresearch.com/) Press `e` to set your API key, `space` to toggle tools, and `c` to apply the configuration. +Only installed tools are listed, and only the NaN part of each config is +touched: everything else in those files is left as it was, and unticking a +tool takes ours back out. The key you paste is checked against the cluster +before you apply it, so a mistyped one is caught here rather than as a 401 +inside each tool later. + ## Build from source Requires Go 1.26+ ([mise](https://mise.jdx.dev/) recommended): diff --git a/internal/api/client.go b/internal/api/client.go index 2181aeb..b007933 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -12,14 +12,21 @@ const BaseURL = "https://cloud-api.nan.builders/api" type Client struct { token string http *http.Client + // Defaults to BaseURL. A field rather than the constant so the tests can + // point the client at a server of their own. + baseURL string } func New(token string) *Client { - return &Client{token: token, http: &http.Client{}} + return &Client{token: token, http: &http.Client{}, baseURL: BaseURL} } func (c *Client) get(path string) ([]byte, error) { - req, err := http.NewRequest(http.MethodGet, BaseURL+path, nil) + base := c.baseURL + if base == "" { + base = BaseURL + } + req, err := http.NewRequest(http.MethodGet, base+path, nil) if err != nil { return nil, err } @@ -113,6 +120,31 @@ func ListModels(apiKey string) ([]string, error) { return ids, nil } +// KeyStatus is what the platform will say about a member's API key. Note what +// is not in it: the key. GET /api/keys answers with metadata only - the secret +// is handed over once, when it is created, and never again - so the Setup tab +// cannot fetch a key on a member's behalf, only tell them whether they have +// one and where it lives. +type KeyStatus struct { + Exists bool `json:"exists"` + Alias string `json:"keyAlias"` + Name string `json:"keyName"` + Region string `json:"region"` + Synced bool `json:"secretSynced"` +} + +func (c *Client) GetKeyStatus() (*KeyStatus, error) { + body, err := c.get("/keys") + if err != nil { + return nil, err + } + var status KeyStatus + if err := json.Unmarshal(body, &status); err != nil { + return nil, err + } + return &status, nil +} + func (c *Client) GetAgentsModels() (any, error) { body, err := c.get("/agents/models") if err != nil { diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..16b9f35 --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,56 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// The platform answers /keys with metadata and no secret in it: a key is +// handed over once, when it is created, and never again. That is the whole +// reason the Setup tab asks a member to paste theirs instead of fetching it, +// and this pins the shape so the assumption is checked rather than remembered. +func TestKeyStatusIsMetadataAndCarriesNoKey(t *testing.T) { + var gotPath, gotCookie string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotCookie = r.Header.Get("Cookie") + _, _ = w.Write([]byte(`{"exists":true,"keyAlias":"an-alias","keyName":"sk-name","region":"EU","secretSynced":true}`)) + })) + defer srv.Close() + + c := &Client{token: "session-token", http: srv.Client(), baseURL: srv.URL} + status, err := c.GetKeyStatus() + if err != nil { + t.Fatal(err) + } + if gotPath != "/keys" { + t.Errorf("asked for %q, want /keys", gotPath) + } + if !strings.Contains(gotCookie, "nan_session=session-token") { + t.Errorf("cookie %q does not carry the session", gotCookie) + } + if !status.Exists || status.Alias != "an-alias" || status.Region != "EU" || !status.Synced { + t.Errorf("status parsed wrong: %+v", status) + } +} + +// An account with no key at all is the case the Setup tab has to say +// something useful about, so it must survive the parse rather than read as +// "yes" by accident. +func TestKeyStatusSaysWhenThereIsNoKey(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"exists":false}`)) + })) + defer srv.Close() + + c := &Client{token: "t", http: srv.Client(), baseURL: srv.URL} + status, err := c.GetKeyStatus() + if err != nil { + t.Fatal(err) + } + if status.Exists { + t.Error("an account with no key reads as having one") + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 909a168..04ab16b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -23,6 +23,18 @@ func dir() (string, error) { return filepath.Join(home, ".config", "nan"), nil } +// Path is where the session actually lives, for the About tab to show. It was +// a "~/.config/nan/session.json" typed into the renderer, which is not a path +// on Windows and is not where anything is: a member told to look there finds +// nothing, and the tilde is not something Explorer resolves. +func Path() string { + d, err := dir() + if err != nil { + return filepath.Join(".config", "nan", "session.json") + } + return filepath.Join(d, "session.json") +} + func Load() (*Session, error) { d, err := dir() if err != nil { diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index 5da8481..434071c 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -3,12 +3,16 @@ package tui import ( "encoding/json" "os" + "os/exec" "path/filepath" + "runtime" "strings" "testing" + "time" "github.com/charmbracelet/lipgloss" + "github.com/nxssie/nan-cli/internal/api" catalog "github.com/nxssie/nan-cli/internal/models" "github.com/nxssie/nan-cli/internal/session" ) @@ -387,9 +391,20 @@ func TestConfigureToolsWritesEveryEnabledTool(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) // os.UserHomeDir reads this one on Windows + // Hermes does not take its home from either of those, so without this the + // run reaches the real install and configures the machine it is testing + // on. Faking the runner as well means no hermes process is spawned at all, + // here or on a machine that has one. + hermesHomeDir := filepath.Join(home, "hermes") + t.Setenv("HERMES_HOME", hermesHomeDir) + if err := os.MkdirAll(hermesHomeDir, 0o700); err != nil { + t.Fatal(err) + } + hermesCalls := recordHermes(t) // A tool counts as installed if its binary is on PATH *or* its config path - // exists, so an empty file each is enough to make all four visible. + // exists, so an empty file each is enough to make the file-written ones + // visible. paths := map[string]string{ "Factory AI": filepath.Join(home, ".factory", "settings.json"), "OpenCode": filepath.Join(home, ".config", "opencode", "opencode.json"), @@ -405,8 +420,11 @@ func TestConfigureToolsWritesEveryEnabledTool(t *testing.T) { } } - if msg := configureTools(testKey, nil); !strings.Contains(msg, "4 added") { - t.Fatalf("configureTools said %q, want the four tools written", msg) + if msg := configureTools(testKey, nil); !strings.Contains(msg, "5 added") { + t.Fatalf("configureTools said %q, want the five tools written", msg) + } + if len(*hermesCalls) == 0 { + t.Error("Hermes was counted but never configured") } for name, p := range paths { @@ -562,3 +580,430 @@ func TestHomeSaysHowToMoveAround(t *testing.T) { t.Errorf("Home is %d rows, the viewport of a 24-row terminal is %d", rows, 24-4) } } + +// The provider block alone does not connect Pi to anything. Pi reads its +// default from a second file, settings.json, and nan.builders/docs/pi marks +// that step "not optional": without it Pi keeps calling its factory provider +// and the member gets a 401 that names neither file. The CLI wrote the first +// file and not the second, so enabling Pi from the Setup tab landed a member +// squarely in the failure the docs call the most common one. +func TestPiConfigWritesTheDefaultsOrPiStillAnswers401(t *testing.T) { + path := tempConfig(t, "models.json") + if err := writePiConfig(path, testKey); err != nil { + t.Fatal(err) + } + + settings := readJSON(t, filepath.Join(filepath.Dir(path), "settings.json")) + if got := settings["defaultProvider"]; got != "nan" { + t.Errorf("defaultProvider = %v, want nan: Pi calls its factory provider otherwise", got) + } + if got := settings["defaultModel"]; got != catalog.Coding { + t.Errorf("defaultModel = %v, want %s", got, catalog.Coding) + } +} + +// settings.json is Pi's, not ours: the default is the only key in it we have +// any business writing. +func TestPiConfigKeepsTheSettingsItDoesNotOwn(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "models.json") + settingsPath := filepath.Join(dir, "settings.json") + existing := `{"theme":"dark","defaultProvider":"openai","defaultModel":"gpt-5"}` + if err := os.WriteFile(settingsPath, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + if err := writePiConfig(path, testKey); err != nil { + t.Fatal(err) + } + + settings := readJSON(t, settingsPath) + if settings["theme"] != "dark" { + t.Error("a setting of theirs was dropped") + } + // A member who picked another provider picked it. Ours is one more + // provider in the file, and they can switch to it inside Pi. + if settings["defaultProvider"] != "openai" { + t.Error("a default the member chose was overwritten") + } +} + +// Turning Pi off in the Setup tab has to leave Pi working, and a default +// pointing at a provider that is no longer in models.json is not working. +func TestPiRemovalTakesTheDefaultItWrote(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "models.json") + settingsPath := filepath.Join(dir, "settings.json") + if err := writePiConfig(path, testKey); err != nil { + t.Fatal(err) + } + if err := removePiConfig(path); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(settingsPath); !os.IsNotExist(err) { + settings := readJSON(t, settingsPath) + if settings["defaultProvider"] == "nan" { + t.Error("Pi is left defaulting to a provider that is no longer in models.json") + } + } +} + +// ── Hermes ─────────────────────────────────────────────────────────────────── + +// Hermes is the first tool here that is not configured by writing its file. +// Its config.yaml is a commented document a member edits, and it ships +// `hermes config set`, which writes into it without flattening the comments +// and validates the key while it is at it. So this writer drives the tool +// instead of reproducing its schema, and what there is to test is the +// conversation it has with it. +func recordHermes(t *testing.T) *[][]string { + t.Helper() + var calls [][]string + original := runHermesConfig + runHermesConfig = func(home string, args ...string) error { + calls = append(calls, args) + return nil + } + t.Cleanup(func() { runHermesConfig = original }) + return &calls +} + +func TestHermesIsConfiguredThroughItsOwnConfigCommand(t *testing.T) { + calls := recordHermes(t) + if err := writeHermesConfig(t.TempDir(), testKey); err != nil { + t.Fatal(err) + } + + want := map[string]string{ + // `custom` is the provider Hermes ships for any OpenAI-compatible + // endpoint. Its aliases (ollama, vllm, llamacpp) all map to this one. + "model.provider": "custom", + "model.base_url": "https://api.nan.builders/v1", + "model.api_key": testKey, + "model.default": catalog.Coding, + } + got := map[string]string{} + for _, c := range *calls { + if len(c) != 3 || c[0] != "set" { + t.Errorf("unexpected call %v", c) + continue + } + got[c[1]] = c[2] + } + for key, value := range want { + if got[key] != value { + t.Errorf("%s = %q, want %q", key, got[key], value) + } + } +} + +// Hermes with a custom endpoint asks the cluster what it serves instead of +// reading a list we write, so there is no model catalogue in this config and +// no window to keep in step - the one thing it needs told is which model to +// open with. +func TestHermesIsNotSentAModelCatalogue(t *testing.T) { + calls := recordHermes(t) + if err := writeHermesConfig(t.TempDir(), testKey); err != nil { + t.Fatal(err) + } + for _, c := range *calls { + for _, arg := range c { + if strings.Contains(arg, "models") { + t.Errorf("call %v writes a model list Hermes discovers on its own", c) + } + } + } +} + +func TestHermesRemovalTakesOnlyWhatWeWrote(t *testing.T) { + calls := recordHermes(t) + if err := removeHermesConfig(t.TempDir()); err != nil { + t.Fatal(err) + } + for _, c := range *calls { + if c[0] != "unset" { + t.Errorf("removal called %v, which is not an unset", c) + } + // The member's own settings live in the same file: their skills, their + // channels, their persona. Only the four keys we put there come out. + switch c[1] { + case "model.provider", "model.base_url", "model.api_key", "model.default": + default: + t.Errorf("removal unsets %q, which we never wrote", c[1]) + } + } +} + +// The path in nan.builders/docs/hermes, ~/.hermes/config.yaml, is the Unix +// one. On Windows Hermes keeps it under LOCALAPPDATA, so a CLI that built the +// path from the home directory would configure a Hermes that is not there. +func TestHermesHomeFollowsTheToolNotTheDoc(t *testing.T) { + t.Setenv("HERMES_HOME", filepath.Join("some", "profile")) + if got := hermesHome(); got != filepath.Join("some", "profile") { + t.Errorf("HERMES_HOME ignored: got %q", got) + } + + t.Setenv("HERMES_HOME", "") + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + local := filepath.Join(home, "AppData", "Local") + t.Setenv("LOCALAPPDATA", local) + + want := filepath.Join(home, ".hermes") + if runtime.GOOS == "windows" { + want = filepath.Join(local, "hermes") + } + if got := hermesHome(); got != want { + t.Errorf("hermesHome() = %q, want %q", got, want) + } +} + +// Everything above agrees with a fake. This one agrees with Hermes, which is +// the only agreement that keeps a member working, and it is why the writer +// was built around `hermes config set` in the first place. Skipped where +// Hermes is not installed, CI included. +func TestHermesConfigAgainstTheRealBinary(t *testing.T) { + if _, err := exec.LookPath("hermes"); err != nil { + t.Skip("hermes is not installed here") + } + // Never the member's own Hermes: HERMES_HOME is what the writer passes to + // every call, so the whole exchange lands in a directory of this test's. + home := t.TempDir() + if err := writeHermesConfig(home, testKey); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(filepath.Join(home, "config.yaml")) + if err != nil { + t.Fatalf("hermes wrote no config: %v", err) + } + written := string(data) + for _, want := range []string{"provider: custom", "base_url: https://api.nan.builders/v1", "default: " + catalog.Coding, testKey} { + if !strings.Contains(written, want) { + t.Errorf("config.yaml has no %q:\n%s", want, written) + } + } + + if err := removeHermesConfig(home); err != nil { + t.Fatal(err) + } + data, _ = os.ReadFile(filepath.Join(home, "config.yaml")) + if strings.Contains(string(data), "api.nan.builders") { + t.Errorf("removal left the cluster behind:\n%s", data) + } +} + +// ── the API key, and what the platform will and will not tell us ───────────── + +func setupModel(t *testing.T, sess *session.Session) model { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("HERMES_HOME", filepath.Join(home, "hermes")) + return newModel(nil, sess) +} + +// The idea this replaced was to have the Setup tab fetch the key with the +// session it already holds. It cannot: GET /api/keys answers with metadata +// and no secret, because a key is shown once, at creation. What is left worth +// doing is telling a member there is one and where it is, instead of showing +// an empty field and nothing else. +func TestSetupSaysWhereTheKeyIsWhenTheFieldIsEmpty(t *testing.T) { + m := setupModel(t, &session.Session{}) + m.keyStatus = &api.KeyStatus{Exists: true, Alias: "an-alias"} + + out := m.renderSetup(newLayout(80, 24)) + for _, want := range []string{"an-alias", "cloud.nan.builders"} { + if !strings.Contains(out, want) { + t.Errorf("the Setup tab does not mention %q", want) + } + } +} + +func TestSetupSaysWhenTheAccountHasNoKeyAtAll(t *testing.T) { + m := setupModel(t, &session.Session{}) + m.keyStatus = &api.KeyStatus{Exists: false} + + out := m.renderSetup(newLayout(80, 24)) + if !strings.Contains(out, "no key yet") { + t.Error("an account with no key is told nothing about it") + } +} + +// Once there is a key in the field the hint is noise, and the key itself is +// never printed. +func TestSetupHidesTheHintAndTheKeyOnceOneIsSet(t *testing.T) { + m := setupModel(t, &session.Session{APIKey: testKey}) + m.keyStatus = &api.KeyStatus{Exists: true, Alias: "an-alias"} + + out := m.renderSetup(newLayout(80, 24)) + if strings.Contains(out, "an-alias") || strings.Contains(out, "cloud.nan.builders") { + t.Error("the hint is still shown after a key was set") + } + if strings.Contains(out, testKey) { + t.Error("the API key is printed on screen") + } +} + +// A key the cluster refuses used to be found out five times over, as a 401 +// inside each tool it had been written into. It is said once, here, before +// anything is written at all. +func TestSetupShowsAKeyTheClusterRefused(t *testing.T) { + m := setupModel(t, &session.Session{APIKey: testKey}) + m.keyCheck = "error: the cluster refused this key - the API key in Setup is not valid" + + out := m.renderSetup(newLayout(80, 24)) + if !strings.Contains(out, "refused this key") { + t.Error("a refused key is not reported in the Setup tab") + } +} + +// The Home tab tells a member that `?` lists "every shortcut, including the +// ones for Setup". It listed e and c and not space, which is the one that +// decides which tools get written at all. +func TestHelpListsTheKeysHomePromises(t *testing.T) { + out := renderHelp() + for _, want := range []string{"space", "e", "c"} { + if !strings.Contains(out, want) { + t.Errorf("the help screen does not mention %q", want) + } + } +} + +// ── the cost comparison ────────────────────────────────────────────────────── + +// The Costs tab exists to answer "what would this have cost me elsewhere". +// Every number in it is typed in by hand from a vendor's pricing page, nothing +// reads them back, and the table went a long time with five of six rows wrong +// - each of them understating the competitor. These are the mechanical checks +// that catch the shapes of wrong a reader would not notice. +func TestPricingTableIsPlausible(t *testing.T) { + if len(pricingTable) == 0 { + t.Fatal("nothing to compare against") + } + seen := map[string]bool{} + for _, p := range pricingTable { + if p.inPer1M <= 0 || p.outPer1M <= 0 { + t.Errorf("%s: a free model is a typo, not a price (%v/%v)", p.model, p.inPer1M, p.outPer1M) + } + // Every frontier vendor charges more for output than for input, so a + // row where that flips is a transposed pair. None of the six wrong + // rows failed this way - they were each plausible and simply not what + // the vendor charged - which is the point: this catches the typo, and + // only reading the pricing page catches the rest. + if p.outPer1M < p.inPer1M { + t.Errorf("%s: output (%v) cheaper than input (%v) - transposed?", p.model, p.outPer1M, p.inPer1M) + } + if seen[p.model] { + t.Errorf("%s is listed twice", p.model) + } + seen[p.model] = true + if p.provider == "" { + t.Errorf("%s has no provider, so it renders with no colour and no attribution", p.model) + } + } +} + +// Every provider in the table needs a colour, or it renders grey and looks +// like a different kind of row. +func TestEveryPricedProviderHasAColour(t *testing.T) { + for _, p := range pricingTable { + if _, ok := providerColor[p.provider]; !ok { + t.Errorf("%s has no colour in providerColor", p.provider) + } + } +} + +// Gemini 3.8 Flash is on a promotional rate that doubles on 2027-01-01. That +// is a number which is right today and silently wrong on a date we already +// know, which no amount of care at review time catches. This is the only +// thing that will. +func TestGeminiFlashPromoHasNotExpired(t *testing.T) { + if time.Now().Before(geminiFlashPromoEnds) { + return + } + t.Errorf("Gemini 3.8 Flash's promotional rate ended on %s: "+ + "its price in pricingTable doubles to 1.50/7.50, and the Costs tab has been "+ + "understating Google ever since. Update the row and move geminiFlashPromoEnds "+ + "or drop it if the row no longer needs one.", geminiFlashPromoEnds.Format("2006-01-02")) +} + +// The footer box was drawn at the width of its own text, 70 columns plus a +// border and the indent, whatever terminal it was in. Anything narrower than +// about 74 got a box running off the right-hand side - which is where this tab +// is read on half a laptop screen. +func TestCostsFitsTheTerminalItIsDrawnIn(t *testing.T) { + usage := map[string]any{ + "last24h": map[string]any{"byModel": []any{ + map[string]any{"model": "gemma4", "inputTokens": 1_000_000.0, "outputTokens": 500_000.0}, + }}, + } + for _, w := range []int{40, 50, 60, 72, 80, 120} { + for _, line := range strings.Split(renderCosts(usage, newLayout(w, 40)), "\n") { + if got := lipgloss.Width(line); got > w { + t.Errorf("at %d columns a line measures %d: %q", w, got, line) + } + } + } +} + +// Ten rows with a blank line between each is not a table any more, it is two +// screens of alternating text and gap. The blank lines that are left group the +// rows by provider, which is the only thing they were ever doing well. +func TestCostsRowsAreNotDoubleSpaced(t *testing.T) { + usage := map[string]any{ + "last24h": map[string]any{"byModel": []any{ + map[string]any{"model": "gemma4", "inputTokens": 1_000_000.0, "outputTokens": 500_000.0}, + }}, + } + out := renderCosts(usage, newLayout(100, 40)) + lines := strings.Split(out, "\n") + + // Find each priced row, then check the one after it is only blank when the + // provider changes. + index := map[string]int{} + for i, line := range lines { + for _, p := range pricingTable { + if strings.Contains(line, p.model) { + index[p.model] = i + } + } + } + for i := 0; i < len(pricingTable)-1; i++ { + this, next := pricingTable[i], pricingTable[i+1] + at, ok := index[this.model] + if !ok { + t.Errorf("%s is priced but never rendered", this.model) + continue + } + gap := strings.TrimSpace(lines[at+1]) == "" + if want := this.provider != next.provider; gap != want { + if want { + t.Errorf("no blank line between %s and %s, which are different providers", this.provider, next.provider) + } else { + t.Errorf("a blank line inside %s's rows, after %s", this.provider, this.model) + } + } + } +} + +// The About tab printed "~/.config/nan/session.json" as a literal. On Windows +// that is not a path, not where the file is, and not something Explorer +// resolves - so a member told to look there finds nothing. +func TestAboutShowsTheSessionPathThatExists(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + out := renderAbout(newLayout(100, 30)) + if strings.Contains(out, "~/") { + t.Error("the About tab still prints a tilde path") + } + if want := session.Path(); !strings.Contains(out, want) { + t.Errorf("the About tab does not show %q", want) + } + if !strings.Contains(session.Path(), home) { + t.Errorf("session.Path() = %q, which is not under the home it was given", session.Path()) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 8d4859b..c124cf1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -7,8 +7,10 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sort" "strings" + "time" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" @@ -111,6 +113,15 @@ type fetchedMsg struct { } type fetchErrMsg struct{ err error } +// The Setup tab never blocks on the network: it is the one tab a member can +// use with no connection, and the two things below are extra information +// rather than its content. They arrive when they arrive. +type keyStatusMsg struct{ status *api.KeyStatus } +type keyCheckedMsg struct { + models int + err error +} + // ── model ───────────────────────────────────────────────────────────────────── type model struct { @@ -128,6 +139,9 @@ type model struct { editingKey bool setupMsg string setupCursor int + keyStatus *api.KeyStatus + keyAsked bool + keyCheck string } func newModel(client *api.Client, sess *session.Session) model { @@ -182,22 +196,40 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = false m.err = msg.err + case keyStatusMsg: + m.keyStatus = msg.status + + case keyCheckedMsg: + // A key that the cluster refuses is worth saying once, here, rather + // than five times later as a 401 inside five different tools. + if msg.err != nil { + m.keyCheck = "error: the cluster refused this key — " + msg.err.Error() + } else { + m.keyCheck = fmt.Sprintf("key accepted by the cluster · %d models", msg.models) + } + case tea.KeyMsg: // When the API key input is active, route all keys to it if m.editingKey { switch msg.String() { case "enter": val := strings.TrimSpace(m.keyInput.Value()) + var check tea.Cmd if val != "" { m.sess.APIKey = val if err := session.Save(m.sess); err != nil { m.setupMsg = "error saving: " + err.Error() } else { m.setupMsg = "API key saved" + m.keyCheck = "checking it against the cluster…" + check = checkKey(val) } } m.editingKey = false m.keyInput.Blur() + if check != nil { + return m, check + } case "esc": m.editingKey = false m.keyInput.Blur() @@ -286,9 +318,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setupMsg = "" } case "c": - if !m.showHelp && m.activeID() == tabSetup && m.sess.APIKey != "" { - msg := configureTools(m.sess.APIKey, m.sess.EnabledTools) - m.setupMsg = msg + if !m.showHelp && m.activeID() == tabSetup { + // Pressing the key that configures everything and having + // nothing happen, with nothing said, is the worst of the + // three possible answers. + if m.sess.APIKey == "" { + m.setupMsg = "error: set your API key first — press e" + } else { + m.setupMsg = configureTools(m.sess.APIKey, m.sess.EnabledTools) + } } } } @@ -297,7 +335,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *model) maybeLoad() tea.Cmd { id := m.activeID() - if id == tabHome || id == tabAbout || id == tabSetup { + // Setup asks the platform one thing, once, and stays usable while it + // waits: no spinner, no error state, nothing that stops a member pasting + // a key on a train. + if id == tabSetup { + // Nothing to ask on a machine that has not logged in: the call would + // come back 401 and be swallowed. + if m.keyAsked || m.sess.Token == "" { + return nil + } + m.keyAsked = true + return m.fetchKeyStatus() + } + if id == tabHome || id == tabAbout { return nil } // Costs tab derives from usage data — load that if needed @@ -317,6 +367,36 @@ func (m *model) maybeLoad() tea.Cmd { return tea.Batch(m.spin.Tick, m.fetchTab(id)) } +// Whether the account has a key, and what it is called. Never the key +// itself: the platform hands that over once, at creation, and will not +// repeat it - so the Setup tab cannot fill the field in for a member, only +// tell them there is one to go and copy. +func (m model) fetchKeyStatus() tea.Cmd { + client := m.client + return func() tea.Msg { + status, err := client.GetKeyStatus() + if err != nil { + // Silent on purpose: this is a hint, and a member with no + // connection still has a Setup tab that works. + return keyStatusMsg{nil} + } + return keyStatusMsg{status} + } +} + +// The one check that catches a mistyped key before it is copied into every +// tool on the machine. /v1/models is the endpoint the key itself opens, so +// a 401 here is exactly the 401 the tools would hit later. +func checkKey(apiKey string) tea.Cmd { + return func() tea.Msg { + ids, err := api.ListModels(apiKey) + if err != nil { + return keyCheckedMsg{err: err} + } + return keyCheckedMsg{models: len(ids)} + } +} + func (m model) fetchTab(id tabID) tea.Cmd { client := m.client apiKey := m.sess.APIKey @@ -763,16 +843,43 @@ type providerPricing struct { outPer1M float64 // $ per 1M output tokens } -// Prices as of mid-2026 (per 1M tokens). +// Per 1M tokens, read off each vendor's own pricing page on 2026-09-14. +// +// Ten rows rather than the six this started as, and the reason is the spread +// rather than the count. The tab multiplies a member's NaN token usage by each +// of these, so a table that carried only mid-range models answered only the +// mid-range question. From Luna at $0.20 in to Astra and Fable at $10, a +// reader can find the row that matches what they would actually have reached +// for instead of taking ours as the comparison. +// +// The six this replaced were five-sixths wrong, and all five in the same +// direction - the output price too low. GPT-5.5 was published at $20 against a +// real $30, Gemini 3.1 Pro at $8 against $12, Gemini 2.5 Flash at $0.35/$1.05 +// against $0.30/$2.50, and "GPT-5.4 Mini" was not a model anyone sells. A tab +// whose whole claim is "this is what you would have paid elsewhere" +// understating every competitor is the one direction it must not be wrong in. +// +// Two rows carry a condition the table cannot express, so each is taken at its +// lowest published rate and the comparison stays conservative: Gemini 3.1 Pro +// costs $4/$18 above a 200k-token prompt, and Gemini 3.8 Flash is on a +// promotional rate that doubles on 2027-01-01. TestGeminiFlashPromoHasNotExpired +// fails on that date so the number is changed rather than forgotten. var pricingTable = []providerPricing{ - {"Claude Sonnet 4.6", "Anthropic", 3.00, 15.00}, + {"Claude Fable 5.1", "Anthropic", 10.00, 50.00}, + {"Claude Opus 5", "Anthropic", 5.00, 25.00}, + {"Claude Sonnet 5", "Anthropic", 2.00, 10.00}, {"Claude Haiku 4.5", "Anthropic", 1.00, 5.00}, - {"GPT-5.5", "OpenAI", 5.00, 20.00}, - {"GPT-5.4 Mini", "OpenAI", 0.40, 1.60}, - {"Gemini 3.1 Pro", "Google", 2.00, 8.00}, - {"Gemini 2.5 Flash", "Google", 0.35, 1.05}, + {"GPT-6 Astra", "OpenAI", 10.00, 50.00}, + {"GPT-5.6 Sol", "OpenAI", 4.00, 20.00}, + {"GPT-5.6 Terra", "OpenAI", 2.00, 12.00}, + {"GPT-5.6 Luna", "OpenAI", 0.20, 1.20}, + {"Gemini 3.1 Pro", "Google", 2.00, 12.00}, + {"Gemini 3.8 Flash", "Google", 0.75, 3.75}, } +// The day Gemini 3.8 Flash stops being half price. +var geminiFlashPromoEnds = time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC) + var providerColor = map[string]lipgloss.TerminalColor{ "Anthropic": lipgloss.Color("#F97316"), "OpenAI": lipgloss.Color("#10B981"), @@ -846,15 +953,25 @@ func renderCosts(usage map[string]any, l layout) string { call string // "$876.11" } + // Below this the provider word is dropped: it is the widest part of the + // label and the least load-bearing, because each provider already has its + // own colour and its own block of rows. Keeping it is what ran the table + // off the side of a split pane. + showProvider := l.w >= 72 + rows := make([]row, len(pricingTable)) for i, p := range pricingTable { pColor, ok := providerColor[p.provider] if !ok { pColor = cGray } - plain := p.model + " " + p.provider - styled := lipgloss.NewStyle().Foreground(cWhite).Bold(true).Render(p.model) + - " " + lipgloss.NewStyle().Foreground(pColor).Render(p.provider) + plain := p.model + styled := lipgloss.NewStyle().Foreground(pColor).Bold(true).Render(p.model) + if showProvider { + plain = p.model + " " + p.provider + styled = lipgloss.NewStyle().Foreground(cWhite).Bold(true).Render(p.model) + + " " + lipgloss.NewStyle().Foreground(pColor).Render(p.provider) + } rows[i] = row{ plainLabel: plain, styledLabel: styled, @@ -911,9 +1028,19 @@ func renderCosts(usage map[string]any, l layout) string { // Header b.WriteString(l.indent + titleStyle.Render("COST COMPARISON") + "\n") - subtitle := "Estimated cost of your usage on other providers." - if l.w >= 72 { - subtitle = "Estimated cost based on your actual input/output tokens on other providers." + // Measured rather than guessed: the long line is 75 columns and was + // switched on from 72 up, so the width that turned it on was also the + // width it ran off. Longest first, and the first one that fits wins. + subtitle := "" + for _, candidate := range []string{ + "Estimated cost based on your actual input/output tokens on other providers.", + "Estimated cost of your usage on other providers.", + "Estimated cost elsewhere.", + } { + subtitle = candidate + if lipgloss.Width(candidate)+lipgloss.Width(l.indent) <= l.w { + break + } } b.WriteString(l.indent + subStyle.Render(subtitle) + "\n\n") @@ -933,8 +1060,17 @@ func renderCosts(usage map[string]any, l layout) string { b.WriteString(l.indent + lipgloss.NewStyle().Foreground(cDimGray). Render(strings.Repeat("─", divW)) + "\n\n") - // Data rows — pad label using plain-text length, then right-align costs - for _, r := range rows { + // Data rows — pad label using plain-text length, then right-align costs. + // + // One line each, with a blank line only where the provider changes. This + // used to put a blank line between every row, which read fine over six and + // stopped being a table at ten: two screens of alternating text and gap, + // impossible to run an eye down. The gaps that are left do some work - + // they group the rows by who charges them. + for i, r := range rows { + if i > 0 && pricingTable[i].provider != pricingTable[i-1].provider { + b.WriteString("\n") + } labelPad := nameW - len(r.plainLabel) if labelPad < 0 { labelPad = 0 @@ -947,21 +1083,32 @@ func renderCosts(usage map[string]any, l layout) string { } else { line += " " + totalStyle.Render(rpad(r.c30, c30W)) } - b.WriteString(l.indent + line + "\n\n") + b.WriteString(l.indent + line + "\n") } + b.WriteString("\n") // Footer — measure plain text width first to avoid border miscalculation const notePlain = "NaN — Your usage is included in your membership. No per-token charges." nanStyle := lipgloss.NewStyle().Foreground(cCyan).Bold(true) note := nanStyle.Render("NaN") + lipgloss.NewStyle().Foreground(cGray).Render(" — Your usage is included in your membership. No per-token charges.") + // Not len(): the em dash is one column and three bytes, so counting bytes + // drew the box two columns wider than its own text. And not the text width + // alone either: at 70 columns of content plus a border and the indent, the + // box ran off the side of any terminal narrower than about 74, which is + // where this tab gets read on half a laptop screen. The text wraps instead. + noteW := lipgloss.Width(notePlain) + if fits := l.w - lipgloss.Width(l.indent) - 4; noteW > fits { + noteW = fits + } + if noteW < 8 { + noteW = 8 + } noteStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(cBlueDim). Padding(0, 1). - // Not len(): the em dash is one column and three bytes, so counting - // bytes drew the box two columns wider than its own text. - Width(lipgloss.Width(notePlain)) + Width(noteW) b.WriteString(indentBlock(noteStyle.Render(note), l.indent) + "\n") return b.String() @@ -1101,6 +1248,16 @@ func detectTools() []toolInfo { binary: "codex", configPath: filepath.Join(home, ".codex", "config.toml"), }, + { + // Not a coding agent like the rest: it lives in the member's + // messaging channels. It is here because connecting it is the same + // two values, and because it is the one tool that tells us where + // its config is instead of making us guess. + name: "Hermes", + binary: "hermes", + configPath: hermesConfigPath(hermesHome()), + installPath: hermesHome(), + }, } for i := range candidates { _, binErr := exec.LookPath(candidates[i].binary) @@ -1199,6 +1356,8 @@ func configureTools(apiKey string, enabledTools map[string]bool) string { err = writePiConfig(t.configPath, apiKey) case "Codex": err = writeCodexConfig(t.configPath, apiKey) + case "Hermes": + err = writeHermesConfig(filepath.Dir(t.configPath), apiKey) } if err != nil { lastErr = err @@ -1216,6 +1375,8 @@ func configureTools(apiKey string, enabledTools map[string]bool) string { err = removePiConfig(t.configPath) case "Codex": err = removeCodexConfig(t.configPath) + case "Hermes": + err = removeHermesConfig(filepath.Dir(t.configPath)) } if err != nil { lastErr = err @@ -1475,7 +1636,47 @@ func writePiConfig(cfgPath, apiKey string) error { if err != nil { return err } - return os.WriteFile(cfgPath, data, 0o600) + if err := os.WriteFile(cfgPath, data, 0o600); err != nil { + return err + } + return writePiDefaults(piSettingsPath(cfgPath)) +} + +// Pi reads the provider it calls from a second file, and until this existed +// the CLI wrote only the first one. nan.builders/docs/pi marks this step "not +// optional" for a reason: with models.json alone Pi goes on calling its +// factory provider, and what the member sees is a 401 that names neither file. +func writePiDefaults(settingsPath string) error { + var settings map[string]any + if data, err := os.ReadFile(settingsPath); err == nil { + _ = json.Unmarshal(data, &settings) + } + if settings == nil { + settings = map[string]any{} + } + + // A member who already picked a default picked it, and ours is one more + // provider they can switch to from inside Pi. This only fills the gap that + // leaves a fresh install calling nothing. + if _, chosen := settings["defaultProvider"]; chosen { + return nil + } + settings["defaultProvider"] = "nan" + settings["defaultModel"] = catalog.Coding + + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil { + return err + } + out, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + return os.WriteFile(settingsPath, out, 0o600) +} + +// settings.json sits next to models.json in Pi's agent directory. +func piSettingsPath(cfgPath string) string { + return filepath.Join(filepath.Dir(cfgPath), "settings.json") } // Pi's schema takes "text" and "image" and nothing else, so mimo-v2.5 goes in @@ -1511,6 +1712,12 @@ func removePiConfig(cfgPath string) error { } delete(providers, "nan") + // A default pointing at a provider that is no longer in models.json is + // worse than no default: Pi starts and fails on the first message. + if err := removePiDefaults(piSettingsPath(cfgPath)); err != nil { + return err + } + // A models.json with nothing left in it is not a config Pi needs to read. if len(providers) == 0 && len(cfg) == 1 { return os.Remove(cfgPath) @@ -1523,6 +1730,34 @@ func removePiConfig(cfgPath string) error { return os.WriteFile(cfgPath, out, 0o600) } +// Only the default we wrote, and only while it still points at us: anything +// the member set themselves is theirs. +func removePiDefaults(settingsPath string) error { + data, err := os.ReadFile(settingsPath) + if err != nil { + return nil + } + var settings map[string]any + if json.Unmarshal(data, &settings) != nil { + return nil + } + if settings["defaultProvider"] != "nan" { + return nil + } + delete(settings, "defaultProvider") + delete(settings, "defaultModel") + + // The file existed only to hold our default, so it goes with it. + if len(settings) == 0 { + return os.Remove(settingsPath) + } + out, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + return os.WriteFile(settingsPath, out, 0o600) +} + func writeCodexConfig(cfgPath, apiKey string) error { data, _ := os.ReadFile(cfgPath) if strings.Contains(string(data), "api.nan.builders") { @@ -1570,6 +1805,81 @@ wire_api = "chat" return os.WriteFile(cfgPath, []byte(content), 0o600) } +// ── Hermes ─────────────────────────────────────────────────────────────────── +// +// Every other tool here is configured by writing its file. Hermes is not, +// because its config.yaml is a long commented document the member also edits +// by hand, and because it ships the command to do it: `hermes config set` +// writes a key without flattening the comments around it and rejects a key it +// does not know. Reproducing that schema in Go would buy nothing and would +// start drifting the day Hermes moves a field. +// +// Two things the tool knows and the docs page does not say. The path in +// nan.builders/docs/hermes is the Unix one; on Windows Hermes keeps its home +// under LOCALAPPDATA. And with the `custom` provider Hermes asks the endpoint +// what it serves, so there is no model list to write here and none to keep in +// step with the cluster - only which model to open with. + +// Swapped in tests, so nothing here spawns a process or goes near the +// member's own Hermes. HERMES_HOME is passed on every call rather than +// inherited: the writer configures the install it detected, not whichever one +// the environment happens to point at. +var runHermesConfig = func(home string, args ...string) error { + cmd := exec.Command("hermes", append([]string{"config"}, args...)...) + cmd.Env = append(os.Environ(), "HERMES_HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("hermes config %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out))) + } + return nil +} + +// The resolution Hermes itself uses: HERMES_HOME, then the platform default. +func hermesHome() string { + if home := strings.TrimSpace(os.Getenv("HERMES_HOME")); home != "" { + return home + } + if runtime.GOOS == "windows" { + if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" { + return filepath.Join(local, "hermes") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, "AppData", "Local", "hermes") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".hermes") +} + +func hermesConfigPath(home string) string { + return filepath.Join(home, "config.yaml") +} + +func writeHermesConfig(home, apiKey string) error { + settings := [][2]string{ + {"model.provider", "custom"}, + {"model.base_url", "https://api.nan.builders/v1"}, + {"model.api_key", apiKey}, + {"model.default", catalog.Coding}, + } + for _, s := range settings { + if err := runHermesConfig(home, "set", s[0], s[1]); err != nil { + return err + } + } + return nil +} + +// The same four keys and nothing else: a member's channels, skills and +// persona live in this file too. +func removeHermesConfig(home string) error { + for _, key := range []string{"model.api_key", "model.base_url", "model.default", "model.provider"} { + if err := runHermesConfig(home, "unset", key); err != nil { + return err + } + } + return nil +} + func removeCodexConfig(cfgPath string) error { data, err := os.ReadFile(cfgPath) if err != nil { @@ -1703,6 +2013,33 @@ func (m model) renderSetup(l layout) string { " " + dimStyle.Render("e to set") + "\n") } + // What the platform says about the account, once it has answered. The key + // itself never comes back from there, so the most this can do is say + // whether there is one to copy and where from - which still beats leaving + // a member staring at an empty field wondering what to paste. + if m.keyStatus != nil && m.sess.APIKey == "" { + hint := "this account has no key yet - create one at cloud.nan.builders" + if m.keyStatus.Exists { + name := m.keyStatus.Alias + if name == "" { + name = m.keyStatus.Name + } + hint = "your account has a key" + if name != "" { + hint += " (" + name + ")" + } + hint += " - copy it from cloud.nan.builders" + } + b.WriteString(l.indent + dimStyle.Render(hint) + "\n") + } + if m.keyCheck != "" { + style := okStyle + if strings.HasPrefix(m.keyCheck, "error") { + style = errStyle + } + b.WriteString(l.indent + style.Render(m.keyCheck) + "\n") + } + // ── Tools ──────────────────────────────────────────────────────────────── b.WriteString("\n" + l.indent + titleStyle.Render("Tools") + "\n") if m.setupMsg != "" { @@ -1777,7 +2114,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.2" +const Version = "0.1.3" func renderAbout(l layout) string { var b strings.Builder @@ -1816,7 +2153,7 @@ func renderAbout(l layout) string { b.WriteString(l.indent + sectionStyle.Render("Session") + "\n\n") b.WriteString(l.indent + labelStyle.Render("Config:") + - dimStyle.Render("~/.config/nan/session.json") + "\n") + dimStyle.Render(session.Path()) + "\n") return b.String() } @@ -1829,7 +2166,8 @@ func renderHelp() string { {"↑/↓ k/j", "scroll"}, {"r", "refresh current tab"}, {"e", "edit API key (Setup tab)"}, - {"c", "configure tools (Setup tab)"}, + {"space", "tick or untick the tool under the cursor (Setup tab)"}, + {"c", "configure the ticked tools (Setup tab)"}, {"?", "toggle this help"}, {"q / Esc", "quit"}, } diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..fe391b5 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,181 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Installs the nan.builders CLI on Windows. + +.DESCRIPTION + The Windows half of scripts/install.sh. It resolves the latest release, + downloads the .zip for this architecture, verifies its checksum against + checksums.txt and puts nan.exe somewhere on the PATH. + + Two things differ from the bash one, both because Windows differs: + + - The artifact is a .zip, not a .tar.gz. Nothing on a stock Windows unpacks + a tarball by double-clicking, and Expand-Archive is built in. + - The default install directory is per-user (LOCALAPPDATA\Programs\nan) and + not a machine-wide one. /usr/local/bin has a sudo prompt that a person + expects; the Windows equivalent is an elevation dialog out of a piped + script, which is worse than installing for one user. Set -InstallDir to + override. + +.PARAMETER Version + A tag such as v0.1.3. Defaults to the latest release. + +.PARAMETER InstallDir + Where to put nan.exe. Defaults to $env:LOCALAPPDATA\Programs\nan. + +.EXAMPLE + irm https://nan.builders/install.ps1 | iex + +.EXAMPLE + & ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -Version v0.1.3 +#> +[CmdletBinding()] +param( + [string]$Version = $env:NAN_VERSION, + [string]$InstallDir = $env:NAN_INSTALL_DIR +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' # Write-Progress is slow over a pipe + +$Repo = 'helmcode/nan-cli' + +function Write-Step($message) { Write-Host "> $message" -ForegroundColor Cyan } +function Write-Done($message) { Write-Host "OK $message" -ForegroundColor Green } +function Write-Warn($message) { Write-Host "! $message" -ForegroundColor Yellow } +function Write-Fail($message) { Write-Host "x $message" -ForegroundColor Red } + +function Get-Arch { + # PROCESSOR_ARCHITECTURE reports the architecture of the *process* under + # WOW64, so a 32-bit PowerShell on an arm64 machine would claim x86. The OS + # architecture is the one that decides which binary runs. + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + switch ($arch) { + 'X64' { return 'amd64' } + 'Arm64' { return 'arm64' } + default { + Write-Fail "unsupported architecture: $arch" + Write-Fail "the releases carry amd64 and arm64: https://github.com/$Repo/releases" + exit 1 + } + } +} + +function Get-LatestVersion { + try { + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -Headers @{ + 'User-Agent' = 'nan-cli-installer' + } + } catch { + $release = $null + } + if ($release -and $release.tag_name) { + return $release.tag_name + } + # The GitHub API rate limits unauthenticated requests per IP, so this fails + # for reasons that have nothing to do with this repo. Saying so beats letting + # an empty version go into a URL and reporting a 404 from it. + Write-Fail 'could not work out the latest version from the GitHub API' + Write-Fail 'it rate limits unauthenticated requests, so this is usually temporary' + Write-Fail 'wait a few minutes, or pick a version yourself:' + Write-Host ' & ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -Version v0.1.3' + Write-Fail "the releases are at https://github.com/$Repo/releases" + exit 1 +} + +function Assert-Checksum($file, $expected) { + if (-not $expected) { + Write-Fail 'checksums.txt carries no entry for this archive' + exit 1 + } + $actual = (Get-FileHash -Path $file -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected.ToLower()) { + Write-Fail 'checksum mismatch' + Write-Fail " expected: $expected" + Write-Fail " got: $actual" + exit 1 + } +} + +function Add-ToUserPath($dir) { + # The user PATH, not the machine one: no elevation, and it survives reboots, + # which setting it only in this process would not. + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @() + if ($userPath) { $entries = $userPath -split ';' | Where-Object { $_ } } + if ($entries -contains $dir) { return $false } + + [Environment]::SetEnvironmentVariable('Path', (($entries + $dir) -join ';'), 'User') + # And in this session too, so `nan` works without opening a new terminal. + $env:Path = "$env:Path;$dir" + return $true +} + +$arch = Get-Arch +if (-not $Version) { + Write-Step 'fetching latest release...' + $Version = Get-LatestVersion +} +if (-not $InstallDir) { + $InstallDir = Join-Path $env:LOCALAPPDATA 'Programs\nan' +} + +Write-Done "nan-cli $Version (windows/$arch)" + +$archive = "nan-cli_${Version}_windows_${arch}.zip" +$base = "https://github.com/$Repo/releases/download/$Version" +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("nan-install-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tmp -Force | Out-Null + +try { + Write-Step "downloading $archive..." + try { + Invoke-WebRequest -Uri "$base/$archive" -OutFile (Join-Path $tmp $archive) + } catch { + Write-Fail "could not download $archive" + Write-Fail "check that $Version is a published release: https://github.com/$Repo/releases" + exit 1 + } + + Write-Step 'verifying checksum...' + $checksums = (Invoke-WebRequest -Uri "$base/checksums.txt").Content + $expected = $null + foreach ($line in $checksums -split "`n") { + if ($line -match "^([0-9a-fA-F]{64})\s+\*?$([regex]::Escape($archive))\s*$") { + $expected = $Matches[1] + } + } + Assert-Checksum (Join-Path $tmp $archive) $expected + + Expand-Archive -Path (Join-Path $tmp $archive) -DestinationPath $tmp -Force + $binary = Join-Path $tmp 'nan.exe' + if (-not (Test-Path $binary)) { + Write-Fail 'the archive does not contain nan.exe' + exit 1 + } + + Write-Step "installing to $InstallDir\nan.exe..." + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + # A running nan.exe holds a lock on its own file, so replacing it while the + # TUI is open fails with a message about the file being in use. Saying which + # file and why beats the raw exception. + try { + Copy-Item -Path $binary -Destination (Join-Path $InstallDir 'nan.exe') -Force + } catch { + Write-Fail "could not write $InstallDir\nan.exe" + Write-Fail 'if nan is running, close it and try again' + exit 1 + } + + Write-Done "installed $Version to $InstallDir\nan.exe" + + if (Add-ToUserPath $InstallDir) { + Write-Warn "$InstallDir was added to your PATH" + Write-Warn 'already-open terminals will not see it until they are restarted' + } + Write-Host '' + Write-Host 'Run ' -NoNewline; Write-Host 'nan' -ForegroundColor Cyan -NoNewline; Write-Host ' to get started.' +} finally { + Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/install.sh b/scripts/install.sh index dce0220..39b92ac 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -50,6 +50,24 @@ get_latest_version() { curl -sL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | cut -d'"' -f4 } +# An unauthenticated GitHub API is rate limited per IP, so this call can come +# back empty for reasons that have nothing to do with this repo. Without this +# check the empty version went straight into the archive name, and what the +# person saw was curl failing on a URL with a hole in it - which says nothing +# about what actually happened or what to do next. +require_version() { + if [ -n "$1" ]; then + return 0 + fi + 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.3 curl -fsSL https://nan.builders/install | bash +" >&2 + err "the releases are at https://github.com/$REPO/releases" + exit 1 +} + verify_checksum() { local file="$1" expected="$2" local actual @@ -99,6 +117,7 @@ main() { if [ -z "$VERSION" ]; then info "fetching latest release..." version="$(get_latest_version)" + require_version "$version" else version="$VERSION" fi