From c62a4f01ac593b1cc335a9bf012a2fa853c9e8a0 Mon Sep 17 00:00:00 2001 From: andriypolandki <=> Date: Wed, 1 Jul 2026 11:59:54 -0700 Subject: [PATCH 1/5] feat(miner-packaging): add startup npm version nudge to gittensory-miner (#2331) --- package-lock.json | 30 +++ package.json | 3 +- packages/gittensory-engine/lib/index.d.ts | 1 + packages/gittensory-engine/lib/index.js | 1 + packages/gittensory-engine/package.json | 37 ++++ packages/gittensory-miner/README.md | 38 ++++ .../gittensory-miner/bin/gittensory-miner.js | 28 +++ packages/gittensory-miner/lib/cli.d.ts | 3 + packages/gittensory-miner/lib/cli.js | 28 +++ .../gittensory-miner/lib/update-check.d.ts | 26 +++ packages/gittensory-miner/lib/update-check.js | 97 ++++++++++ packages/gittensory-miner/package.json | 42 ++++ test/unit/miner-cli.test.ts | 181 ++++++++++++++++++ test/unit/support/miner-cli-harness.ts | 83 ++++++++ 14 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-engine/lib/index.d.ts create mode 100644 packages/gittensory-engine/lib/index.js create mode 100644 packages/gittensory-engine/package.json create mode 100644 packages/gittensory-miner/README.md create mode 100755 packages/gittensory-miner/bin/gittensory-miner.js create mode 100644 packages/gittensory-miner/lib/cli.d.ts create mode 100644 packages/gittensory-miner/lib/cli.js create mode 100644 packages/gittensory-miner/lib/update-check.d.ts create mode 100644 packages/gittensory-miner/lib/update-check.js create mode 100644 packages/gittensory-miner/package.json create mode 100644 test/unit/miner-cli.test.ts create mode 100644 test/unit/support/miner-cli-harness.ts diff --git a/package-lock.json b/package-lock.json index a8e4c44f7a..c77919714c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2691,10 +2691,18 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@jsonbored/gittensory-engine": { + "resolved": "packages/gittensory-engine", + "link": true + }, "node_modules/@jsonbored/gittensory-mcp": { "resolved": "packages/gittensory-mcp", "link": true }, + "node_modules/@jsonbored/gittensory-miner": { + "resolved": "packages/gittensory-miner", + "link": true + }, "node_modules/@jsonbored/gittensory-ui": { "resolved": "apps/gittensory-ui", "link": true @@ -15474,6 +15482,14 @@ "zod": "^3.25.28 || ^4" } }, + "packages/gittensory-engine": { + "name": "@jsonbored/gittensory-engine", + "version": "0.1.0", + "license": "AGPL-3.0-only", + "engines": { + "node": ">=22.0.0" + } + }, "packages/gittensory-mcp": { "name": "@jsonbored/gittensory-mcp", "version": "0.6.0", @@ -15488,6 +15504,20 @@ "engines": { "node": ">=22.0.0" } + }, + "packages/gittensory-miner": { + "name": "@jsonbored/gittensory-miner", + "version": "0.1.0", + "license": "AGPL-3.0-only", + "dependencies": { + "@jsonbored/gittensory-engine": "0.1.0" + }, + "bin": { + "gittensory-miner": "bin/gittensory-miner.js" + }, + "engines": { + "node": ">=22.0.0" + } } } } diff --git a/package.json b/package.json index 5b707e9148..9c7c85140e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "db:migrate:remote": "wrangler d1 migrations apply gittensory --remote", "drizzle:generate": "drizzle-kit generate", "build:mcp": "npm --workspace @jsonbored/gittensory-mcp run build", + "build:miner": "npm --workspace @jsonbored/gittensory-engine run build && npm --workspace @jsonbored/gittensory-miner run build", "test:mcp-pack": "node scripts/check-mcp-package.mjs", "rees:install": "npm ci --prefix review-enrichment --prefer-offline --no-audit --no-fund", "rees:test": "npm run rees:install && npm --prefix review-enrichment test", @@ -60,7 +61,7 @@ "test:smoke:observability": "node scripts/smoke-observability-traces.mjs", "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node scripts/smoke-ui-browser.mjs", - "test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", + "test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", "test:release": "npm run test:ci && npm run changelog:check", "test:release:mcp": "npm run test:ci && npm run changelog:check:mcp", "test:watch": "vitest", diff --git a/packages/gittensory-engine/lib/index.d.ts b/packages/gittensory-engine/lib/index.d.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/gittensory-engine/lib/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/gittensory-engine/lib/index.js b/packages/gittensory-engine/lib/index.js new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/gittensory-engine/lib/index.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/gittensory-engine/package.json b/packages/gittensory-engine/package.json new file mode 100644 index 0000000000..972c7a8bcd --- /dev/null +++ b/packages/gittensory-engine/package.json @@ -0,0 +1,37 @@ +{ + "name": "@jsonbored/gittensory-engine", + "version": "0.1.0", + "license": "AGPL-3.0-only", + "type": "module", + "description": "Shared pure engine helpers for Gittensory miner and gate logic.", + "repository": { + "type": "git", + "url": "git+https://github.com/JSONbored/gittensory.git", + "directory": "packages/gittensory-engine" + }, + "homepage": "https://github.com/JSONbored/gittensory#readme", + "bugs": { + "url": "https://github.com/JSONbored/gittensory/issues" + }, + "keywords": [ + "gittensor", + "gittensory", + "engine", + "shared", + "miner" + ], + "publishConfig": { + "access": "public" + }, + "main": "./lib/index.js", + "exports": "./lib/index.js", + "files": [ + "lib" + ], + "scripts": { + "build": "node --check lib/index.js" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md new file mode 100644 index 0000000000..5e18d2552d --- /dev/null +++ b/packages/gittensory-miner/README.md @@ -0,0 +1,38 @@ +# @jsonbored/gittensory-miner + +Foundation CLI for the local Gittensory miner runtime. + +This package is the future home of the autonomous discover → analyze → plan → prepare → create → manage miner workflow. In this foundation phase it provides the package scaffold, a minimal CLI surface for `--help` and `--version`, and a non-blocking npm registry version nudge on startup. + +## Status + +Current scope is intentionally small: + +- workspace package wiring +- CLI entry point +- `--help` and `version` commands +- startup npm version nudge (override with `--no-update-check` or `GITTENSORY_MINER_NO_UPDATE_CHECK=1`) + +Real miner commands land in follow-up issues. + +## Install + +From a local checkout: + +```sh +npm install +npm --workspace @jsonbored/gittensory-miner run build +``` + +## Commands + +```sh +gittensory-miner --help +gittensory-miner help +gittensory-miner --version +gittensory-miner version +``` + +## Version check + +On every invocation the CLI starts an async npm registry lookup (5s timeout). When the installed package is behind `@jsonbored/gittensory-miner@latest`, it prints a one-line upgrade command to stderr without blocking or failing the requested command. Set `GITTENSORY_NPM_REGISTRY_URL` to point at a mirror, same as `@jsonbored/gittensory-mcp`. diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js new file mode 100755 index 0000000000..2eb438baad --- /dev/null +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { printHelp, printVersion, runCli } from "../lib/cli.js"; +import { resolveUpgradeCommand, startUpdateCheck } from "../lib/update-check.js"; + +const cliArgs = process.argv.slice(2); +const require = createRequire(import.meta.url); +const packageName = "@jsonbored/gittensory-miner"; +const packageVersion = require("../package.json").version; +const upgradeCommand = resolveUpgradeCommand(packageName); + +const updateCheck = startUpdateCheck(cliArgs, { packageName, packageVersion, upgradeCommand }); + +if (cliArgs.length === 0 || cliArgs.includes("--help") || cliArgs.includes("-h") || cliArgs[0] === "help") { + await updateCheck; + printHelp({ packageName }); + process.exit(0); +} + +if (cliArgs.includes("--version") || cliArgs.includes("-v") || cliArgs[0] === "version") { + await updateCheck; + printVersion({ packageName, packageVersion }); + process.exit(0); +} + +const exitCode = runCli(cliArgs, { packageName }); +await updateCheck; +process.exit(exitCode); diff --git a/packages/gittensory-miner/lib/cli.d.ts b/packages/gittensory-miner/lib/cli.d.ts new file mode 100644 index 0000000000..30a181d860 --- /dev/null +++ b/packages/gittensory-miner/lib/cli.d.ts @@ -0,0 +1,3 @@ +export function printVersion(input: { packageName: string; packageVersion: string }): void; +export function printHelp(input: { packageName: string }): void; +export function runCli(cliArgs: string[], input: { packageName: string }): number; diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js new file mode 100644 index 0000000000..91ccc42dee --- /dev/null +++ b/packages/gittensory-miner/lib/cli.js @@ -0,0 +1,28 @@ +export function printVersion(input) { + console.log(`${input.packageName}/${input.packageVersion} (node ${process.version})`); +} + +export function printHelp(input) { + console.log( + [ + input.packageName, + "", + "Foundation CLI for the local Gittensory miner runtime.", + "", + "Usage:", + " gittensory-miner --help", + " gittensory-miner --version", + " gittensory-miner help", + " gittensory-miner version", + "", + "Options:", + " --no-update-check Skip the npm registry version nudge (also GITTENSORY_MINER_NO_UPDATE_CHECK=1)", + ].join("\n"), + ); +} + +export function runCli(cliArgs, input) { + const command = cliArgs[0] ?? ""; + console.error(`Unknown command: ${command}. Run ${input.packageName} --help.`); + return 1; +} diff --git a/packages/gittensory-miner/lib/update-check.d.ts b/packages/gittensory-miner/lib/update-check.d.ts new file mode 100644 index 0000000000..a50b09a0a0 --- /dev/null +++ b/packages/gittensory-miner/lib/update-check.d.ts @@ -0,0 +1,26 @@ +export function resolveNpmRegistryUrl(env?: Record): string; +export function resolveUpgradeCommand(packageName?: string): string; +export function shouldSkipUpdateCheck(cliArgs: string[], env?: Record): boolean; +export function compareSemver(a: string, b: string): -1 | 0 | 1 | null; +export function fetchLatestPackageVersion(input: { + packageName: string; + npmRegistryUrl: string; + timeoutMs?: number; +}): Promise; +export function maybePrintUpdateNudge(input: { + packageName: string; + packageVersion: string; + npmRegistryUrl: string; + upgradeCommand: string; + timeoutMs?: number; +}): Promise; +export function startUpdateCheck( + cliArgs: string[], + input: { + packageName: string; + packageVersion: string; + upgradeCommand?: string; + env?: Record; + timeoutMs?: number; + }, +): Promise; diff --git a/packages/gittensory-miner/lib/update-check.js b/packages/gittensory-miner/lib/update-check.js new file mode 100644 index 0000000000..5361e795b1 --- /dev/null +++ b/packages/gittensory-miner/lib/update-check.js @@ -0,0 +1,97 @@ +const defaultPackageName = "@jsonbored/gittensory-miner"; + +export function resolveNpmRegistryUrl(env = process.env) { + return (env.GITTENSORY_NPM_REGISTRY_URL ?? "https://registry.npmjs.org").replace(/\/+$/, ""); +} + +export function resolveUpgradeCommand(packageName = defaultPackageName) { + return `npm install -g ${packageName}@latest`; +} + +export function shouldSkipUpdateCheck(cliArgs, env = process.env) { + if (/^(1|true|yes)$/i.test(env.GITTENSORY_MINER_NO_UPDATE_CHECK ?? "")) return true; + return cliArgs.includes("--no-update-check"); +} + +function parseSemver(version) { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(version ?? "").trim()); + if (!match) return null; + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] ?? null }; +} + +function comparePrerelease(a, b) { + const left = a.split("."); + const right = b.split("."); + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const leftId = left[index]; + const rightId = right[index]; + if (leftId === undefined) return -1; + if (rightId === undefined) return 1; + const leftNumeric = /^\d+$/.test(leftId); + const rightNumeric = /^\d+$/.test(rightId); + if (leftNumeric && rightNumeric) { + if (Number(leftId) !== Number(rightId)) return Number(leftId) < Number(rightId) ? -1 : 1; + } else if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1; + } else if (leftId !== rightId) { + return leftId < rightId ? -1 : 1; + } + } + return 0; +} + +export function compareSemver(a, b) { + const left = parseSemver(a); + const right = parseSemver(b); + if (!left || !right) return null; + for (const part of ["major", "minor", "patch"]) { + if (left[part] !== right[part]) return left[part] < right[part] ? -1 : 1; + } + if (left.prerelease === right.prerelease) return 0; + if (left.prerelease === null) return 1; + if (right.prerelease === null) return -1; + return comparePrerelease(left.prerelease, right.prerelease); +} + +export async function fetchLatestPackageVersion(input) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), input.timeoutMs ?? 5000); + const registrySlug = input.packageName.startsWith("@") ? input.packageName.replace("/", "%2F") : input.packageName; + const registryPath = `${input.npmRegistryUrl}/${registrySlug}/latest`; + try { + const response = await fetch(registryPath, { + signal: controller.signal, + headers: { accept: "application/json" }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || typeof payload.version !== "string") throw new Error("npm_latest_version_unavailable"); + return payload.version; + } finally { + clearTimeout(timeout); + } +} + +// Non-blocking startup nudge: prints one upgrade line when local is behind npm latest. +// Mirrors packages/gittensory-mcp/bin/gittensory-mcp.js packageVersion/npmRegistryUrl/upgradeCommand (#2331). +export async function maybePrintUpdateNudge(input) { + try { + const latestVersion = await fetchLatestPackageVersion(input); + const comparison = compareSemver(input.packageVersion, latestVersion); + if (comparison !== null && comparison < 0) { + process.stderr.write(`${input.upgradeCommand}\n`); + } + } catch { + // Offline or unreachable registry — never block or fail the CLI. + } +} + +export function startUpdateCheck(cliArgs, input) { + if (shouldSkipUpdateCheck(cliArgs, input.env)) return Promise.resolve(); + return maybePrintUpdateNudge({ + packageName: input.packageName, + packageVersion: input.packageVersion, + npmRegistryUrl: resolveNpmRegistryUrl(input.env), + upgradeCommand: input.upgradeCommand ?? resolveUpgradeCommand(input.packageName), + timeoutMs: input.timeoutMs, + }); +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json new file mode 100644 index 0000000000..c00ac2fb76 --- /dev/null +++ b/packages/gittensory-miner/package.json @@ -0,0 +1,42 @@ +{ + "name": "@jsonbored/gittensory-miner", + "version": "0.1.0", + "license": "AGPL-3.0-only", + "type": "module", + "description": "Foundation CLI for the local Gittensory miner runtime.", + "repository": { + "type": "git", + "url": "git+https://github.com/JSONbored/gittensory.git", + "directory": "packages/gittensory-miner" + }, + "homepage": "https://github.com/JSONbored/gittensory#readme", + "bugs": { + "url": "https://github.com/JSONbored/gittensory/issues" + }, + "keywords": [ + "gittensor", + "gittensory", + "miner", + "cli", + "agent" + ], + "publishConfig": { + "access": "public" + }, + "bin": { + "gittensory-miner": "bin/gittensory-miner.js" + }, + "files": [ + "bin", + "lib" + ], + "scripts": { + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js" + }, + "dependencies": { + "@jsonbored/gittensory-engine": "0.1.0" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts new file mode 100644 index 0000000000..4cc5a9907c --- /dev/null +++ b/test/unit/miner-cli.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { closeFixtureServer, runCapture, startRegistryFixture } from "./support/miner-cli-harness"; + +type MinerCli = typeof import("../../packages/gittensory-miner/lib/cli.js"); +type MinerUpdateCheck = typeof import("../../packages/gittensory-miner/lib/update-check.js"); + +let printHelp: MinerCli["printHelp"]; +let printVersion: MinerCli["printVersion"]; +let runCli: MinerCli["runCli"]; +let compareSemver: MinerUpdateCheck["compareSemver"]; +let fetchLatestPackageVersion: MinerUpdateCheck["fetchLatestPackageVersion"]; +let maybePrintUpdateNudge: MinerUpdateCheck["maybePrintUpdateNudge"]; +let resolveNpmRegistryUrl: MinerUpdateCheck["resolveNpmRegistryUrl"]; +let resolveUpgradeCommand: MinerUpdateCheck["resolveUpgradeCommand"]; +let shouldSkipUpdateCheck: MinerUpdateCheck["shouldSkipUpdateCheck"]; +let startUpdateCheck: MinerUpdateCheck["startUpdateCheck"]; + +beforeAll(async () => { + const cli = await import("../../packages/gittensory-miner/lib/cli.js"); + const updateCheck = await import("../../packages/gittensory-miner/lib/update-check.js"); + ({ printHelp, printVersion, runCli } = cli); + ({ + compareSemver, + fetchLatestPackageVersion, + maybePrintUpdateNudge, + resolveNpmRegistryUrl, + resolveUpgradeCommand, + shouldSkipUpdateCheck, + startUpdateCheck, + } = updateCheck); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await closeFixtureServer(); +}); + +describe("gittensory-miner CLI helpers", () => { + it("prints the package version with the node runtime", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + printVersion({ packageName: "@jsonbored/gittensory-miner", packageVersion: "0.1.0" }); + expect(log).toHaveBeenCalledWith(expect.stringContaining("@jsonbored/gittensory-miner/0.1.0")); + expect(log).toHaveBeenCalledWith(expect.stringContaining(process.version)); + }); + + it("prints help text with the supported commands", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + printHelp({ packageName: "@jsonbored/gittensory-miner" }); + const text = log.mock.calls[0]?.[0]; + expect(text).toContain("gittensory-miner --help"); + expect(text).toContain("gittensory-miner version"); + expect(text).toContain("--no-update-check"); + }); + + it("returns exit code 1 for unknown commands", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(runCli(["mystery"], { packageName: "@jsonbored/gittensory-miner" })).toBe(1); + expect(error).toHaveBeenCalledWith("Unknown command: mystery. Run @jsonbored/gittensory-miner --help."); + }); + + it("keeps the CLI version source aligned with package metadata", async () => { + const packageJson = await import("../../packages/gittensory-miner/package.json", { with: { type: "json" } }); + expect(packageJson.default.version).toBe("0.1.0"); + }); +}); + +describe("gittensory-miner startup update check (#2331)", () => { + it("mirrors the mcp npm registry and upgrade command conventions", () => { + expect(resolveNpmRegistryUrl({})).toBe("https://registry.npmjs.org"); + expect(resolveNpmRegistryUrl({ GITTENSORY_NPM_REGISTRY_URL: "https://registry.example.com/" })).toBe("https://registry.example.com"); + expect(resolveUpgradeCommand("@jsonbored/gittensory-miner")).toBe("npm install -g @jsonbored/gittensory-miner@latest"); + }); + + it("skips the check when --no-update-check or GITTENSORY_MINER_NO_UPDATE_CHECK=1 is set", () => { + expect(shouldSkipUpdateCheck(["--version", "--no-update-check"])).toBe(true); + expect(shouldSkipUpdateCheck(["version"], { GITTENSORY_MINER_NO_UPDATE_CHECK: "1" })).toBe(true); + expect(shouldSkipUpdateCheck(["version"], { GITTENSORY_MINER_NO_UPDATE_CHECK: "true" })).toBe(true); + expect(shouldSkipUpdateCheck(["version"], {})).toBe(false); + }); + + it("orders semver values the same way as gittensory-mcp", () => { + expect(compareSemver("0.1.0", "0.2.0")).toBe(-1); + expect(compareSemver("0.2.0", "0.1.0")).toBe(1); + expect(compareSemver("0.1.0", "0.1.0")).toBe(0); + expect(compareSemver("0.5.0", "0.5.0-rc.1")).toBe(1); + expect(compareSemver("0.6.0", "0.7.0-rc.1")).toBe(-1); + }); + + it("prints a one-line upgrade nudge when npm latest is newer", async () => { + const registryUrl = await startRegistryFixture({ latestVersion: "9.9.9" }); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await maybePrintUpdateNudge({ + packageName: "@jsonbored/gittensory-miner", + packageVersion: "0.1.0", + npmRegistryUrl: registryUrl, + upgradeCommand: "npm install -g @jsonbored/gittensory-miner@latest", + }); + expect(stderr).toHaveBeenCalledWith("npm install -g @jsonbored/gittensory-miner@latest\n"); + }); + + it("prints nothing when the installed version matches npm latest", async () => { + const registryUrl = await startRegistryFixture({ latestVersion: "0.1.0" }); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await maybePrintUpdateNudge({ + packageName: "@jsonbored/gittensory-miner", + packageVersion: "0.1.0", + npmRegistryUrl: registryUrl, + upgradeCommand: "npm install -g @jsonbored/gittensory-miner@latest", + }); + expect(stderr).not.toHaveBeenCalled(); + }); + + it("swallows registry failures without throwing", async () => { + const registryUrl = await startRegistryFixture({ npmStatus: 500 }); + await expect( + maybePrintUpdateNudge({ + packageName: "@jsonbored/gittensory-miner", + packageVersion: "0.1.0", + npmRegistryUrl: registryUrl, + upgradeCommand: "npm install -g @jsonbored/gittensory-miner@latest", + }), + ).resolves.toBeUndefined(); + }); + + it("does not throw when fetchLatestPackageVersion cannot reach the registry", async () => { + const registryUrl = await startRegistryFixture({ npmStatus: 503 }); + await expect( + fetchLatestPackageVersion({ + packageName: "@jsonbored/gittensory-miner", + npmRegistryUrl: registryUrl, + }), + ).rejects.toThrow("npm_latest_version_unavailable"); + }); + + it("startUpdateCheck resolves immediately when opted out", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + await startUpdateCheck(["--no-update-check"], { + packageName: "@jsonbored/gittensory-miner", + packageVersion: "0.1.0", + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("startUpdateCheck prints the nudge when npm latest is newer", async () => { + const registryUrl = await startRegistryFixture({ latestVersion: "9.9.9" }); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await startUpdateCheck(["--version"], { + packageName: "@jsonbored/gittensory-miner", + packageVersion: "0.1.0", + env: { GITTENSORY_NPM_REGISTRY_URL: registryUrl }, + }); + expect(stderr).toHaveBeenCalledWith("npm install -g @jsonbored/gittensory-miner@latest\n"); + }); + + it("startUpdateCheck stays silent when npm latest matches the installed version", async () => { + const registryUrl = await startRegistryFixture({ latestVersion: "0.1.0" }); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await startUpdateCheck(["--version"], { + packageName: "@jsonbored/gittensory-miner", + packageVersion: "0.1.0", + env: { GITTENSORY_NPM_REGISTRY_URL: registryUrl }, + }); + expect(stderr).not.toHaveBeenCalled(); + }); + + it("startUpdateCheck swallows registry failures without throwing", async () => { + const registryUrl = await startRegistryFixture({ npmStatus: 500 }); + await expect( + startUpdateCheck(["--version"], { + packageName: "@jsonbored/gittensory-miner", + packageVersion: "0.1.0", + env: { GITTENSORY_NPM_REGISTRY_URL: registryUrl }, + }), + ).resolves.toBeUndefined(); + }); + + it("serves --version without blocking when update checks are disabled", () => { + const output = runCapture(["--version", "--no-update-check"]); + expect(output).toContain("@jsonbored/gittensory-miner/0.1.0"); + }); +}); diff --git a/test/unit/support/miner-cli-harness.ts b/test/unit/support/miner-cli-harness.ts new file mode 100644 index 0000000000..861d7a9e33 --- /dev/null +++ b/test/unit/support/miner-cli-harness.ts @@ -0,0 +1,83 @@ +import { execFile, execFileSync, spawnSync } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export const bin = join(process.cwd(), "packages/gittensory-miner/bin/gittensory-miner.js"); +let server: Server | null = null; + +export async function closeFixtureServer() { + if (server) await new Promise((resolve) => server?.close(() => resolve())); + server = null; +} + +export function run(args: string[], env: Record = {}) { + return execFileSync("node", [bin, ...args], { + encoding: "utf8", + env: { + ...process.env, + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +export function runCapture(args: string[], env: Record = {}) { + const result = spawnSync("node", [bin, ...args], { + encoding: "utf8", + env: { + ...process.env, + ...env, + }, + }); + return `${result.stdout ?? ""}${result.stderr ?? ""}`; +} + +export function runAsync(args: string[], env: Record = {}) { + return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFile( + "node", + [bin, ...args], + { + encoding: "utf8", + env: { + ...process.env, + ...env, + }, + }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(`${error.message}\n${stderr}`)); + return; + } + resolve({ stdout, stderr }); + }, + ); + }); +} + +export async function startRegistryFixture(options: { latestVersion?: string; npmStatus?: number } = {}) { + server = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url && request.url.includes("gittensory-miner/latest")) { + if (options.npmStatus && options.npmStatus >= 400) { + response.statusCode = options.npmStatus; + response.end(JSON.stringify({ error: "registry_error" })); + return; + } + response.end(JSON.stringify({ version: options.latestVersion ?? "0.1.0" })); + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: "not_found" })); + }); + await new Promise((resolve) => server?.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("fixture server failed to bind"); + return `http://127.0.0.1:${address.port}`; +} + +export function tempEnvPrefix() { + return mkdtempSync(join(tmpdir(), "gittensory-miner-cli-")); +} From b14751466703576c74ca6a4f128da1ce8235c56a Mon Sep 17 00:00:00 2001 From: andriypolandki <=> Date: Wed, 1 Jul 2026 17:10:55 -0700 Subject: [PATCH 2/5] fix(miner-packaging): serve help and version before registry update check --- .../gittensory-miner/bin/gittensory-miner.js | 26 ++++- test/unit/miner-cli.test.ts | 104 ++++++++++++++---- test/unit/support/miner-cli-harness.ts | 48 +++++--- 3 files changed, 139 insertions(+), 39 deletions(-) diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 2eb438baad..b6b4032df8 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -1,7 +1,10 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { printHelp, printVersion, runCli } from "../lib/cli.js"; -import { resolveUpgradeCommand, startUpdateCheck } from "../lib/update-check.js"; +import { + resolveUpgradeCommand, + startUpdateCheck, +} from "../lib/update-check.js"; const cliArgs = process.argv.slice(2); const require = createRequire(import.meta.url); @@ -9,16 +12,27 @@ const packageName = "@jsonbored/gittensory-miner"; const packageVersion = require("../package.json").version; const upgradeCommand = resolveUpgradeCommand(packageName); -const updateCheck = startUpdateCheck(cliArgs, { packageName, packageVersion, upgradeCommand }); +const updateCheck = startUpdateCheck(cliArgs, { + packageName, + packageVersion, + upgradeCommand, +}); -if (cliArgs.length === 0 || cliArgs.includes("--help") || cliArgs.includes("-h") || cliArgs[0] === "help") { - await updateCheck; +if ( + cliArgs.length === 0 || + cliArgs.includes("--help") || + cliArgs.includes("-h") || + cliArgs[0] === "help" +) { printHelp({ packageName }); process.exit(0); } -if (cliArgs.includes("--version") || cliArgs.includes("-v") || cliArgs[0] === "version") { - await updateCheck; +if ( + cliArgs.includes("--version") || + cliArgs.includes("-v") || + cliArgs[0] === "version" +) { printVersion({ packageName, packageVersion }); process.exit(0); } diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts index 4cc5a9907c..70da3ee557 100644 --- a/test/unit/miner-cli.test.ts +++ b/test/unit/miner-cli.test.ts @@ -1,8 +1,13 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { closeFixtureServer, runCapture, startRegistryFixture } from "./support/miner-cli-harness"; +import { + closeFixtureServer, + runCapture, + startRegistryFixture, +} from "./support/miner-cli-harness"; type MinerCli = typeof import("../../packages/gittensory-miner/lib/cli.js"); -type MinerUpdateCheck = typeof import("../../packages/gittensory-miner/lib/update-check.js"); +type MinerUpdateCheck = + typeof import("../../packages/gittensory-miner/lib/update-check.js"); let printHelp: MinerCli["printHelp"]; let printVersion: MinerCli["printVersion"]; @@ -17,7 +22,8 @@ let startUpdateCheck: MinerUpdateCheck["startUpdateCheck"]; beforeAll(async () => { const cli = await import("../../packages/gittensory-miner/lib/cli.js"); - const updateCheck = await import("../../packages/gittensory-miner/lib/update-check.js"); + const updateCheck = + await import("../../packages/gittensory-miner/lib/update-check.js"); ({ printHelp, printVersion, runCli } = cli); ({ compareSemver, @@ -38,8 +44,13 @@ afterEach(async () => { describe("gittensory-miner CLI helpers", () => { it("prints the package version with the node runtime", () => { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - printVersion({ packageName: "@jsonbored/gittensory-miner", packageVersion: "0.1.0" }); - expect(log).toHaveBeenCalledWith(expect.stringContaining("@jsonbored/gittensory-miner/0.1.0")); + printVersion({ + packageName: "@jsonbored/gittensory-miner", + packageVersion: "0.1.0", + }); + expect(log).toHaveBeenCalledWith( + expect.stringContaining("@jsonbored/gittensory-miner/0.1.0"), + ); expect(log).toHaveBeenCalledWith(expect.stringContaining(process.version)); }); @@ -53,13 +64,22 @@ describe("gittensory-miner CLI helpers", () => { }); it("returns exit code 1 for unknown commands", () => { - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); - expect(runCli(["mystery"], { packageName: "@jsonbored/gittensory-miner" })).toBe(1); - expect(error).toHaveBeenCalledWith("Unknown command: mystery. Run @jsonbored/gittensory-miner --help."); + const error = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + expect( + runCli(["mystery"], { packageName: "@jsonbored/gittensory-miner" }), + ).toBe(1); + expect(error).toHaveBeenCalledWith( + "Unknown command: mystery. Run @jsonbored/gittensory-miner --help.", + ); }); it("keeps the CLI version source aligned with package metadata", async () => { - const packageJson = await import("../../packages/gittensory-miner/package.json", { with: { type: "json" } }); + const packageJson = await import( + "../../packages/gittensory-miner/package.json", + { with: { type: "json" } } + ); expect(packageJson.default.version).toBe("0.1.0"); }); }); @@ -67,14 +87,30 @@ describe("gittensory-miner CLI helpers", () => { describe("gittensory-miner startup update check (#2331)", () => { it("mirrors the mcp npm registry and upgrade command conventions", () => { expect(resolveNpmRegistryUrl({})).toBe("https://registry.npmjs.org"); - expect(resolveNpmRegistryUrl({ GITTENSORY_NPM_REGISTRY_URL: "https://registry.example.com/" })).toBe("https://registry.example.com"); - expect(resolveUpgradeCommand("@jsonbored/gittensory-miner")).toBe("npm install -g @jsonbored/gittensory-miner@latest"); + expect( + resolveNpmRegistryUrl({ + GITTENSORY_NPM_REGISTRY_URL: "https://registry.example.com/", + }), + ).toBe("https://registry.example.com"); + expect(resolveUpgradeCommand("@jsonbored/gittensory-miner")).toBe( + "npm install -g @jsonbored/gittensory-miner@latest", + ); }); it("skips the check when --no-update-check or GITTENSORY_MINER_NO_UPDATE_CHECK=1 is set", () => { - expect(shouldSkipUpdateCheck(["--version", "--no-update-check"])).toBe(true); - expect(shouldSkipUpdateCheck(["version"], { GITTENSORY_MINER_NO_UPDATE_CHECK: "1" })).toBe(true); - expect(shouldSkipUpdateCheck(["version"], { GITTENSORY_MINER_NO_UPDATE_CHECK: "true" })).toBe(true); + expect(shouldSkipUpdateCheck(["--version", "--no-update-check"])).toBe( + true, + ); + expect( + shouldSkipUpdateCheck(["version"], { + GITTENSORY_MINER_NO_UPDATE_CHECK: "1", + }), + ).toBe(true); + expect( + shouldSkipUpdateCheck(["version"], { + GITTENSORY_MINER_NO_UPDATE_CHECK: "true", + }), + ).toBe(true); expect(shouldSkipUpdateCheck(["version"], {})).toBe(false); }); @@ -88,19 +124,25 @@ describe("gittensory-miner startup update check (#2331)", () => { it("prints a one-line upgrade nudge when npm latest is newer", async () => { const registryUrl = await startRegistryFixture({ latestVersion: "9.9.9" }); - const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); await maybePrintUpdateNudge({ packageName: "@jsonbored/gittensory-miner", packageVersion: "0.1.0", npmRegistryUrl: registryUrl, upgradeCommand: "npm install -g @jsonbored/gittensory-miner@latest", }); - expect(stderr).toHaveBeenCalledWith("npm install -g @jsonbored/gittensory-miner@latest\n"); + expect(stderr).toHaveBeenCalledWith( + "npm install -g @jsonbored/gittensory-miner@latest\n", + ); }); it("prints nothing when the installed version matches npm latest", async () => { const registryUrl = await startRegistryFixture({ latestVersion: "0.1.0" }); - const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); await maybePrintUpdateNudge({ packageName: "@jsonbored/gittensory-miner", packageVersion: "0.1.0", @@ -143,18 +185,24 @@ describe("gittensory-miner startup update check (#2331)", () => { it("startUpdateCheck prints the nudge when npm latest is newer", async () => { const registryUrl = await startRegistryFixture({ latestVersion: "9.9.9" }); - const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); await startUpdateCheck(["--version"], { packageName: "@jsonbored/gittensory-miner", packageVersion: "0.1.0", env: { GITTENSORY_NPM_REGISTRY_URL: registryUrl }, }); - expect(stderr).toHaveBeenCalledWith("npm install -g @jsonbored/gittensory-miner@latest\n"); + expect(stderr).toHaveBeenCalledWith( + "npm install -g @jsonbored/gittensory-miner@latest\n", + ); }); it("startUpdateCheck stays silent when npm latest matches the installed version", async () => { const registryUrl = await startRegistryFixture({ latestVersion: "0.1.0" }); - const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); await startUpdateCheck(["--version"], { packageName: "@jsonbored/gittensory-miner", packageVersion: "0.1.0", @@ -178,4 +226,20 @@ describe("gittensory-miner startup update check (#2331)", () => { const output = runCapture(["--version", "--no-update-check"]); expect(output).toContain("@jsonbored/gittensory-miner/0.1.0"); }); + + it("serves --help immediately without waiting for a slow registry check", async () => { + const registryUrl = await startRegistryFixture({ + latestVersion: "9.9.9", + delayMs: 10_000, + }); + const startedAt = Date.now(); + const output = runCapture(["--help"], { + GITTENSORY_NPM_REGISTRY_URL: registryUrl, + }); + expect(Date.now() - startedAt).toBeLessThan(2000); + expect(output).toContain("gittensory-miner --help"); + expect(output).not.toContain( + "npm install -g @jsonbored/gittensory-miner@latest", + ); + }); }); diff --git a/test/unit/support/miner-cli-harness.ts b/test/unit/support/miner-cli-harness.ts index 861d7a9e33..82584791f4 100644 --- a/test/unit/support/miner-cli-harness.ts +++ b/test/unit/support/miner-cli-harness.ts @@ -4,11 +4,15 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -export const bin = join(process.cwd(), "packages/gittensory-miner/bin/gittensory-miner.js"); +export const bin = join( + process.cwd(), + "packages/gittensory-miner/bin/gittensory-miner.js", +); let server: Server | null = null; export async function closeFixtureServer() { - if (server) await new Promise((resolve) => server?.close(() => resolve())); + if (server) + await new Promise((resolve) => server?.close(() => resolve())); server = null; } @@ -57,24 +61,42 @@ export function runAsync(args: string[], env: Record = {}) { }); } -export async function startRegistryFixture(options: { latestVersion?: string; npmStatus?: number } = {}) { +export async function startRegistryFixture( + options: { + latestVersion?: string; + npmStatus?: number; + delayMs?: number; + } = {}, +) { server = createServer((request, response) => { - response.setHeader("content-type", "application/json"); - if (request.url && request.url.includes("gittensory-miner/latest")) { - if (options.npmStatus && options.npmStatus >= 400) { - response.statusCode = options.npmStatus; - response.end(JSON.stringify({ error: "registry_error" })); + const respond = () => { + response.setHeader("content-type", "application/json"); + if (request.url && request.url.includes("gittensory-miner/latest")) { + if (options.npmStatus && options.npmStatus >= 400) { + response.statusCode = options.npmStatus; + response.end(JSON.stringify({ error: "registry_error" })); + return; + } + response.end( + JSON.stringify({ version: options.latestVersion ?? "0.1.0" }), + ); return; } - response.end(JSON.stringify({ version: options.latestVersion ?? "0.1.0" })); + response.statusCode = 404; + response.end(JSON.stringify({ error: "not_found" })); + }; + if (options.delayMs && options.delayMs > 0) { + setTimeout(respond, options.delayMs); return; } - response.statusCode = 404; - response.end(JSON.stringify({ error: "not_found" })); + respond(); }); - await new Promise((resolve) => server?.listen(0, "127.0.0.1", () => resolve())); + await new Promise((resolve) => + server?.listen(0, "127.0.0.1", () => resolve()), + ); const address = server.address(); - if (!address || typeof address === "string") throw new Error("fixture server failed to bind"); + if (!address || typeof address === "string") + throw new Error("fixture server failed to bind"); return `http://127.0.0.1:${address.port}`; } From 0c693b4aeaaa0894d69977148d2856c6ffafa2ca Mon Sep 17 00:00:00 2001 From: andriypolandki <=> Date: Wed, 1 Jul 2026 17:16:45 -0700 Subject: [PATCH 3/5] resolve security finding --- packages/gittensory-miner/lib/update-check.js | 60 ++++++++++++++++--- test/unit/miner-cli.test.ts | 36 +++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/gittensory-miner/lib/update-check.js b/packages/gittensory-miner/lib/update-check.js index 5361e795b1..07aae70f46 100644 --- a/packages/gittensory-miner/lib/update-check.js +++ b/packages/gittensory-miner/lib/update-check.js @@ -1,7 +1,38 @@ const defaultPackageName = "@jsonbored/gittensory-miner"; +const defaultNpmRegistryUrl = "https://registry.npmjs.org"; + +function isLocalRegistryHost(hostname) { + const normalized = hostname.toLowerCase().replace(/\.$/, ""); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "[::1]" + ); +} export function resolveNpmRegistryUrl(env = process.env) { - return (env.GITTENSORY_NPM_REGISTRY_URL ?? "https://registry.npmjs.org").replace(/\/+$/, ""); + const raw = env.GITTENSORY_NPM_REGISTRY_URL?.trim(); + if (!raw) return defaultNpmRegistryUrl; + + let url; + try { + url = new URL(raw); + } catch { + return defaultNpmRegistryUrl; + } + + if (url.username || url.password || url.search || url.hash || !url.hostname) { + return defaultNpmRegistryUrl; + } + + const local = isLocalRegistryHost(url.hostname); + if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) { + return defaultNpmRegistryUrl; + } + + const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""); + return `${url.origin}${path}`; } export function resolveUpgradeCommand(packageName = defaultPackageName) { @@ -9,14 +40,22 @@ export function resolveUpgradeCommand(packageName = defaultPackageName) { } export function shouldSkipUpdateCheck(cliArgs, env = process.env) { - if (/^(1|true|yes)$/i.test(env.GITTENSORY_MINER_NO_UPDATE_CHECK ?? "")) return true; + if (/^(1|true|yes)$/i.test(env.GITTENSORY_MINER_NO_UPDATE_CHECK ?? "")) + return true; return cliArgs.includes("--no-update-check"); } function parseSemver(version) { - const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(version ?? "").trim()); + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec( + String(version ?? "").trim(), + ); if (!match) return null; - return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] ?? null }; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null, + }; } function comparePrerelease(a, b) { @@ -30,7 +69,8 @@ function comparePrerelease(a, b) { const leftNumeric = /^\d+$/.test(leftId); const rightNumeric = /^\d+$/.test(rightId); if (leftNumeric && rightNumeric) { - if (Number(leftId) !== Number(rightId)) return Number(leftId) < Number(rightId) ? -1 : 1; + if (Number(leftId) !== Number(rightId)) + return Number(leftId) < Number(rightId) ? -1 : 1; } else if (leftNumeric !== rightNumeric) { return leftNumeric ? -1 : 1; } else if (leftId !== rightId) { @@ -56,7 +96,9 @@ export function compareSemver(a, b) { export async function fetchLatestPackageVersion(input) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), input.timeoutMs ?? 5000); - const registrySlug = input.packageName.startsWith("@") ? input.packageName.replace("/", "%2F") : input.packageName; + const registrySlug = input.packageName.startsWith("@") + ? input.packageName.replace("/", "%2F") + : input.packageName; const registryPath = `${input.npmRegistryUrl}/${registrySlug}/latest`; try { const response = await fetch(registryPath, { @@ -64,7 +106,8 @@ export async function fetchLatestPackageVersion(input) { headers: { accept: "application/json" }, }); const payload = await response.json().catch(() => ({})); - if (!response.ok || typeof payload.version !== "string") throw new Error("npm_latest_version_unavailable"); + if (!response.ok || typeof payload.version !== "string") + throw new Error("npm_latest_version_unavailable"); return payload.version; } finally { clearTimeout(timeout); @@ -91,7 +134,8 @@ export function startUpdateCheck(cliArgs, input) { packageName: input.packageName, packageVersion: input.packageVersion, npmRegistryUrl: resolveNpmRegistryUrl(input.env), - upgradeCommand: input.upgradeCommand ?? resolveUpgradeCommand(input.packageName), + upgradeCommand: + input.upgradeCommand ?? resolveUpgradeCommand(input.packageName), timeoutMs: input.timeoutMs, }); } diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts index 70da3ee557..b95f7b9ecf 100644 --- a/test/unit/miner-cli.test.ts +++ b/test/unit/miner-cli.test.ts @@ -97,6 +97,42 @@ describe("gittensory-miner startup update check (#2331)", () => { ); }); + it("falls back to the default npm registry for unsafe or invalid registry URLs", () => { + expect( + resolveNpmRegistryUrl({ + GITTENSORY_NPM_REGISTRY_URL: "file:///etc/passwd", + }), + ).toBe("https://registry.npmjs.org"); + expect( + resolveNpmRegistryUrl({ + GITTENSORY_NPM_REGISTRY_URL: "http://169.254.169.254/", + }), + ).toBe("https://registry.npmjs.org"); + expect( + resolveNpmRegistryUrl({ + GITTENSORY_NPM_REGISTRY_URL: "https://user:pass@registry.example.com/", + }), + ).toBe("https://registry.npmjs.org"); + expect( + resolveNpmRegistryUrl({ + GITTENSORY_NPM_REGISTRY_URL: "not-a-url", + }), + ).toBe("https://registry.npmjs.org"); + }); + + it("allows http registry URLs only on local loopback hosts", () => { + expect( + resolveNpmRegistryUrl({ + GITTENSORY_NPM_REGISTRY_URL: "http://127.0.0.1:4873/", + }), + ).toBe("http://127.0.0.1:4873"); + expect( + resolveNpmRegistryUrl({ + GITTENSORY_NPM_REGISTRY_URL: "http://localhost:4873/", + }), + ).toBe("http://localhost:4873"); + }); + it("skips the check when --no-update-check or GITTENSORY_MINER_NO_UPDATE_CHECK=1 is set", () => { expect(shouldSkipUpdateCheck(["--version", "--no-update-check"])).toBe( true, From ce86b33a4ad4a7ffc73bd8a2dbafce2ff2fc24f1 Mon Sep 17 00:00:00 2001 From: andriypolandki <=> Date: Wed, 1 Jul 2026 17:26:54 -0700 Subject: [PATCH 4/5] fixed. unknown commands no longer wait on the registry lookup --- .../gittensory-miner/bin/gittensory-miner.js | 3 +-- test/unit/miner-cli.test.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index b6b4032df8..1bda4c254d 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -12,7 +12,7 @@ const packageName = "@jsonbored/gittensory-miner"; const packageVersion = require("../package.json").version; const upgradeCommand = resolveUpgradeCommand(packageName); -const updateCheck = startUpdateCheck(cliArgs, { +startUpdateCheck(cliArgs, { packageName, packageVersion, upgradeCommand, @@ -38,5 +38,4 @@ if ( } const exitCode = runCli(cliArgs, { packageName }); -await updateCheck; process.exit(exitCode); diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts index b95f7b9ecf..0c1087c7b9 100644 --- a/test/unit/miner-cli.test.ts +++ b/test/unit/miner-cli.test.ts @@ -1,5 +1,7 @@ +import { spawnSync } from "node:child_process"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { + bin, closeFixtureServer, runCapture, startRegistryFixture, @@ -278,4 +280,22 @@ describe("gittensory-miner startup update check (#2331)", () => { "npm install -g @jsonbored/gittensory-miner@latest", ); }); + + it("returns unknown-command errors immediately without waiting for a slow registry check", async () => { + const registryUrl = await startRegistryFixture({ + latestVersion: "9.9.9", + delayMs: 10_000, + }); + const startedAt = Date.now(); + const result = spawnSync("node", [bin, "mystery"], { + encoding: "utf8", + env: { + ...process.env, + GITTENSORY_NPM_REGISTRY_URL: registryUrl, + }, + }); + expect(Date.now() - startedAt).toBeLessThan(2000); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Unknown command: mystery"); + }); }); From ea52776c222998eb64e2c34c9e3070259fc2358a Mon Sep 17 00:00:00 2001 From: andriypolandki <=> Date: Wed, 1 Jul 2026 21:48:13 -0700 Subject: [PATCH 5/5] add await updateCheck --- .../gittensory-miner/bin/gittensory-miner.js | 7 ++++- .../gittensory-miner/lib/update-check.d.ts | 14 +++++++-- packages/gittensory-miner/lib/update-check.js | 14 +++++++++ test/unit/miner-cli.test.ts | 31 +++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 1bda4c254d..414d619ca2 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -2,6 +2,7 @@ import { createRequire } from "node:module"; import { printHelp, printVersion, runCli } from "../lib/cli.js"; import { + awaitOpportunisticUpdateCheck, resolveUpgradeCommand, startUpdateCheck, } from "../lib/update-check.js"; @@ -12,10 +13,11 @@ const packageName = "@jsonbored/gittensory-miner"; const packageVersion = require("../package.json").version; const upgradeCommand = resolveUpgradeCommand(packageName); -startUpdateCheck(cliArgs, { +const updateCheck = startUpdateCheck(cliArgs, { packageName, packageVersion, upgradeCommand, + env: process.env, }); if ( @@ -25,6 +27,7 @@ if ( cliArgs[0] === "help" ) { printHelp({ packageName }); + await awaitOpportunisticUpdateCheck(updateCheck); process.exit(0); } @@ -34,8 +37,10 @@ if ( cliArgs[0] === "version" ) { printVersion({ packageName, packageVersion }); + await awaitOpportunisticUpdateCheck(updateCheck); process.exit(0); } const exitCode = runCli(cliArgs, { packageName }); +await awaitOpportunisticUpdateCheck(updateCheck); process.exit(exitCode); diff --git a/packages/gittensory-miner/lib/update-check.d.ts b/packages/gittensory-miner/lib/update-check.d.ts index a50b09a0a0..bb12aa28b2 100644 --- a/packages/gittensory-miner/lib/update-check.d.ts +++ b/packages/gittensory-miner/lib/update-check.d.ts @@ -1,6 +1,11 @@ -export function resolveNpmRegistryUrl(env?: Record): string; +export function resolveNpmRegistryUrl( + env?: Record, +): string; export function resolveUpgradeCommand(packageName?: string): string; -export function shouldSkipUpdateCheck(cliArgs: string[], env?: Record): boolean; +export function shouldSkipUpdateCheck( + cliArgs: string[], + env?: Record, +): boolean; export function compareSemver(a: string, b: string): -1 | 0 | 1 | null; export function fetchLatestPackageVersion(input: { packageName: string; @@ -24,3 +29,8 @@ export function startUpdateCheck( timeoutMs?: number; }, ): Promise; +export const updateCheckExitGraceMs: number; +export function awaitOpportunisticUpdateCheck( + updateCheck: Promise, + graceMs?: number, +): Promise; diff --git a/packages/gittensory-miner/lib/update-check.js b/packages/gittensory-miner/lib/update-check.js index 07aae70f46..4839a90e41 100644 --- a/packages/gittensory-miner/lib/update-check.js +++ b/packages/gittensory-miner/lib/update-check.js @@ -139,3 +139,17 @@ export function startUpdateCheck(cliArgs, input) { timeoutMs: input.timeoutMs, }); } + +export const updateCheckExitGraceMs = 250; + +// After command output is printed, give a fast registry response time to emit the nudge +// without waiting for the full lookup timeout on slow/offline registries. +export async function awaitOpportunisticUpdateCheck( + updateCheck, + graceMs = updateCheckExitGraceMs, +) { + await Promise.race([ + updateCheck.catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, graceMs)), + ]); +} diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts index 0c1087c7b9..d502bac0c3 100644 --- a/test/unit/miner-cli.test.ts +++ b/test/unit/miner-cli.test.ts @@ -21,6 +21,7 @@ let resolveNpmRegistryUrl: MinerUpdateCheck["resolveNpmRegistryUrl"]; let resolveUpgradeCommand: MinerUpdateCheck["resolveUpgradeCommand"]; let shouldSkipUpdateCheck: MinerUpdateCheck["shouldSkipUpdateCheck"]; let startUpdateCheck: MinerUpdateCheck["startUpdateCheck"]; +let awaitOpportunisticUpdateCheck: MinerUpdateCheck["awaitOpportunisticUpdateCheck"]; beforeAll(async () => { const cli = await import("../../packages/gittensory-miner/lib/cli.js"); @@ -35,6 +36,7 @@ beforeAll(async () => { resolveUpgradeCommand, shouldSkipUpdateCheck, startUpdateCheck, + awaitOpportunisticUpdateCheck, } = updateCheck); }); @@ -260,6 +262,35 @@ describe("gittensory-miner startup update check (#2331)", () => { ).resolves.toBeUndefined(); }); + it("awaitOpportunisticUpdateCheck waits for a fast update check but caps slow lookups", async () => { + let resolved = false; + const fastCheck = Promise.resolve().then(() => { + resolved = true; + }); + await awaitOpportunisticUpdateCheck(fastCheck, 250); + expect(resolved).toBe(true); + + const startedAt = Date.now(); + await awaitOpportunisticUpdateCheck(new Promise(() => undefined), 50); + expect(Date.now() - startedAt).toBeLessThan(200); + }); + + it("awaitOpportunisticUpdateCheck lets a fast update check finish before exit", async () => { + const registryUrl = await startRegistryFixture({ latestVersion: "9.9.9" }); + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + const updateCheck = startUpdateCheck(["mystery"], { + packageName: "@jsonbored/gittensory-miner", + packageVersion: "0.1.0", + env: { GITTENSORY_NPM_REGISTRY_URL: registryUrl }, + }); + await awaitOpportunisticUpdateCheck(updateCheck); + expect(stderr).toHaveBeenCalledWith( + "npm install -g @jsonbored/gittensory-miner@latest\n", + ); + }); + it("serves --version without blocking when update checks are disabled", () => { const output = runCapture(["--version", "--no-update-check"]); expect(output).toContain("@jsonbored/gittensory-miner/0.1.0");