diff --git a/README.md b/README.md index a35e74eaac..b32a506533 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), HTTPS API origin (or `http://localhost` for a local one) and run the helper without the two `npx` commands. It opens an isolated Chrome or Edge window for your usual sign-in and project choice; install either browser first. For CLI-managed - local evaluation installs, it discovers the matching dashboard with the installed or + local evaluation installs, it discovers the matching dashboard with the newest installed or cached CopilotKit CLI and uses that dashboard for login and session requests. Discovery runs offline with Bun, without downloading the CLI or requiring `npx` on your PATH. If no CLI is available, manually hosted deployments use their API origin for sign-in. Complete the local auth diff --git a/scripts/local-intelligence-cli.test.ts b/scripts/local-intelligence-cli.test.ts index b8a8c3dbbf..78a21da443 100644 --- a/scripts/local-intelligence-cli.test.ts +++ b/scripts/local-intelligence-cli.test.ts @@ -66,7 +66,7 @@ test("minimal-PATH child uses an existing npm cache offline without exposing cre }); }); -test("installed CLI wins over cache; otherwise newest cached version wins deterministically", async () => { +test("newest available CLI wins even when an older version is installed", async () => { await fixture(async (root, install) => { await install(join(root, ".npm/_npx/z/node_modules/copilotkit"), "4.9.0"); await install( @@ -84,13 +84,10 @@ test("installed CLI wins over cache; otherwise newest cached version wins determ expect( await localCliEntrypoint(root, { PATH: minimalPath }, root, []), ).toBe(latest); - const installed = await install( - join(root, "node_modules/copilotkit"), - "4.8.0", - ); + await install(join(root, "node_modules/copilotkit"), "4.8.0"); expect( await localCliEntrypoint(root, { PATH: minimalPath }, root, []), - ).toBe(installed); + ).toBe(latest); }); }); @@ -154,3 +151,119 @@ test("cancellation terminates discovery and timeout is a runtime failure", async ).toBe(1); }); }); + +test.each(["4.11.0", "4.10.0"])( + "installed CLI wins against cached 4.10.0 when its version is %s", + async (version) => { + await fixture(async (root, install) => { + await install( + join(root, ".npm/_npx/cache/node_modules/copilotkit"), + "4.10.0", + ); + const installed = await install( + join(root, "node_modules/copilotkit"), + version, + ); + expect( + await localCliEntrypoint(root, { PATH: minimalPath }, root, []), + ).toBe(installed); + }); + }, +); + +test("equal cached versions use a deterministic path tie-breaker", async () => { + await fixture(async (root, install) => { + await install(join(root, ".npm/_npx/z/node_modules/copilotkit"), "4.10.0"); + const first = await install( + join(root, ".npm/_npx/a/node_modules/copilotkit"), + "4.10.0", + ); + expect( + await localCliEntrypoint(root, { PATH: minimalPath }, root, []), + ).toBe(first); + }); +}); + +test.each([true, false])( + "Windows default npm cache uses local app data (configured: %s)", + async (configured) => { + await fixture(async (root, install) => { + const local = configured + ? join(root, "custom-local") + : join(root, "AppData/Local"); + const expected = await install( + join(local, "npm-cache/_npx/cache/node_modules/copilotkit"), + ); + await install( + join(root, ".npm/_npx/wrong/node_modules/copilotkit"), + "99.0.0", + ); + expect( + await localCliEntrypoint( + root, + { PATH: minimalPath, ...(configured ? { LOCALAPPDATA: local } : {}) }, + root, + [], + "win32", + ), + ).toBe(expected); + }); + }, +); + +test.each(["npm_config_cache", "NPM_CONFIG_CACHE"])( + "Windows respects explicit %s cache override", + async (key) => { + await fixture(async (root, install) => { + const explicit = join(root, "override"); + const expected = await install( + join(explicit, "_npx/cache/node_modules/copilotkit"), + ); + await install( + join( + root, + "AppData/Local/npm-cache/_npx/default/node_modules/copilotkit", + ), + "99.0.0", + ); + expect( + await localCliEntrypoint( + root, + { + PATH: minimalPath, + NPM_CONFIG_CACHE: join(root, "unused"), + [key]: explicit, + }, + root, + [], + "win32", + ), + ).toBe(expected); + }); + }, +); + +test("a stale global installation cannot shadow a newer cached CLI", async () => { + await fixture(async (root, install) => { + const globalRoot = join(root, "global/copilotkit"); + await install(globalRoot, "4.8.0"); + const cached = await install( + join(root, ".npm/_npx/cache/node_modules/copilotkit"), + "4.10.0", + ); + expect( + await localCliEntrypoint(root, { PATH: minimalPath }, root, [globalRoot]), + ).toBe(cached); + }); +}); + +test("newest installed CLI wins across local and global installations", async () => { + await fixture(async (root, install) => { + await install(join(root, "node_modules/copilotkit"), "4.8.0"); + const globalRoot = join(root, "global/copilotkit"); + const global = await install(globalRoot, "4.11.0"); + expect( + await localCliEntrypoint(root, { PATH: minimalPath }, root, [globalRoot]), + ).toBe(global); + }); +}); diff --git a/scripts/local-intelligence-cli.ts b/scripts/local-intelligence-cli.ts index 83d471ae74..ed5f4c4344 100644 --- a/scripts/local-intelligence-cli.ts +++ b/scripts/local-intelligence-cli.ts @@ -23,6 +23,7 @@ export async function localCliEntrypoint( "/opt/homebrew/lib/node_modules/copilotkit", "/usr/local/lib/node_modules/copilotkit", ], + platform: NodeJS.Platform = process.platform, ): Promise { const entry = async (root: string) => { try { @@ -72,29 +73,37 @@ export async function localCliEntrypoint( .filter(Boolean) .map((bin) => join(dirname(bin), "lib/node_modules/copilotkit")), ]; + const candidates: { path: string; version: string; installed: boolean }[] = + []; for (const root of installed) { const found = await entry(root); - if (found) return found.path; + if (found) candidates.push({ ...found, installed: true }); } // npm's npx cache is a complete installation with its dependency graph. Bun's raw // package cache is not, so only Bun's installed global package is considered above. const cacheRoot = join( environment.npm_config_cache ?? environment.NPM_CONFIG_CACHE ?? - join(home, ".npm"), + (platform === "win32" + ? join( + environment.LOCALAPPDATA ?? join(home, "AppData/Local"), + "npm-cache", + ) + : join(home, ".npm")), "_npx", ); - const cached: { path: string; version: string }[] = []; const names = await readdir(cacheRoot).catch(() => [] as string[]); for (const name of names.sort()) { const found = await entry(join(cacheRoot, name, "node_modules/copilotkit")); - if (found) cached.push(found); + if (found) candidates.push({ ...found, installed: false }); } - cached.sort( + candidates.sort( (a, b) => - Bun.semver.order(b.version, a.version) || a.path.localeCompare(b.path), + Bun.semver.order(b.version, a.version) || + Number(b.installed) - Number(a.installed) || + a.path.localeCompare(b.path), ); - return cached[0]?.path; + return candidates[0]?.path; } /** Execute the installed CLI with Desktop's already-resolved Bun, even with Finder's PATH. */