From 7c0facf84a81bf6a9b9f8db9f25c68a7e8caa4d4 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sat, 9 May 2026 13:23:00 +0100 Subject: [PATCH 1/2] fix(update): drop install-v1 Accept header on dist-tag fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm registry rejects `Accept: application/vnd.npm.install-v1+json` on the dist-tag endpoint (`//`) with HTTP 406 — the abbreviated metadata format only applies to the package-doc endpoint (`/`). `td update` was failing in production with `UPDATE_CHECK_FAILED: Registry request failed (HTTP 406)`. Drop the header; the dist-tag endpoint already returns a single small version document, so the header was never buying anything anyway. Adds a regression test that asserts `fetch` is called without the second options argument. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/update.test.ts | 11 ++++++++--- src/commands/update.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/commands/update.test.ts b/src/commands/update.test.ts index 821c423..2628fd8 100644 --- a/src/commands/update.test.ts +++ b/src/commands/update.test.ts @@ -165,9 +165,14 @@ describe('update --check', () => { mockReadConfigOrThrow.mockResolvedValue({ update_channel: 'pre-release' }) mockFetchOk('1.36.0-next.1') await createProgram().parseAsync(['node', 'td', 'update', '--check']) - expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/@doist/todoist-cli/next', { - headers: { Accept: 'application/vnd.npm.install-v1+json' }, - }) + expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/@doist/todoist-cli/next') + }) + + it('does not send the install-v1 Accept header (rejected with 406 on dist-tag URLs)', async () => { + mockFetchOk('99.99.99') + await createProgram().parseAsync(['node', 'td', 'update', '--check']) + const fetchCall = (fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls[0] + expect(fetchCall).toHaveLength(1) }) }) diff --git a/src/commands/update.ts b/src/commands/update.ts index a24cb65..2b23286 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -91,12 +91,13 @@ export async function fetchLatestVersion(args: { registryUrl?: string }): Promise { const base = args.registryUrl ?? DEFAULT_REGISTRY_URL + // Hit the dist-tag endpoint (`//`) directly — it returns a + // single resolved version document, much smaller than the full package + // metadata. The abbreviated `application/vnd.npm.install-v1+json` format + // is rejected here with HTTP 406 — it only applies to the package-doc + // endpoint (`/`), not dist-tag resolutions. const url = `${base}/${args.packageName}/${getInstallTag(args.channel)}` - // The abbreviated metadata format skips `readme`, dependency trees, etc. - // (~50× smaller than the default response on a typical package). - const response = await fetch(url, { - headers: { Accept: 'application/vnd.npm.install-v1+json' }, - }) + const response = await fetch(url) if (!response.ok) { throw new Error(`Registry request failed (HTTP ${response.status})`) } From 842613ca029b4a6be688e694f21cf830f0d0296f Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sat, 9 May 2026 13:28:05 +0100 Subject: [PATCH 2/2] test: drop redundant header regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `expect(fetch).toHaveBeenCalledWith(url)` already enforces an exact argument match — it fails if a second `options` argument is passed, which is the only way the bad `Accept` header could come back. The manual `mock.calls[0]` array inspection added nothing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/update.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/commands/update.test.ts b/src/commands/update.test.ts index 2628fd8..9efc250 100644 --- a/src/commands/update.test.ts +++ b/src/commands/update.test.ts @@ -167,13 +167,6 @@ describe('update --check', () => { await createProgram().parseAsync(['node', 'td', 'update', '--check']) expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/@doist/todoist-cli/next') }) - - it('does not send the install-v1 Accept header (rejected with 406 on dist-tag URLs)', async () => { - mockFetchOk('99.99.99') - await createProgram().parseAsync(['node', 'td', 'update', '--check']) - const fetchCall = (fetch as unknown as { mock: { calls: unknown[][] } }).mock.calls[0] - expect(fetchCall).toHaveLength(1) - }) }) describe('update install flow', () => {