From bcfa8b2e87a451ada59d8e0ebe3b1c9ac09064db Mon Sep 17 00:00:00 2001 From: borjaperfra Date: Mon, 14 Sep 2026 10:37:46 +0200 Subject: [PATCH 1/5] docs(cli): Hermes, and the Hermes path that was only half true The CLI configures Hermes as of nan-cli 0.1.3, so the table on /docs/nan-cli and the card on /docs/agent-setup say so, in both languages. Two corrections that came out of driving the real tool while writing it: - The Hermes page published `~/.hermes/config.yaml` as the path. That is the macOS and Linux one; on Windows Hermes keeps its home under LOCALAPPDATA. Both are published now, along with `hermes config path`, which answers for whichever machine the reader is on. - The Hermes page now says the CLI can do this step, which is the shortcut the rest of the section already points at for the other tools. The test is for the half that kept drifting. The list of tools lives in four files - two pages, two languages - and the last time the CLI learned one, the table gained a row and the card went on naming four, so the answer a member got depended on which page they landed on. It cannot check the Go source, which is another repository; it checks that the site agrees with the site. It earned itself immediately: it caught the Spanish Hermes page going out without the CLI note the English one had. Co-Authored-By: Claude Opus 5 (1M context) --- src/content/docs-es/agent-setup.mdx | 2 +- src/content/docs-es/hermes.mdx | 15 +++++- src/content/docs-es/nan-cli.mdx | 13 +++++- src/content/docs/agent-setup.mdx | 2 +- src/content/docs/hermes.mdx | 15 +++++- src/content/docs/nan-cli.mdx | 13 +++++- src/tests/lib/docsCliTools.test.ts | 72 +++++++++++++++++++++++++++++ 7 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 src/tests/lib/docsCliTools.test.ts diff --git a/src/content/docs-es/agent-setup.mdx b/src/content/docs-es/agent-setup.mdx index 760f203..edc1c00 100644 --- a/src/content/docs-es/agent-setup.mdx +++ b/src/content/docs-es/agent-setup.mdx @@ -113,7 +113,7 @@ Cada página de esta sección tiene la misma forma: qué necesitas, la configura name: 'CLI de NaN', href: '/es/docs/nan-cli', kind: 'Terminal', - note: 'Configura OpenCode, Codex, Pi y droid por ti, y te enseña tu consumo.', + note: 'Configura OpenCode, Codex, Pi, droid y Hermes por ti, y te enseña tu consumo.', }, { name: 'Servidor MCP', diff --git a/src/content/docs-es/hermes.mdx b/src/content/docs-es/hermes.mdx index 9feda22..65558ca 100644 --- a/src/content/docs-es/hermes.mdx +++ b/src/content/docs-es/hermes.mdx @@ -29,6 +29,13 @@ Además tienes terminal web, subida de ficheros, logs y métricas, y puedes expo Si prefieres correrlo tú, Hermes trae un proveedor llamado **`custom`**, pensado para cualquier endpoint compatible con OpenAI. Es el que usa NaN. + +El [CLI de NaN](/es/docs/nan-cli) te lo configura: encuentra tu Hermes, le +pone los tres valores de abajo con `hermes config set` y no toca nada más de +tu configuración. Los pasos de aquí son lo que hace, por si prefieres hacerlo +tú o quieres saber qué ha cambiado. + + ### Instálalo @@ -55,7 +62,11 @@ Elige el proveedor **`custom`** (aparece también como `local`, `ollama` o `vllm | API key | tu clave, la que empieza por `sk-` | | Modelo | `glm5.3-flash` | -A diferencia de los proveedores que Hermes trae preconfigurados, el `custom` **no tiene variable de entorno fija**: la dirección y la clave se las das tú aquí, y las guarda en `~/.hermes/config.yaml`. +A diferencia de los proveedores que Hermes trae preconfigurados, el `custom` **no tiene variable de entorno fija**: la dirección y la clave se las das tú aquí, y las guarda en su fichero de configuración. + +Ese fichero es `~/.hermes/config.yaml` en macOS y Linux. En Windows es +`%LOCALAPPDATA%\hermes\config.yaml`. `hermes config path` te dice cuál es el +tuyo, y `HERMES_HOME` manda sobre los dos. ### Comprueba que funciona @@ -69,7 +80,7 @@ Te dice si el proveedor responde. Después arranca el agente y mándale un mensa ## Cambiar de modelo -El modelo por defecto vive en `~/.hermes/config.yaml`, en `model.default`. No se toca a mano: +El modelo por defecto vive en ese mismo fichero, en `model.default`. No se toca a mano: ```bash hermes model diff --git a/src/content/docs-es/nan-cli.mdx b/src/content/docs-es/nan-cli.mdx index 85e8b04..807cbdf 100644 --- a/src/content/docs-es/nan-cli.mdx +++ b/src/content/docs-es/nan-cli.mdx @@ -1,6 +1,6 @@ --- title: CLI de NaN -description: "La herramienta oficial de terminal: consumo, costes y configuración automática de OpenCode, Codex, Pi y droid." +description: "La herramienta oficial de terminal: consumo, costes y configuración automática de OpenCode, Codex, Pi, droid y Hermes." order: 4 group: Configurar tu agente --- @@ -23,9 +23,20 @@ Es un atajo, no un requisito. Todo lo que hace se puede hacer a mano siguiendo l | [Codex](/es/docs/codex) | Sí | | Pi | Sí | | Factory AI (`droid`) | Sí | +| [Hermes](/es/docs/hermes) | Sí | Para Claude Code, Cursor, VS Code, Cline y Zed sigue haciendo falta la configuración manual de sus páginas. +Solo lista las herramientas que tienes instaladas, y solo toca la parte de NaN +de cada fichero: lo demás se queda como estaba, y desmarcar una herramienta +vuelve a sacar lo nuestro. Hermes es la excepción a «escribe el fichero»: el +CLI usa `hermes config set`, que es la forma que tiene Hermes de escribir su +configuración, así que los comentarios del fichero sobreviven. + +La key que pegas se comprueba contra el clúster antes de escribir nada, así que +una mal copiada se ve ahí y no más tarde como un `401` dentro de cada +herramienta. + ## Qué más te da El CLI abre un panel con pestañas que se mueve con las flechas: diff --git a/src/content/docs/agent-setup.mdx b/src/content/docs/agent-setup.mdx index 85b2d37..c2d6c15 100644 --- a/src/content/docs/agent-setup.mdx +++ b/src/content/docs/agent-setup.mdx @@ -113,7 +113,7 @@ Yours is not here? If it accepts an OpenAI base URL and an API key, it works. Co name: 'NaN CLI', href: '/docs/nan-cli', kind: 'Terminal', - note: 'It configures OpenCode, Codex, Pi and droid for you, and shows you your usage.', + note: 'It configures OpenCode, Codex, Pi, droid and Hermes for you, and shows you your usage.', }, { name: 'MCP server', diff --git a/src/content/docs/hermes.mdx b/src/content/docs/hermes.mdx index f68c841..91c9fb2 100644 --- a/src/content/docs/hermes.mdx +++ b/src/content/docs/hermes.mdx @@ -29,6 +29,13 @@ You also get a web terminal, file uploads, logs and metrics, and you can expose If you would rather run it yourself, Hermes ships a provider called **`custom`**, meant for any OpenAI-compatible endpoint. That is the one NaN uses. + +The [NaN CLI](/docs/nan-cli) configures this for you: it finds your Hermes, +fills in the three values below through `hermes config set` and leaves the +rest of your config alone. The steps here are what it does, if you would +rather do it yourself or want to know what changed. + + ### Install it @@ -55,7 +62,11 @@ Choose the **`custom`** provider (it also shows up as `local`, `ollama` or `vllm | API key | your key, the one that starts with `sk-` | | Model | `glm5.3-flash` | -Unlike the providers Hermes ships preconfigured, `custom` **has no fixed environment variable**: you give it the address and the key here, and it keeps them in `~/.hermes/config.yaml`. +Unlike the providers Hermes ships preconfigured, `custom` **has no fixed environment variable**: you give it the address and the key here, and it keeps them in its config file. + +That file is `~/.hermes/config.yaml` on macOS and Linux. On Windows it is +`%LOCALAPPDATA%\hermes\config.yaml`. `hermes config path` prints whichever +one is yours, and `HERMES_HOME` overrides both. ### Check that it works @@ -69,7 +80,7 @@ It tells you whether the provider answers. Then start the agent and send it a me ## Switching models -The default model lives in `~/.hermes/config.yaml`, under `model.default`. You do not edit it by hand: +The default model lives in that same file, under `model.default`. You do not edit it by hand: ```bash hermes model diff --git a/src/content/docs/nan-cli.mdx b/src/content/docs/nan-cli.mdx index 9761839..22cf2eb 100644 --- a/src/content/docs/nan-cli.mdx +++ b/src/content/docs/nan-cli.mdx @@ -1,6 +1,6 @@ --- title: NaN CLI -description: "The official terminal tool: usage, costs and automatic setup for OpenCode, Codex, Pi and droid." +description: "The official terminal tool: usage, costs and automatic setup for OpenCode, Codex, Pi, droid and Hermes." order: 4 group: Set up your agent --- @@ -23,9 +23,20 @@ It is a shortcut, not a requirement. Everything it does can be done by hand by f | [Codex](/docs/codex) | Yes | | Pi | Yes | | Factory AI (`droid`) | Yes | +| [Hermes](/docs/hermes) | Yes | Claude Code, Cursor, VS Code, Cline and Zed still need the manual configuration from their pages. +It only lists tools you actually have installed, and it only touches the NaN +part of each file: everything else in them is left as it was, and unticking a +tool takes ours back out again. Hermes is the exception to "writes the file": +the CLI drives `hermes config set`, which is Hermes' own way of writing its +config, so the comments in it survive. + +The key you paste is checked against the cluster before anything is written, +so a mistyped one is caught there instead of turning up later as a `401` +inside each tool. + ## What else you get The CLI opens a panel with tabs you move through with the arrow keys: diff --git a/src/tests/lib/docsCliTools.test.ts b/src/tests/lib/docsCliTools.test.ts new file mode 100644 index 0000000..9d14bca --- /dev/null +++ b/src/tests/lib/docsCliTools.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +/** + * The list of tools the CLI configures on its own is published in two places: + * the table on /docs/nan-cli, and the one-line note on the NaN CLI card in + * /docs/agent-setup. Four times two, because each exists in English and in + * Spanish. + * + * They drifted the first time the CLI learned a new tool: the table gained a + * row and the card went on saying "OpenCode, Codex, Pi and droid", so which + * answer a member got depended on which page they landed on. Nothing in the + * build compares the two - they are prose in different files - so this does. + * + * It does not check the CLI itself: the Go source is another repository and + * this suite cannot see it. What it pins is that the site agrees with the + * site, in both languages, which is the half that kept going wrong. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const CONTENT = resolve(here, '../../content'); + +/** Named in the CLI's own README as the tools its Setup tab writes. */ +const TOOLS = ['OpenCode', 'Codex', 'Pi', 'droid', 'Hermes'] as const; + +const read = (locale: string, page: string) => + readFileSync(resolve(CONTENT, locale, `${page}.mdx`), 'utf8'); + +describe.each(['docs', 'docs-es'] as const)('what the CLI configures, in %s', (locale) => { + test('the table on the CLI page has a row for every tool', () => { + const body = read(locale, 'nan-cli'); + // The table ends where the next heading starts; a mention further down the + // page is not a row and must not pass for one. + const table = body.split('\n## ')[1] ?? ''; + for (const tool of TOOLS) { + expect(table, `${tool} is missing from the table on /docs/nan-cli`).toContain(tool); + } + }); + + test('the card on the agent-setup page names the same tools', () => { + const body = read(locale, 'agent-setup'); + const card = body + .split('\n') + .find((line) => line.includes('shows you your usage') || line.includes('te enseña tu consumo')); + expect(card, 'the NaN CLI card has no note to check').toBeDefined(); + for (const tool of TOOLS) { + expect(card, `${tool} is missing from the NaN CLI card on /docs/agent-setup`).toContain(tool); + } + }); +}); + +/** + * Hermes is configured through `hermes config set` rather than by writing its + * YAML, and its config is not where the page used to say it was on Windows. + * Both are things a member acts on, so both stay published. + */ +describe.each(['docs', 'docs-es'] as const)('the Hermes page in %s', (locale) => { + const body = () => read(locale, 'hermes'); + + test('does not publish the Unix path as the only one', () => { + const text = body(); + expect(text).toContain('~/.hermes/config.yaml'); + expect(text, 'the Windows path is not published').toContain('LOCALAPPDATA'); + expect(text, 'nothing tells a member how to ask Hermes itself').toContain('hermes config path'); + }); + + test('points at the CLI as the shortcut', () => { + expect(body()).toContain('nan-cli'); + }); +}); From 9bf9fe5e8269cce902d09a546d8a4035ee7f38a3 Mon Sep 17 00:00:00 2001 From: borjaperfra Date: Mon, 14 Sep 2026 10:52:19 +0200 Subject: [PATCH 2/5] docs: where Pi comes from, and a promise the cluster no longer keeps Two things a member acts on, both wrong on the page. The Pi page said what Pi is and never said where to get it. Every other page in the section links its tool out - Hermes to Nous Research, Gentle-AI to its repo - and the only link anywhere pointed at pi.ai from the CLI's README, which is Inflection's consumer chatbot and a different piece of software with a different company behind it. Pi is at pi.dev, and it installs in one line on each kind of machine, Windows included. The model page closed the premium note with "every other model can be called by any member". That is no longer true: `GET /v1/models` answers with what the cluster is running, which is not the same question as what a key can call, and there is at least one id today that appears there and comes back `401`. The note now points at the table as the thing to go by and says why the endpoint is not it - without pretending the difference does not exist. Co-Authored-By: Claude Opus 5 (1M context) --- src/content/docs-es/choose-a-model.md | 4 +++- src/content/docs-es/pi.mdx | 4 +++- src/content/docs/choose-a-model.md | 4 +++- src/content/docs/pi.mdx | 4 +++- src/tests/lib/docsCliTools.test.ts | 25 +++++++++++++++++++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/content/docs-es/choose-a-model.md b/src/content/docs-es/choose-a-model.md index 2a6f7ea..ab4e2ba 100644 --- a/src/content/docs-es/choose-a-model.md +++ b/src/content/docs-es/choose-a-model.md @@ -56,7 +56,9 @@ Si no sabes cuál coger, busca en la primera columna lo que quieres hacer. Las fichas completas, con parámetros, licencias y modos de razonamiento, están en [Modelos](/es/docs/models). > **`glm5.3` es el único que no entra con la suscripción normal** -> Necesita una clave en el tier premium. Si lo pides sin él, la respuesta es un **`401`**, no un `403`: "This API key does not have access to the requested model". Parece una clave rota y no lo es, así que mira el tier antes de ponerte a rotar credenciales. Tampoco aparece en `GET /v1/models`. Todos los demás los puede llamar cualquier miembro. +> Necesita una clave en el tier premium. Si lo pides sin él, la respuesta es un **`401`**, no un `403`: "This API key does not have access to the requested model". Parece una clave rota y no lo es, así que mira el tier antes de ponerte a rotar credenciales. Tampoco aparece en `GET /v1/models`. +> +> Todos los demás **de la tabla de arriba** los puede llamar cualquier miembro. Guíate por esa tabla y no por `GET /v1/models`: ese endpoint contesta con lo que el clúster tiene levantado, que no es la misma pregunta que qué puede llamar tu clave, así que un id puede salir ahí y aun así devolver un `401`. ## Cómo se leen los ids diff --git a/src/content/docs-es/pi.mdx b/src/content/docs-es/pi.mdx index 4715b45..05ef51c 100644 --- a/src/content/docs-es/pi.mdx +++ b/src/content/docs-es/pi.mdx @@ -11,7 +11,9 @@ import Callout from '../../components/docs/Callout.astro'; # Pi. -Pi es un agente de terminal deliberadamente pequeño: un armazón mínimo que se adapta a tu forma de trabajar en vez de imponerte la suya. Habla el formato de OpenAI, así que NaN entra como un proveedor más. +[Pi](https://pi.dev) es un agente de terminal deliberadamente pequeño: un armazón mínimo que se adapta a tu forma de trabajar en vez de imponerte la suya. Habla el formato de OpenAI, así que NaN entra como un proveedor más. + +Se instala con `curl -fsSL https://pi.dev/install.sh | sh`, o en Windows con `powershell -c "irm https://pi.dev/install.ps1 | iex"`. El [CLI de NaN](/es/docs/nan-cli) escribe esta configuración por ti: pestaña Setup, `e` para pegar tu clave, `espacio` para marcar Pi, `c` para aplicar. diff --git a/src/content/docs/choose-a-model.md b/src/content/docs/choose-a-model.md index 171af01..c430002 100644 --- a/src/content/docs/choose-a-model.md +++ b/src/content/docs/choose-a-model.md @@ -56,7 +56,9 @@ If you do not know which one to pick, look for what you want to do in the first The full spec sheets, with parameters, licenses and reasoning modes, are in [Models](/docs/models). > **`glm5.3` is the only one the normal subscription does not cover** -> It needs a key on the premium tier. If you ask for it without one, the answer is a **`401`**, not a `403`: "This API key does not have access to the requested model". It reads like a broken key and it is not, so check the tier before you go rotating credentials. It does not show up in `GET /v1/models` either. Every other model can be called by any member. +> It needs a key on the premium tier. If you ask for it without one, the answer is a **`401`**, not a `403`: "This API key does not have access to the requested model". It reads like a broken key and it is not, so check the tier before you go rotating credentials. It does not show up in `GET /v1/models` either. +> +> Every other model **in the table above** can be called by any member. Go by that table rather than by `GET /v1/models`: that endpoint answers with what the cluster is running, which is not the same question as what your key can call, so an id can appear there and still come back `401`. ## How to read the ids diff --git a/src/content/docs/pi.mdx b/src/content/docs/pi.mdx index 347a425..b2bea8c 100644 --- a/src/content/docs/pi.mdx +++ b/src/content/docs/pi.mdx @@ -11,7 +11,9 @@ import Callout from '../../components/docs/Callout.astro'; # Pi. -Pi is a deliberately small terminal agent: a minimal shell that adapts to how you work instead of imposing how it works. It speaks the OpenAI format, so NaN goes in as one more provider. +[Pi](https://pi.dev) is a deliberately small terminal agent: a minimal shell that adapts to how you work instead of imposing how it works. It speaks the OpenAI format, so NaN goes in as one more provider. + +It installs with `curl -fsSL https://pi.dev/install.sh | sh`, or on Windows with `powershell -c "irm https://pi.dev/install.ps1 | iex"`. The [NaN CLI](/docs/nan-cli) writes this configuration for you: Setup tab, `e` to paste your key, `space` to mark Pi, `c` to apply. diff --git a/src/tests/lib/docsCliTools.test.ts b/src/tests/lib/docsCliTools.test.ts index 9d14bca..14ca2d8 100644 --- a/src/tests/lib/docsCliTools.test.ts +++ b/src/tests/lib/docsCliTools.test.ts @@ -70,3 +70,28 @@ describe.each(['docs', 'docs-es'] as const)('the Hermes page in %s', (locale) => expect(body()).toContain('nan-cli'); }); }); + +/** + * Every other tool page says where its tool comes from - Hermes links Nous + * Research, Gentle-AI links its repo - and the Pi page said only what Pi is, + * so a reader who did not already have it had nowhere to go. The CLI's README + * had a link and it pointed at pi.ai, which is Inflection's consumer chatbot + * and a different piece of software entirely. + */ +describe.each(['docs', 'docs-es'] as const)('the Pi page in %s', (locale) => { + const body = () => read(locale, 'pi'); + + test('says where Pi comes from', () => { + expect(body(), 'no link to the Pi project').toContain('https://pi.dev'); + }); + + test('does not send anyone to the Inflection chatbot', () => { + expect(body()).not.toContain('pi.ai'); + }); + + test('says how to install it, on both kinds of machine', () => { + const text = body(); + expect(text, 'no install command').toContain('pi.dev/install.sh'); + expect(text, 'nothing for Windows').toContain('install.ps1'); + }); +}); From ff3b95df3570c078e9b2da05c3c8bb821d313246 Mon Sep 17 00:00:00 2001 From: borjaperfra Date: Mon, 14 Sep 2026 11:05:30 +0200 Subject: [PATCH 3/5] docs(pi): publish the seven models, not two of them The page published `glm5.3-flash` and `deepseek-v4-flash` as an example while the CLI writes all seven, so the same member got a different answer depending on whether they set Pi up by hand or let the CLI do it - and by hand they got a model picker missing five of the models they are paying for. Nothing caught it. Every window on the page was correct; there were just two of them, and the rule the suite enforced was written over the values rather than over the set. The OpenCode page has published all seven all along, which is what made the difference invisible: two of our own pages disagreeing about what the cluster serves. Both blocks are now the same seven in the same order, and the prose names them the way /docs/opencode does. Two things the list needs explaining, both now in Known issues: - `mimo-v2.5` goes in without its audio. Pi's `input` schema is `("text" | "image")[]` and a third value does not fail that one model: Pi refuses the whole file, every other provider in it included. That is why this page and the OpenCode page differ on that one field. - `glm5.3` is premium. It is on the list so a member who has the tier can see it, and the 401 without it is explained on /docs/choose-a-model. The tests are the OpenCode ones written for Pi, the completeness check included - seen failing against the two-model page before going in - plus one the other pages have no equivalent of: settings.json has to name a provider and a model that models.json actually declares. A default pointing at something the same page never defined is the failure the CLI was shipping until this week. Co-Authored-By: Claude Opus 5 (1M context) --- src/content/docs-es/pi.mdx | 52 ++++++++++++++++-- src/content/docs/pi.mdx | 50 ++++++++++++++++-- src/tests/lib/docsClientConfigs.test.ts | 70 +++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 7 deletions(-) diff --git a/src/content/docs-es/pi.mdx b/src/content/docs-es/pi.mdx index 05ef51c..76503fd 100644 --- a/src/content/docs-es/pi.mdx +++ b/src/content/docs-es/pi.mdx @@ -33,9 +33,17 @@ En `~/.pi/agent/models.json`: "nan": { "baseUrl": "https://api.nan.builders/v1", "api": "openai-completions", - "apiKey": "sk-tu-clave", + "apiKey": "sk-your-key", "compat": { "supportsDeveloperRole": true }, "models": [ + { + "id": "deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1048575, + "maxTokens": 32768 + }, { "id": "glm5.3-flash", "name": "GLM 5.3 Flash", @@ -45,11 +53,43 @@ En `~/.pi/agent/models.json`: "maxTokens": 32768 }, { - "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash", + "id": "qwen3.8-flash", + "name": "Qwen 3.8 Flash", "reasoning": true, "input": ["text", "image"], - "contextWindow": 1048575, + "contextWindow": 262144, + "maxTokens": 32768 + }, + { + "id": "mimo-v2.5", + "name": "Xiaomi MiMo V2.5", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1048576, + "maxTokens": 32768 + }, + { + "id": "gemma4", + "name": "Gemma 4", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 262144, + "maxTokens": 65536 + }, + { + "id": "qwen3.6", + "name": "Qwen 3.6", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 262144, + "maxTokens": 65536 + }, + { + "id": "glm5.3", + "name": "GLM 5.3 (premium)", + "reasoning": true, + "input": ["text"], + "contextWindow": 1048576, "maxTokens": 32768 } ] @@ -58,6 +98,8 @@ En `~/.pi/agent/models.json`: } ``` +Esta es la configuración de los 7 modelos LLM que sirve NaN: `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.8-flash`, `mimo-v2.5`, `gemma4`, `qwen3.6` y `glm5.3`. Es el mismo conjunto que escribe el [CLI de NaN](/es/docs/nan-cli), y el mismo que publica la página de [OpenCode](/es/docs/opencode). + `api: "openai-completions"` es lo que le dice a Pi con qué formato hablar. `maxTokens` es el techo de una respuesta, no el contexto. Es la misma cifra que el bloque de [OpenCode](/es/docs/opencode) publica como `limit.output`, porque responde a la misma pregunta. ### Ponlo por defecto @@ -94,5 +136,7 @@ Pídele algo corto. Si contesta, está saliendo por el clúster. Puedes cambiar - **La clave va escrita en el fichero.** `models.json` vive en tu carpeta personal, así que no suele acabar en un repositorio, pero tenlo en cuenta si sincronizas tu configuración entre máquinas. - **`maxTokens` no es el contexto.** Es el techo de cada respuesta. El razonamiento sale de ese mismo presupuesto, así que si pides respuestas largas y razonadas, súbelo. - **Los modelos que declares son los que verás.** Pi no le pregunta al clúster qué hay disponible: muestra lo que haya en la lista. +- **`mimo-v2.5` entra sin su audio.** El esquema de Pi acepta `"text"` e `"image"` en `input` y nada más, y un tercer valor no tumba solo a ese modelo: Pi rechaza el fichero entero, con todos los demás proveedores que haya dentro. El modelo sigue oyendo audio por la API, pero no desde Pi. +- **`glm5.3` necesita una clave del tier premium.** Está en la lista para que quien lo tenga lo vea. Sin ese tier la respuesta es un `401`, explicado en [Elige tu modelo](/es/docs/choose-a-model). diff --git a/src/content/docs/pi.mdx b/src/content/docs/pi.mdx index b2bea8c..21d8f15 100644 --- a/src/content/docs/pi.mdx +++ b/src/content/docs/pi.mdx @@ -36,6 +36,14 @@ In `~/.pi/agent/models.json`: "apiKey": "sk-your-key", "compat": { "supportsDeveloperRole": true }, "models": [ + { + "id": "deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1048575, + "maxTokens": 32768 + }, { "id": "glm5.3-flash", "name": "GLM 5.3 Flash", @@ -45,11 +53,43 @@ In `~/.pi/agent/models.json`: "maxTokens": 32768 }, { - "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash", + "id": "qwen3.8-flash", + "name": "Qwen 3.8 Flash", "reasoning": true, "input": ["text", "image"], - "contextWindow": 1048575, + "contextWindow": 262144, + "maxTokens": 32768 + }, + { + "id": "mimo-v2.5", + "name": "Xiaomi MiMo V2.5", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1048576, + "maxTokens": 32768 + }, + { + "id": "gemma4", + "name": "Gemma 4", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 262144, + "maxTokens": 65536 + }, + { + "id": "qwen3.6", + "name": "Qwen 3.6", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 262144, + "maxTokens": 65536 + }, + { + "id": "glm5.3", + "name": "GLM 5.3 (premium)", + "reasoning": true, + "input": ["text"], + "contextWindow": 1048576, "maxTokens": 32768 } ] @@ -58,6 +98,8 @@ In `~/.pi/agent/models.json`: } ``` +This is the config for the 7 LLM models NaN serves: `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.8-flash`, `mimo-v2.5`, `gemma4`, `qwen3.6` and `glm5.3`. It is the same set the [NaN CLI](/docs/nan-cli) writes, and the same one the [OpenCode](/docs/opencode) page publishes. + `api: "openai-completions"` is what tells Pi which format to speak. `maxTokens` is the ceiling for one answer, not the context. It is the same figure the [OpenCode](/docs/opencode) block publishes as `limit.output`, because it answers the same question. ### Make it the default @@ -94,5 +136,7 @@ Ask it for something short. If it answers, it is going out through the cluster. - **The key is written in the file.** `models.json` lives in your home directory, so it does not usually end up in a repository, but keep it in mind if you sync your configuration between machines. - **`maxTokens` is not the context.** It is the ceiling for each answer. Reasoning comes out of that same budget, so if you ask for long reasoned answers, raise it. - **The models you declare are the ones you get.** Pi does not ask the cluster what is available: it shows whatever is on the list. +- **`mimo-v2.5` goes in without its audio.** Pi's schema accepts `"text"` and `"image"` for `input` and nothing else, and a third value does not fail that one model: Pi refuses the whole file, every other provider in it included. The model still hears audio through the API, just not from inside Pi. +- **`glm5.3` needs a key on the premium tier.** It is on the list so that a member who has it can see it. Without that tier the answer is a `401`, which is explained in [Choose your model](/docs/choose-a-model). diff --git a/src/tests/lib/docsClientConfigs.test.ts b/src/tests/lib/docsClientConfigs.test.ts index 20f5154..2f10eff 100644 --- a/src/tests/lib/docsClientConfigs.test.ts +++ b/src/tests/lib/docsClientConfigs.test.ts @@ -406,6 +406,76 @@ function vscodeConfig(locale: string): any { return JSON.parse(raw as string); } +/** + * Pi takes two files and the page publishes both: models.json declares the + * provider, settings.json says which provider to use. The second one is not + * optional - without it Pi calls its factory provider and the member gets a + * 401 that names neither file - and it is the step the NaN CLI used to skip. + */ +function piModels(locale: string): any { + const raw = jsonBlocks(pageBody(locale, 'pi.mdx')).find((b) => b.includes('"providers"')); + expect(raw, `${locale}: no json block declares a Pi provider`).toBeDefined(); + return JSON.parse(raw as string); +} + +function piSettings(locale: string): any { + const raw = jsonBlocks(pageBody(locale, 'pi.mdx')).find((b) => b.includes('defaultProvider')); + expect(raw, `${locale}: no json block declares the Pi default`).toBeDefined(); + return JSON.parse(raw as string); +} + +describe.each(LOCALES)('models.json published in %s', (locale) => { + test('declares the provider the way Pi reads it', () => { + const nan = piModels(locale).providers?.nan; + expect(nan?.baseUrl).toBe('https://api.nan.builders/v1'); + // What tells Pi which wire format to speak. Anything else here and the + // provider is declared and unusable. + expect(nan?.api).toBe('openai-completions'); + expect(ALLOWED_PLACEHOLDERS.has(nan?.apiKey), `${locale}: ${nan?.apiKey}`).toBe(true); + }); + + /** + * The page published two models as an example while the CLI wrote all seven, + * so the same member got a different answer depending on whether they set Pi + * up by hand or let the CLI do it - and by hand they got a picker missing + * five of the models they are paying for. Nothing caught it: every window on + * the page was correct, there were just two of them. + */ + test('publishes exactly the served models, community plus premium', () => { + const ids = (piModels(locale).providers.nan.models as any[]).map((m) => m.id).sort(); + const expected = [...Object.keys(EXPECTED_MODELS), ...Object.keys(EXPECTED_PREMIUM)].sort(); + expect(ids).toEqual(expected); + }); + + /** + * Pi's schema for `input` is `("text" | "image")[]`. A third value does not + * fail the one model: Pi refuses the whole file, with every other provider + * in it. So mimo-v2.5 is published without its audio, and the page says so. + */ + test('no model declares an input outside Pi schema', () => { + for (const m of piModels(locale).providers.nan.models as any[]) { + for (const input of m.input) { + expect(['text', 'image'], `${m.id}: input ${input}`).toContain(input); + } + } + }); + + test('every model carries the window and the answer budget', () => { + for (const m of piModels(locale).providers.nan.models as any[]) { + expect(typeof m.contextWindow, `${m.id}: contextWindow`).toBe('number'); + expect(typeof m.maxTokens, `${m.id}: maxTokens`).toBe('number'); + } + }); + + test('the default points at a provider and a model the same page declares', () => { + const settings = piSettings(locale); + expect(settings.defaultProvider).toBe('nan'); + const ids = (piModels(locale).providers.nan.models as any[]).map((m) => m.id); + expect(ids, `defaultModel ${settings.defaultModel} is not in the provider`) + .toContain(settings.defaultModel); + }); +}); + describe.each(LOCALES)('chatLanguageModels.json published in %s', (locale) => { test('is an array with one customendpoint provider', () => { const cfg = vscodeConfig(locale); From 156ebf5b1619a26be4f73a04e66d223489b27a58 Mon Sep 17 00:00:00 2001 From: borjaperfra Date: Mon, 14 Sep 2026 11:22:42 +0200 Subject: [PATCH 4/5] feat: serve the Windows installer at /install.ps1 The Windows half of /install, same shape and for the same reasons: the script is not copied into this repo, it is fetched from a released tag next to the code it installs, and a failure comes back as a script that exits rather than as an HTTP error page. The caller is a shell in both cases; only the shell differs, which is what the content type is about - Invoke-RestMethod parses by content type, and anything json-ish would hand `iex` an object where it expected the text of a script. The pin is v0.1.3, which is not cut yet. Until it is, the upstream fetch 404s and the route answers with the error script that says what happened and where to download by hand - the correct degraded behaviour for something piped into `iex`, and better than the route not existing. /install is deliberately left pinned at v0.1.1. That script works today, and moving it before the tag exists would break a working install path in order to improve one that does not exist yet. Both pins move to v0.1.3 together once it is published - which also ships the install.sh change in this same branch. The docs stop saying Windows means building from source. Co-Authored-By: Claude Opus 5 (1M context) --- src/content/docs-es/nan-cli.mdx | 17 +++++- src/content/docs/nan-cli.mdx | 17 +++++- src/pages/install.ps1.ts | 91 +++++++++++++++++++++++++++ src/tests/api/installPs1.test.ts | 102 +++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/pages/install.ps1.ts create mode 100644 src/tests/api/installPs1.test.ts diff --git a/src/content/docs-es/nan-cli.mdx b/src/content/docs-es/nan-cli.mdx index 807cbdf..dd0de9a 100644 --- a/src/content/docs-es/nan-cli.mdx +++ b/src/content/docs-es/nan-cli.mdx @@ -75,7 +75,22 @@ INSTALL_DIR="$HOME/.local/bin" curl -fsSL https://nan.builders/install | bash Si la carpeta que elijas no está en tu `PATH`, el propio instalador te lo dice y te da la línea que hay que añadir a tu `~/.zshrc` o a tu `~/.bashrc`. -Hay binarios para macOS y Linux, en Intel y en ARM. En Windows, de momento, hay que compilar desde el código, y el bloque de abajo explica cómo. +En Windows, desde PowerShell: + +```powershell +irm https://nan.builders/install.ps1 | iex +``` + +Hace el mismo trabajo: resuelve la última release, descarga el `.zip` de tu arquitectura, **verifica su checksum** y deja `nan.exe` en `%LOCALAPPDATA%\Programs +an`, añadiendo esa carpeta a tu `PATH` de usuario si no estaba. Las terminales que ya tuvieras abiertas no lo verán hasta que las reinicies. + +Instala para tu usuario y no para toda la máquina, a propósito: el equivalente en Windows del `sudo` es un diálogo de elevación saliendo de un script por tubería, que es peor. Para ponerlo en otro sitio: + +```powershell +& ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -InstallDir "C: ools" +``` + +Hay binarios para macOS, Linux y Windows, en Intel y en ARM. ### Comprueba que ha ido bien diff --git a/src/content/docs/nan-cli.mdx b/src/content/docs/nan-cli.mdx index 22cf2eb..0d62b44 100644 --- a/src/content/docs/nan-cli.mdx +++ b/src/content/docs/nan-cli.mdx @@ -75,7 +75,22 @@ INSTALL_DIR="$HOME/.local/bin" curl -fsSL https://nan.builders/install | bash If the directory you choose is not in your `PATH`, the installer says so and hands you the line to add to your `~/.zshrc` or your `~/.bashrc`. -There are binaries for macOS and Linux, on Intel and on ARM. On Windows you have to build from source for now, and the block below explains how. +On Windows, from PowerShell: + +```powershell +irm https://nan.builders/install.ps1 | iex +``` + +It does the same work: resolves the latest release, downloads the `.zip` for your architecture, **verifies its checksum** and puts `nan.exe` in `%LOCALAPPDATA%\Programs +an`, adding that directory to your user `PATH` if it is not there already. Terminals you already had open will not see it until you restart them. + +It installs for your user and not for the whole machine, deliberately: the Windows equivalent of the `sudo` prompt is an elevation dialog out of a piped script, which is worse. To put it elsewhere: + +```powershell +& ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -InstallDir "C: ools" +``` + +There are binaries for macOS, Linux and Windows, on Intel and on ARM. ### Check that it worked diff --git a/src/pages/install.ps1.ts b/src/pages/install.ps1.ts new file mode 100644 index 0000000..c6c24fc --- /dev/null +++ b/src/pages/install.ps1.ts @@ -0,0 +1,91 @@ +import type { APIRoute } from 'astro'; + +/** + * `https://nan.builders/install.ps1`, the Windows half of /install. + * + * The CLI shipped linux and darwin binaries only, so the answer on Windows was + * to clone the repo and have Go installed. The release now builds windows + * amd64 and arm64 as .zip archives, and this is what turns those into a + * one-liner: + * + * irm https://nan.builders/install.ps1 | iex + * + * Same shape as /install and for the same reasons: the script is not copied + * into this repo, it is fetched from the tag next to the code it installs, and + * a failure is answered with a script that exits rather than with an HTTP error + * page. The caller is a shell in both cases; only the shell differs. + */ + +export const prerender = false; + +/** + * Pinned to a RELEASED TAG, exactly as /install is, and for the same reason: + * whatever this URL returns runs on a member's machine. + * + * This tag has to exist for the route to serve anything. Until it is cut, the + * upstream fetch 404s and the route answers with the error script below, which + * tells the person what happened and where to download by hand - the correct + * degraded behaviour for something piped into `iex`, and better than this + * route not existing at all. + * + * /install is deliberately NOT bumped in step: it is pinned to v0.1.1, that + * script still works, and moving it before the tag exists would break an + * install path that works today in order to improve one that does not exist + * yet. Both pins move to the same tag once v0.1.3 is published. + */ +const SCRIPT_URL = + 'https://raw.githubusercontent.com/helmcode/nan-cli/v0.1.3/scripts/install.ps1'; + +export const GET: APIRoute = async () => { + let upstream: Response; + try { + upstream = await fetch(SCRIPT_URL); + } catch { + return scriptError('could not reach the install script upstream'); + } + + if (!upstream.ok) { + return scriptError(`install script unavailable (upstream ${upstream.status})`); + } + + const body = await upstream.text(); + + // The PowerShell equivalent of the shebang check on /install: a body that + // does not open with the `#Requires` line is a sign the upstream answered + // with something else - an HTML error page, a redirect notice - and piped + // into `iex` that is executed as commands. + if (!body.startsWith('#Requires')) { + return scriptError('install script upstream returned something that is not a script'); + } + + return new Response(body, { + status: 200, + headers: { + // Not application/json or anything Invoke-RestMethod would try to parse: + // `irm | iex` needs a string back, and text/plain is what gives it one. + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'public, max-age=300', + }, + }); +}; + +/** + * Failure as PowerShell that writes to stderr and exits, not as an HTTP error + * page. `iex` on an HTML body runs it line by line. + */ +function scriptError(message: string): Response { + const body = [ + '#Requires -Version 5.1', + `Write-Error "nan install: ${message}"`, + 'Write-Error "Download the .zip by hand instead: https://github.com/helmcode/nan-cli/releases/latest"', + 'exit 1', + '', + ].join('\n'); + return new Response(body, { + status: 503, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/src/tests/api/installPs1.test.ts b/src/tests/api/installPs1.test.ts new file mode 100644 index 0000000..96fadbd --- /dev/null +++ b/src/tests/api/installPs1.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { GET } from '../../pages/install.ps1'; + +/** + * GET /install.ps1, the URL `irm https://nan.builders/install.ps1 | iex` hits. + * + * Same contract as /install and the same reason for each assert: whatever comes + * back is executed. What differs is the shell. A body PowerShell will not take + * as a string - anything Invoke-RestMethod decides to parse into an object - + * reaches `iex` as something other than a script, and an HTML error page piped + * into `iex` runs line by line. + */ + +const SCRIPT = '#Requires -Version 5.1\n$ErrorActionPreference = "Stop"\nWrite-Host installing\n'; + +const call = () => GET({} as never); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('GET /install.ps1', () => { + it('serves the upstream script as text PowerShell will not try to parse', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(SCRIPT, { status: 200 }))); + + const res = await call(); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(SCRIPT); + // Invoke-RestMethod parses by content type. Anything json-ish here and the + // caller gets an object where it expected the text of a script. + expect(res.headers.get('Content-Type')).toContain('text/plain'); + }); + + it('reads the script from the CLI repo instead of keeping a copy here', async () => { + const fetchMock = vi.fn(async () => new Response(SCRIPT, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await call(); + + const url = String(fetchMock.mock.calls[0][0]); + expect(url).toContain('helmcode/nan-cli'); + expect(url).toContain('scripts/install.ps1'); + }); + + it('pins the script to a tag, not to a moving branch', async () => { + const here = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(resolve(here, '../../pages/install.ps1.ts'), 'utf8'); + const url = source.match(/raw\.githubusercontent\.com\/helmcode\/nan-cli\/([^/]+)\//); + expect(url, 'no upstream URL found in the route').not.toBeNull(); + // Whatever this serves runs on a member's machine. A branch changes under + // us on any push; a tag is a revision somebody decided to publish. + expect(url![1], `pinned to ${url![1]}`).toMatch(/^v\d+\.\d+\.\d+$/); + }); + + it('refuses a body that is not a script', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('

404

', { status: 200 }))); + + const res = await call(); + + expect(res.status).toBe(503); + const body = await res.text(); + expect(body.startsWith('#Requires')).toBe(true); + expect(body).toContain('exit 1'); + }); + + it('answers an upstream failure with a script that exits non-zero', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 }))); + + const res = await call(); + + expect(res.status).toBe(503); + const body = await res.text(); + expect(body).toContain('upstream 404'); + // The tag this is pinned to may not be cut yet, so this path is the one a + // member actually meets. It has to name somewhere to go. + expect(body).toContain('github.com/helmcode/nan-cli/releases'); + expect(body).toContain('exit 1'); + }); + + it('answers a network failure the same way', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { + throw new Error('ECONNREFUSED'); + })); + + const res = await call(); + + expect(res.status).toBe(503); + expect(await res.text()).toContain('exit 1'); + }); + + it('does not let the installer be cached for long', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(SCRIPT, { status: 200 }))); + + const res = await call(); + + expect(res.headers.get('Cache-Control')).toContain('max-age=300'); + }); +}); From 121a47cfdc3f544e97c2fcc2c81da98f188dc5c4 Mon Sep 17 00:00:00 2001 From: borjaperfra Date: Mon, 14 Sep 2026 11:41:09 +0200 Subject: [PATCH 5/5] fix: point both installers at v0.1.4, now that it exists /install had been pinned to v0.1.1 for three releases. The pin is deliberate - whatever that URL returns runs on a member's machine, and a tag is a revision somebody decided to publish - but nothing moved it, so the change that tells a member what happened when the GitHub API rate limits the version lookup was released and served to no one. /install.ps1 moves to v0.1.4 rather than the v0.1.3 it was written against, because v0.1.3's PowerShell installer does not work. Two PowerShell 5.1 faults, both found the first time it was pointed at a published release rather than a local zip: an Invoke-WebRequest without -UseBasicParsing, which throws where the Internet Explorer engine is absent or has never run its first-run setup, and checksums.txt read off a response body that PowerShell hands back as a Byte[] because GitHub serves release assets as application/octet-stream. Both fixed in helmcode/nan-cli#5 and run end to end on Windows 11 afterwards. The test pins the two routes to each other. They are not required to name the same tag forever, since each moves when its own script changes, but one drifting three releases behind the other in silence is exactly what happened, and it is cheap to be told. Co-Authored-By: Claude Opus 5 (1M context) --- src/pages/install.ps1.ts | 22 ++++++++++++---------- src/pages/install.ts | 7 ++++++- src/tests/api/installPs1.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/pages/install.ps1.ts b/src/pages/install.ps1.ts index c6c24fc..f08dca2 100644 --- a/src/pages/install.ps1.ts +++ b/src/pages/install.ps1.ts @@ -22,19 +22,21 @@ export const prerender = false; * Pinned to a RELEASED TAG, exactly as /install is, and for the same reason: * whatever this URL returns runs on a member's machine. * - * This tag has to exist for the route to serve anything. Until it is cut, the - * upstream fetch 404s and the route answers with the error script below, which - * tells the person what happened and where to download by hand - the correct - * degraded behaviour for something piped into `iex`, and better than this - * route not existing at all. + * v0.1.4 and not v0.1.3, which is where this script was first published and + * where it did not work. Two PowerShell 5.1 faults, both found the first time + * it was pointed at a published release: an `Invoke-WebRequest` without + * `-UseBasicParsing`, which throws where the Internet Explorer engine is + * absent, and `checksums.txt` read off a response body that PowerShell hands + * back as a `Byte[]` because GitHub serves release assets as + * application/octet-stream. Both are fixed in v0.1.4, which was then run end + * to end on Windows 11 against the real release. * - * /install is deliberately NOT bumped in step: it is pinned to v0.1.1, that - * script still works, and moving it before the tag exists would break an - * install path that works today in order to improve one that does not exist - * yet. Both pins move to the same tag once v0.1.3 is published. + * If this tag ever stops existing, the upstream fetch 404s and the route + * answers with the error script below - the correct degraded behaviour for + * something piped into `iex`. */ const SCRIPT_URL = - 'https://raw.githubusercontent.com/helmcode/nan-cli/v0.1.3/scripts/install.ps1'; + 'https://raw.githubusercontent.com/helmcode/nan-cli/v0.1.4/scripts/install.ps1'; export const GET: APIRoute = async () => { let upstream: Response; diff --git a/src/pages/install.ts b/src/pages/install.ts index 2f03605..7e1e5a9 100644 --- a/src/pages/install.ts +++ b/src/pages/install.ts @@ -35,9 +35,14 @@ export const prerender = false; * script asks the GitHub API for the LATEST release at run time, so a member * running an older installer still gets the newest binary. Only a change to * `scripts/install.sh` itself needs a bump here. + * + * v0.1.4 is the first tag where the script says something useful when the + * GitHub API rate limits the version lookup. Before it, the empty version went + * into the archive name and the person saw curl fail on a URL with a hole in + * it, which explains neither what happened nor what to do. */ const SCRIPT_URL = - 'https://raw.githubusercontent.com/helmcode/nan-cli/v0.1.1/scripts/install.sh'; + 'https://raw.githubusercontent.com/helmcode/nan-cli/v0.1.4/scripts/install.sh'; export const GET: APIRoute = async () => { let upstream: Response; diff --git a/src/tests/api/installPs1.test.ts b/src/tests/api/installPs1.test.ts index 96fadbd..f702742 100644 --- a/src/tests/api/installPs1.test.ts +++ b/src/tests/api/installPs1.test.ts @@ -100,3 +100,28 @@ describe('GET /install.ps1', () => { expect(res.headers.get('Cache-Control')).toContain('max-age=300'); }); }); + +/** + * The two installer routes are pinned by hand, and the pair going out of step + * is what this whole sequence was about: /install sat on v0.1.1 for three + * releases, so the fix that tells a member what happened when the GitHub API + * rate limits them was published and served to nobody. + * + * They are not required to name the same tag forever - each moves when its own + * script changes - but a gap of more than nothing is worth having to justify, + * so this pins them together and fails loudly when one moves alone. + */ +describe('the two installer routes', () => { + const pin = (file: string) => { + const here = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(resolve(here, '../../pages/', file), 'utf8'); + const match = source.match(/raw\.githubusercontent\.com\/helmcode\/nan-cli\/([^/]+)\//); + expect(match, `no upstream URL in ${file}`).not.toBeNull(); + return match![1]; + }; + + it('serve scripts from the same released tag', () => { + expect(pin('install.ts'), 'the bash and PowerShell installers are pinned to different tags') + .toBe(pin('install.ps1.ts')); + }); +});