From 63cb669c1494f0424c1b25af94fbf787e97f382f Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Tue, 11 Aug 2026 00:28:20 +0530 Subject: [PATCH] fix: ship web fetch-browser so the blocked-fetch escalation works (#247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `webcmd web fetch` raises FETCH_BLOCKED / FETCH_REQUIRES_BROWSER with a hint pointing at `webcmd web fetch-browser`, and smart-search names it as the mandatory second rung. On a fresh install that command died with ADAPTER_LOAD: 0470ac43 dropped "clis/" from package.json `files` when it moved adapters to plugins, but the generated cli-manifest.json still advertises web/fetch-browser, and no `web` plugin was ever created — so there was nothing to install either. The published package cannot carry adapter source at the root (the packaging guard forbids it), so the build stages clis/ next to the compiled output instead: dist/src/clis/ ships under the existing dist/src/ entry, and BUILTIN_CLIS resolves there when the repo-root tree is absent. The core manifest is staged alongside it so the manifest lookup contract (clisDir/../cli-manifest.json) still holds and an installed CLI does not fall back to a filesystem scan. check-package-bin now asserts every cli-manifest module is present in the tarball, which is the regression this bug was. Co-Authored-By: Claude Opus 5 --- scripts/check-package-bin.mjs | 16 ++++++++++++++++ scripts/copy-yaml.cjs | 13 ++++++++++++- src/build-manifest.ts | 9 ++++++++- src/main.ts | 10 +++++++--- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/scripts/check-package-bin.mjs b/scripts/check-package-bin.mjs index 01c40594..48262f01 100644 --- a/scripts/check-package-bin.mjs +++ b/scripts/check-package-bin.mjs @@ -78,6 +78,22 @@ try { if (packedPaths.has('scripts/fetch-adapters.js')) { fail('packed tarball contains the retired adapter fetch lifecycle'); } + + // Every command the core manifest advertises must be in the tarball. A + // manifest entry whose module was left behind is discoverable but dies with + // ADAPTER_LOAD on first use — see #247, where web/fetch-browser was the only + // escalation path FETCH_BLOCKED knew how to recommend. + const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'cli-manifest.json'), 'utf8')); + for (const entry of manifest) { + if (!entry.modulePath) continue; + const packedModule = `dist/src/clis/${entry.modulePath}`; + if (!packedPaths.has(packedModule)) { + fail(`packed tarball is missing manifest module for ${entry.site}/${entry.name}: ${packedModule}`); + } + } + if (manifest.length > 0 && !packedPaths.has('dist/src/cli-manifest.json')) { + fail('packed tarball is missing the staged core manifest: dist/src/cli-manifest.json'); + } for (const [name, target] of binEntries) { if (!packedPaths.has(String(target))) { fail(`packed tarball is missing bin "${name}" target: ${target}`); diff --git a/scripts/copy-yaml.cjs b/scripts/copy-yaml.cjs index 9af6dbfb..4b622bc4 100644 --- a/scripts/copy-yaml.cjs +++ b/scripts/copy-yaml.cjs @@ -2,7 +2,8 @@ * Copy YAML support files to dist/. * (Adapters are JS-first and no longer need yaml copying.) */ -const { copyFileSync, mkdirSync, existsSync } = require('fs'); +const { copyFileSync, cpSync, mkdirSync, existsSync } = require('fs'); +const { sep } = require('path'); // Copy external CLI registry to dist/ const extSrc = 'src/external-clis.yaml'; @@ -14,3 +15,13 @@ if (existsSync(extSrc)) { const playwrightClient = 'src/browser/run/generated/playwright-client.js'; mkdirSync('dist/src/browser/run/generated', { recursive: true }); copyFileSync(playwrightClient, 'dist/src/browser/run/generated/playwright-client.js'); + +// Stage the builtin adapter tree next to the compiled output. package.json +// `files` ships dist/src/ but not the repo-root clis/, so without this the +// core manifest points at modules the published package does not contain. +if (existsSync('clis')) { + cpSync('clis', 'dist/src/clis', { + recursive: true, + filter: (src) => !src.split(sep).includes('test'), + }); +} diff --git a/src/build-manifest.ts b/src/build-manifest.ts index 5cb93350..5fdc38bf 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -27,7 +27,7 @@ import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { getErrorMessage } from './errors.js'; import { fullName, getRegistry, type CliCommand } from './registry.js'; -import { findPackageRoot } from './package-paths.js'; +import { findPackageRoot, getCliManifestPath } from './package-paths.js'; import type { ManifestEntry } from './manifest-types.js'; import { isRecord } from './utils.js'; import { @@ -450,6 +450,13 @@ async function main(): Promise { fs.mkdirSync(path.dirname(OUTPUT), { recursive: true }); fs.writeFileSync(OUTPUT, artifacts.manifestJson); + // The published package resolves the builtin tree to dist/src/clis/, and the + // manifest lookup is always clisDir/../cli-manifest.json — keep the staged + // copy in step so an installed CLI does not fall back to a filesystem scan. + const stagedClis = path.join(PACKAGE_ROOT, 'dist', 'src', 'clis'); + if (fs.existsSync(stagedClis)) { + fs.writeFileSync(getCliManifestPath(stagedClis), artifacts.manifestJson); + } fs.writeFileSync(HOSTED_CONTRACT_OUTPUT, artifacts.hostedContractJson); console.error(`✅ Manifest compiled: ${entries.length} entries → ${OUTPUT}`); console.error(`✅ Hosted contract compiled: ${packageMetadata.name}@${packageMetadata.version} → ${HOSTED_CONTRACT_OUTPUT}`); diff --git a/src/main.ts b/src/main.ts index 57fd60da..236e08a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,9 +26,13 @@ import { CONFIG_DIR_NAME } from './brand.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -// The empty core manifest remains next to the retired clis/ location so older -// user-local adapter manifests keep the same lookup contract. -const BUILTIN_CLIS = path.join(findPackageRoot(__filename), 'clis'); +// The core manifest sits next to the builtin clis/ tree so user-local adapter +// manifests keep the same lookup contract. In a repo checkout that tree is +// clis/ at the package root; the published package cannot ship adapter source +// at the root, so the build stages a copy next to the compiled output and this +// resolves to dist/src/clis/ there. +const REPO_BUILTIN_CLIS = path.join(findPackageRoot(__filename), 'clis'); +const BUILTIN_CLIS = fs.existsSync(REPO_BUILTIN_CLIS) ? REPO_BUILTIN_CLIS : path.join(__dirname, 'clis'); const USER_CLIS = path.join(os.homedir(), CONFIG_DIR_NAME, 'clis'); const USER_PLUGINS = path.join(os.homedir(), CONFIG_DIR_NAME, 'plugins');