diff --git a/.changeset/name-unreachable-hosts.md b/.changeset/name-unreachable-hosts.md new file mode 100644 index 0000000..eb517c9 --- /dev/null +++ b/.changeset/name-unreachable-hosts.md @@ -0,0 +1,17 @@ +--- +'seamless-cli': patch +--- + +Say what could not be reached when a scaffold's network read fails. + +`seamless init` makes three remote reads: the template registry, the templates archive, and +the auth server's `.env.example`. Each of them handles a non-ok HTTP response with a message +naming the status and the URL, but a connection-level failure (offline, DNS, TLS, no route) +rejects with a bare `TypeError: fetch failed` that propagated untouched to the top-level +handler. The whole output was "Error: fetch failed", which named neither the host nor which +of the three reads had failed. + +The three call sites now go through a shared helper that turns that rejection into a message +naming the URL, what the CLI wanted from it, and the network as the likely cause, in the +style `login` already uses for an unreachable instance. Non-ok responses keep the messages +they had, and the original error is preserved as the thrown error's `cause`. diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index fdefd25..270ff0d 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.3% + + coverage: 99.4% @@ -17,7 +17,7 @@ coverage coverage - 99.3% - 99.3% + 99.4% + 99.4% diff --git a/src/core/fetch.test.ts b/src/core/fetch.test.ts index 5ce2e7f..5e4b34a 100644 --- a/src/core/fetch.test.ts +++ b/src/core/fetch.test.ts @@ -33,14 +33,18 @@ describe("fetchEnvExample", () => { ); }); - it("propagates a network failure", async () => { + it("names the URL and the purpose when the connection fails", async () => { + const cause = new TypeError("fetch failed"); vi.stubGlobal( "fetch", vi.fn(async () => { - throw new Error("network down"); + throw cause; }), ); - await expect(fetchEnvExample()).rejects.toThrow("network down"); + await expect(fetchEnvExample()).rejects.toThrow( + `Could not reach https://raw.githubusercontent.com/fells-code/seamless-auth-api/${SEAMLESS_AUTH_API_VERSION}/.env.example to read the auth server's env.example. Check your network connection.`, + ); + await expect(fetchEnvExample()).rejects.toMatchObject({ cause }); }); }); diff --git a/src/core/fetch.ts b/src/core/fetch.ts index df3ac13..6fccead 100644 --- a/src/core/fetch.ts +++ b/src/core/fetch.ts @@ -1,10 +1,29 @@ import { SEAMLESS_AUTH_API_VERSION } from "./images.js"; +/** + * A GET whose connection-level failure says what it could not reach. + * + * When `fetch` never gets a response at all (offline, DNS, TLS, no route) it rejects + * with a bare `TypeError: fetch failed`, which reaches the top-level handler as + * "Error: fetch failed" and names neither the host nor what the CLI wanted from it. + * A non-ok response is left to the caller, which knows what its status means. + */ +export async function fetchRemote(url: string, purpose: string): Promise { + try { + return await fetch(url); + } catch (err) { + throw new Error( + `Could not reach ${url} to ${purpose}. Check your network connection.`, + { cause: err }, + ); + } +} + export async function fetchEnvExample(): Promise { const url = `https://raw.githubusercontent.com/fells-code/seamless-auth-api/${SEAMLESS_AUTH_API_VERSION}/.env.example`; - const res = await fetch(url); + const res = await fetchRemote(url, "read the auth server's env.example"); if (!res.ok) { throw new Error("Failed to fetch auth env.example"); diff --git a/src/core/templates.test.ts b/src/core/templates.test.ts index c1ea9e2..a321edf 100644 --- a/src/core/templates.test.ts +++ b/src/core/templates.test.ts @@ -248,6 +248,18 @@ describe("openTemplateSource (remote)", () => { ); }); + it("names the registry URL when the connection fails", async () => { + mockFetchByUrl({ + "registry.json": () => { + throw new TypeError("fetch failed"); + }, + }); + + await expect(openTemplateSource()).rejects.toThrow( + `Could not reach https://raw.githubusercontent.com/${SEAMLESS_TEMPLATES_REPO}/${SEAMLESS_TEMPLATES_REF}/registry.json to read the template registry. Check your network connection.`, + ); + }); + it("throws when the fetched registry is malformed", async () => { mockFetchByUrl({ "registry.json": () => ({ ok: true, text: () => JSON.stringify({}) }), @@ -271,6 +283,25 @@ describe("openTemplateSource (remote)", () => { ).rejects.toThrow(/Failed to download templates \(503\)/); }); + it("names the archive URL when the connection fails", async () => { + mockFetchByUrl({ + "registry.json": () => ({ + ok: true, + text: () => JSON.stringify(registryPayload), + }), + ".zip": () => { + throw new TypeError("fetch failed"); + }, + }); + + const source = await openTemplateSource(); + await expect( + source.readManifest(registryPayload.templates[0] as RegistryEntry), + ).rejects.toThrow( + `Could not reach https://github.com/${SEAMLESS_TEMPLATES_REPO}/archive/${SEAMLESS_TEMPLATES_REF}.zip to download the project templates. Check your network connection.`, + ); + }); + it("throws when the downloaded archive has no entries", async () => { mockFetchByUrl({ "registry.json": () => ({ diff --git a/src/core/templates.ts b/src/core/templates.ts index dfc543e..980aaa9 100644 --- a/src/core/templates.ts +++ b/src/core/templates.ts @@ -5,6 +5,7 @@ import AdmZip from "adm-zip"; import { VERSION } from "../index.js"; import { parseEnvString, writeEnv } from "./env.js"; +import { fetchRemote } from "./fetch.js"; import { generateSecret } from "./secrets.js"; import { SEAMLESS_TEMPLATES_REF, SEAMLESS_TEMPLATES_REPO } from "./images.js"; @@ -140,7 +141,7 @@ function openLocalSource(dir: string): TemplateSource { async function openRemoteSource(repo: string, ref: string): Promise { const registryUrl = `https://raw.githubusercontent.com/${repo}/${ref}/registry.json`; - const res = await fetch(registryUrl); + const res = await fetchRemote(registryUrl, "read the template registry"); if (!res.ok) { throw new Error( `Failed to fetch the template registry (${res.status}) from ${registryUrl}.`, @@ -154,7 +155,7 @@ async function openRemoteSource(repo: string, ref: string): Promise { if (archive) return archive; const url = `https://github.com/${repo}/archive/${ref}.zip`; - const zipRes = await fetch(url); + const zipRes = await fetchRemote(url, "download the project templates"); if (!zipRes.ok) { throw new Error(`Failed to download templates (${zipRes.status}) from ${url}.`); }