From 7ea5414432ff2ab88b8dc6a3967a21d25db86886 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Wed, 12 Feb 2025 10:15:15 -0500 Subject: [PATCH 1/2] a very fast and simple version check --- CHANGELOG.md | 6 ++ package.json | 2 + pnpm-lock.yaml | 3 + src/settings/settings.ts | 12 ++++ src/utils/versionCheck.test.ts | 90 ++++++++++++++++++++++++---- src/utils/versionCheck.ts | 103 +++++++++++++++------------------ 6 files changed, 150 insertions(+), 66 deletions(-) create mode 100644 src/settings/settings.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 337edc3..e97de39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Patch Changes +- A very fast and simple background version check + +## 0.0.19 + +### Patch Changes + - ensure the cli works on windows ## 0.0.18 diff --git a/package.json b/package.json index f8ebdc7..47d21d9 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "build": "tsc --noEmit && tsc", "build:ci": "tsc", "clean": "rimraf dist", + "clean:all": "rimraf dist node_modules", "lint": "eslint \"src/**/*.ts\" --fix", "format": "prettier --write \"src/**/*.*\"", "test": "vitest run", @@ -51,6 +52,7 @@ "@anthropic-ai/sdk": "^0.36", "chalk": "^5", "dotenv": "^16", + "semver": "^7.7.1", "source-map-support": "^0.5", "uuid": "^11", "yargs": "^17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69865bf..c5209aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: dotenv: specifier: ^16 version: 16.4.7 + semver: + specifier: ^7.7.1 + version: 7.7.1 source-map-support: specifier: ^0.5 version: 0.5.21 diff --git a/src/settings/settings.ts b/src/settings/settings.ts new file mode 100644 index 0000000..fba67f4 --- /dev/null +++ b/src/settings/settings.ts @@ -0,0 +1,12 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const settingsDir = path.join(os.homedir(), ".mycoder"); + +export const getSettingsDir = (): string => { + if (!fs.existsSync(settingsDir)) { + fs.mkdirSync(settingsDir, { recursive: true }); + } + return settingsDir; +}; diff --git a/src/utils/versionCheck.test.ts b/src/utils/versionCheck.test.ts index 41e5679..ef68cc2 100644 --- a/src/utils/versionCheck.test.ts +++ b/src/utils/versionCheck.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines-per-function */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { generateUpgradeMessage, @@ -5,12 +6,20 @@ import { getPackageInfo, checkForUpdates, } from "./versionCheck.js"; +import * as fs from "fs"; +import * as fsPromises from "fs/promises"; +import * as path from "path"; +import { getSettingsDir } from "../settings/settings.js"; + +vi.mock("fs"); +vi.mock("fs/promises"); +vi.mock("../settings/settings.js"); describe("versionCheck", () => { describe("generateUpgradeMessage", () => { it("returns null when versions are the same", () => { expect(generateUpgradeMessage("1.0.0", "1.0.0", "test-package")).toBe( - null, + null ); }); @@ -19,6 +28,12 @@ describe("versionCheck", () => { expect(message).toContain("Update available: 1.0.0 → 1.1.0"); expect(message).toContain("Run 'npm install -g test-package' to update"); }); + + it("returns null when current version is higher", () => { + expect(generateUpgradeMessage("2.0.0", "1.0.0", "test-package")).toBe( + null + ); + }); }); describe("fetchLatestVersion", () => { @@ -43,18 +58,30 @@ describe("versionCheck", () => { const version = await fetchLatestVersion("test-package"); expect(version).toBe("1.1.0"); expect(mockFetch).toHaveBeenCalledWith( - "https://registry.npmjs.org/test-package/latest", + "https://registry.npmjs.org/test-package/latest" ); }); - it("returns null when fetch fails", async () => { + it("throws error when fetch fails", async () => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found", }); - const version = await fetchLatestVersion("test-package"); - expect(version).toBe(null); + await expect(fetchLatestVersion("test-package")).rejects.toThrow( + "Failed to fetch version info: Not Found" + ); + }); + + it("throws error when version is missing from response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + await expect(fetchLatestVersion("test-package")).rejects.toThrow( + "Version info not found in response" + ); }); }); @@ -71,28 +98,69 @@ describe("versionCheck", () => { describe("checkForUpdates", () => { const mockFetch = vi.fn(); const originalFetch = global.fetch; - const originalEnv = process.env; + const mockSettingsDir = "/mock/settings/dir"; + const versionFilePath = path.join(mockSettingsDir, "lastVersionCheck"); beforeEach(() => { global.fetch = mockFetch; - process.env = { ...originalEnv }; + vi.mocked(getSettingsDir).mockReturnValue(mockSettingsDir); + vi.mocked(fs.existsSync).mockReturnValue(false); }); afterEach(() => { global.fetch = originalFetch; - process.env = originalEnv; vi.clearAllMocks(); }); - it("returns upgrade message when update available", async () => { - process.env.npm_config_global = "true"; + it("returns null and initiates background check when no cached version", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ version: "999.0.0" }), // Much higher version + json: () => Promise.resolve({ version: "2.0.0" }), }); + const result = await checkForUpdates(); + expect(result).toBe(null); + + // Wait for setImmediate to complete + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockFetch).toHaveBeenCalled(); + expect(fsPromises.writeFile).toHaveBeenCalledWith( + versionFilePath, + "2.0.0", + "utf8" + ); + }); + + it("returns upgrade message when cached version is newer", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fsPromises.readFile).mockResolvedValue("2.0.0"); + const result = await checkForUpdates(); expect(result).toContain("Update available"); }); + + it("handles errors gracefully during version check", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fsPromises.readFile).mockRejectedValue(new Error("Test error")); + + const result = await checkForUpdates(); + expect(result).toBe(null); + }); + + it("handles errors gracefully during background update", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + mockFetch.mockRejectedValue(new Error("Network error")); + + const result = await checkForUpdates(); + expect(result).toBe(null); + + // Wait for setImmediate to complete + await new Promise((resolve) => setImmediate(resolve)); + + // Verify the error was handled + expect(fsPromises.writeFile).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/utils/versionCheck.ts b/src/utils/versionCheck.ts index 1c2d6da..6af26ef 100644 --- a/src/utils/versionCheck.ts +++ b/src/utils/versionCheck.ts @@ -1,100 +1,93 @@ import { Logger } from "./logger.js"; import chalk from "chalk"; import { createRequire } from "module"; +import * as path from "path"; import type { PackageJson } from "type-fest"; +import { getSettingsDir } from "../settings/settings.js"; +import * as fsPromises from "fs/promises"; +import * as fs from "fs"; +import semver from "semver"; const require = createRequire(import.meta.url); const logger = new Logger({ name: "version-check" }); -/** - * Gets the current package info from package.json - */ export function getPackageInfo(): { - name: string | undefined; - version: string | undefined; + name: string; + version: string; } { const packageInfo = require("../../package.json") as PackageJson; + if (!packageInfo.name || !packageInfo.version) { + throw new Error("Unable to determine package info"); + } + return { name: packageInfo.name, version: packageInfo.version, }; } -/** - * Checks if the package is running as a global npm package - */ -export function isGlobalPackage(): boolean { - return !!process.env.npm_config_global; -} +export async function fetchLatestVersion(packageName: string): Promise { + const registryUrl = `https://registry.npmjs.org/${packageName}/latest`; + const response = await fetch(registryUrl); -/** - * Fetches the latest version of a package from npm registry - */ -export async function fetchLatestVersion( - packageName: string, -): Promise { - try { - const registryUrl = `https://registry.npmjs.org/${packageName}/latest`; - const response = await fetch(registryUrl); - - if (!response.ok) { - throw new Error(`Failed to fetch version info: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to fetch version info: ${response.statusText}`); + } - const data = (await response.json()) as { version: string | undefined }; - return data.version ?? null; - } catch (error) { - logger.warn( - "Error fetching latest version:", - error instanceof Error ? error.message : String(error), - ); - return null; + const data = (await response.json()) as { version: string | undefined }; + if (!data.version) { + throw new Error("Version info not found in response"); } + return data.version; } -/** - * Generates an upgrade message if versions differ - */ export function generateUpgradeMessage( currentVersion: string, latestVersion: string, - packageName: string, + packageName: string ): string | null { - return currentVersion !== latestVersion + return semver.gt(latestVersion, currentVersion) ? chalk.green( - ` Update available: ${currentVersion} → ${latestVersion}\n Run 'npm install -g ${packageName}' to update`, + ` Update available: ${currentVersion} → ${latestVersion}\n Run 'npm install -g ${packageName}' to update` ) : null; } -/** - * Checks if a newer version of the package is available on npm. - * Only runs check when package is installed globally. - * - * @returns Upgrade message string if update available, null otherwise - */ export async function checkForUpdates(): Promise { try { const { name: packageName, version: currentVersion } = getPackageInfo(); - if (!packageName || !currentVersion) { - logger.warn("Unable to determine current package name or version"); - return null; + const settingDir = getSettingsDir(); + const versionFilePath = path.join(settingDir, "lastVersionCheck"); + if (fs.existsSync(versionFilePath)) { + const lastVersionCheck = await fsPromises.readFile( + versionFilePath, + "utf8" + ); + return generateUpgradeMessage( + currentVersion, + lastVersionCheck, + packageName + ); } - const latestVersion = await fetchLatestVersion(packageName); + fetchLatestVersion(packageName) + .then(async (latestVersion) => { + return fsPromises.writeFile(versionFilePath, latestVersion, "utf8"); + }) + .catch((error) => { + logger.warn( + "Error fetching latest version:", + error instanceof Error ? error.message : String(error) + ); + }); - if (!latestVersion) { - logger.warn("Unable to determine latest published version"); - return null; - } - - return generateUpgradeMessage(currentVersion, latestVersion, packageName); + return null; } catch (error) { // Log error but don't throw to handle gracefully logger.warn( "Error checking for updates:", - error instanceof Error ? error.message : String(error), + error instanceof Error ? error.message : String(error) ); return null; } From a042adc66ec9f6da2d415fc6f453fbd808565a6b Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Wed, 12 Feb 2025 10:15:40 -0500 Subject: [PATCH 2/2] extend default timeout on commands to 10s. --- src/tools/system/shellStart.test.ts | 18 +++++++++--------- src/tools/system/shellStart.ts | 6 ++++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/tools/system/shellStart.test.ts b/src/tools/system/shellStart.test.ts index 2ac2929..2aea7ce 100644 --- a/src/tools/system/shellStart.test.ts +++ b/src/tools/system/shellStart.test.ts @@ -23,7 +23,7 @@ describe("shellStartTool", () => { description: "Test process", timeout: 500, // Generous timeout to ensure sync mode }, - { logger }, + { logger } ); expect(result.mode).toBe("sync"); @@ -41,7 +41,7 @@ describe("shellStartTool", () => { description: "Slow command test", timeout: 50, // Short timeout to force async mode }, - { logger }, + { logger } ); expect(result.mode).toBe("async"); @@ -57,7 +57,7 @@ describe("shellStartTool", () => { command: "nonexistentcommand", description: "Invalid command test", }, - { logger }, + { logger } ); expect(result.mode).toBe("sync"); @@ -75,7 +75,7 @@ describe("shellStartTool", () => { description: "Sync completion test", timeout: 500, }, - { logger }, + { logger } ); // Even sync results should be in processStates @@ -88,7 +88,7 @@ describe("shellStartTool", () => { description: "Async completion test", timeout: 50, }, - { logger }, + { logger } ); if (asyncResult.mode === "async") { @@ -103,7 +103,7 @@ describe("shellStartTool", () => { description: "Pipe test", timeout: 50, // Force async for interactive command }, - { logger }, + { logger } ); expect(result.mode).toBe("async"); @@ -130,15 +130,15 @@ describe("shellStartTool", () => { } }); - it("should use default timeout of 100ms", async () => { + it("should use default timeout of 10000ms", async () => { const result = await shellStartTool.execute( { command: "sleep 1", description: "Default timeout test", }, - { logger }, + { logger } ); - expect(result.mode).toBe("async"); + expect(result.mode).toBe("sync"); }); }); diff --git a/src/tools/system/shellStart.ts b/src/tools/system/shellStart.ts index 0d626b1..ca84991 100644 --- a/src/tools/system/shellStart.ts +++ b/src/tools/system/shellStart.ts @@ -29,7 +29,9 @@ const parameterSchema = z.object({ timeout: z .number() .optional() - .describe("Timeout in ms before switching to async mode (default: 100ms)"), + .describe( + "Timeout in ms before switching to async mode (default: 10s, which usually is sufficient)" + ), }); const returnSchema = z.union([ @@ -58,7 +60,7 @@ const returnSchema = z.union([ type Parameters = z.infer; type ReturnType = z.infer; -const DEFAULT_TIMEOUT = 100; +const DEFAULT_TIMEOUT = 1000 * 10; export const shellStartTool: Tool = { name: "shellStart",