diff --git a/package-lock.json b/package-lock.json index 93c5de32d7..c77919714c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2699,6 +2699,10 @@ "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 @@ -15500,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-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..414d619ca2 --- /dev/null +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { printHelp, printVersion, runCli } from "../lib/cli.js"; +import { + awaitOpportunisticUpdateCheck, + 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, + env: process.env, +}); + +if ( + cliArgs.length === 0 || + cliArgs.includes("--help") || + cliArgs.includes("-h") || + cliArgs[0] === "help" +) { + printHelp({ packageName }); + await awaitOpportunisticUpdateCheck(updateCheck); + process.exit(0); +} + +if ( + cliArgs.includes("--version") || + cliArgs.includes("-v") || + 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/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..bb12aa28b2 --- /dev/null +++ b/packages/gittensory-miner/lib/update-check.d.ts @@ -0,0 +1,36 @@ +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; +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 new file mode 100644 index 0000000000..4839a90e41 --- /dev/null +++ b/packages/gittensory-miner/lib/update-check.js @@ -0,0 +1,155 @@ +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) { + 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) { + 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, + }); +} + +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/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..d502bac0c3 --- /dev/null +++ b/test/unit/miner-cli.test.ts @@ -0,0 +1,332 @@ +import { spawnSync } from "node:child_process"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + bin, + 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"]; +let awaitOpportunisticUpdateCheck: MinerUpdateCheck["awaitOpportunisticUpdateCheck"]; + +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, + awaitOpportunisticUpdateCheck, + } = 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("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, + ); + 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("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"); + }); + + 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", + ); + }); + + 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"); + }); +}); diff --git a/test/unit/support/miner-cli-harness.ts b/test/unit/support/miner-cli-harness.ts new file mode 100644 index 0000000000..82584791f4 --- /dev/null +++ b/test/unit/support/miner-cli-harness.ts @@ -0,0 +1,105 @@ +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; + delayMs?: number; + } = {}, +) { + server = createServer((request, response) => { + 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.statusCode = 404; + response.end(JSON.stringify({ error: "not_found" })); + }; + if (options.delayMs && options.delayMs > 0) { + setTimeout(respond, options.delayMs); + return; + } + respond(); + }); + 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-")); +}